普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月15日首页

组件拆分重构 App.vue

作者 前端付豪
2026年3月15日 07:32

先这样拆解 不然越来越乱

src/
  components/
    StudentBar.vue
    TabNav.vue
    SolvePanel.vue
    HistoryPanel.vue
    WrongPanel.vue

1)新增 src/components/StudentBar.vue

<template>
  <div class="student-bar">
    <select
      :value="currentStudentId"
      class="student-select"
      @change="onStudentChange"
    >
      <option v-for="item in studentList" :key="item.id" :value="item.id">
        {{ item.name }}
      </option>
    </select>

    <input
      :value="newStudentName"
      class="student-input"
      placeholder="输入新学生姓名"
      @input="onNameInput"
    />

    <button class="retry-btn" @click="$emit('create-student')">
      新增学生
    </button>

    <button class="wrong-btn" @click="$emit('export-report')">
      导出练习单
    </button>
  </div>
</template>

<script setup lang="ts">
import type { StudentItem } from '../api/math'

defineProps<{
  studentList: StudentItem[]
  currentStudentId: number
  newStudentName: string
}>()

const emit = defineEmits<{
  (e: 'update:currentStudentId', value: number): void
  (e: 'update:newStudentName', value: string): void
  (e: 'student-change'): void
  (e: 'create-student'): void
  (e: 'export-report'): void
}>()

const onStudentChange = (event: Event) => {
  const value = Number((event.target as HTMLSelectElement).value)
  emit('update:currentStudentId', value)
  emit('student-change')
}

const onNameInput = (event: Event) => {
  emit('update:newStudentName', (event.target as HTMLInputElement).value)
}
</script>

<style scoped>
.student-bar {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 20px;
}

.student-select,
.student-input {
  height: 40px;
  padding: 0 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #fff;
  outline: none;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
</style>

2)新增 src/components/TabNav.vue

<template>
  <div class="tabs">
    <button
      v-for="item in tabList"
      :key="item.value"
      :class="['tab-btn', activeTab === item.value ? 'active' : '']"
      @click="$emit('change', item.value)"
    >
      {{ item.label }}
    </button>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  activeTab: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion'
}>()

defineEmits<{
  (e: 'change', value: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion'): void
}>()

const tabList = [
  { label: '题目解析', value: 'solve' },
  { label: '历史记录', value: 'history' },
  { label: '错题本', value: 'wrong' },
  { label: '学习报告', value: 'report' },
  { label: '学习建议', value: 'suggestion' },
] as const
</script>

<style scoped>
.tabs {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

.tab-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.tab-btn.active {
  background: #18a058;
  color: #fff;
  border-color: #18a058;
}
</style>

3)新增 src/components/SolvePanel.vue

<template>
  <div>
    <div class="upload-area">
      <label class="upload-btn">
        {{ imageLoading ? '识别中...' : '上传题目图片' }}
        <input
          type="file"
          accept="image/*"
          class="file-input"
          :disabled="imageLoading"
          @change="$emit('image-change', $event)"
        />
      </label>
    </div>

    <textarea
      :value="question"
      class="question-input"
      placeholder="请输入一道数学题,例如:解方程 3x + 5 = 11"
      @input="$emit('update:question', ($event.target as HTMLTextAreaElement).value)"
    />

    <button class="submit-btn" @click="$emit('submit')" :disabled="loading">
      {{ loading ? '解析中...' : '开始解析' }}
    </button>

    <div v-if="result" class="result-card">
      <div class="card-header">
        <h2>本次解析结果</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', result.id)">
            {{ regenerateLoadingMap[result.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', result)">
            {{ result.is_wrong ? '取消错题' : '加入错题本' }}
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ result.question }}</p>

      <h3>答案</h3>
      <p>{{ result.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, index) in result.steps" :key="index">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, index) in result.knowledge_points" :key="index">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ result.similar_question }}</p>

      <div v-if="regeneratedMap[result.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[result.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[result.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[result.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>

    <div class="practice-panel">
      <h2>按知识点生成练习题</h2>
      <div class="practice-form">
        <input
          :value="practiceKnowledge"
          class="practice-input"
          placeholder="请输入知识点,例如:一元一次方程"
          @input="$emit('update:practiceKnowledge', ($event.target as HTMLInputElement).value)"
        />
        <button class="submit-btn" @click="$emit('generate-practice')" :disabled="practiceLoading">
          {{ practiceLoading ? '生成中...' : '生成练习题' }}
        </button>
      </div>

      <div v-if="practiceList.length" class="practice-list">
        <div v-for="(item, index) in practiceList" :key="index" class="result-card">
          <h3>练习题 {{ index + 1 }}</h3>
          <p>{{ item.question }}</p>

          <h3>答案</h3>
          <p>{{ item.answer }}</p>

          <h3>步骤解析</h3>
          <ol>
            <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
          </ol>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { PracticeQuestionItem, SolveResponse } from '../api/math'

defineProps<{
  question: string
  loading: boolean
  imageLoading: boolean
  result: (SolveResponse & { question: string }) | null
  practiceKnowledge: string
  practiceLoading: boolean
  practiceList: PracticeQuestionItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'update:question', value: string): void
  (e: 'submit'): void
  (e: 'image-change', event: Event): void
  (e: 'toggle-wrong', item: SolveResponse & { question: string }): void
  (e: 'regenerate', id: number): void
  (e: 'update:practiceKnowledge', value: string): void
  (e: 'generate-practice'): void
}>()
</script>

<style scoped>
.upload-area {
  margin-bottom: 16px;
}

.upload-btn {
  display: inline-flex;
  align-items: center;
  padding: 10px 16px;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.file-input {
  display: none;
}

.question-input {
  width: 100%;
  min-height: 140px;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font-size: 16px;
  box-sizing: border-box;
}

.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.practice-panel {
  margin-top: 32px;
}

.practice-form {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}

.practice-input {
  flex: 1;
  height: 40px;
  padding: 0 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  outline: none;
}

.practice-list {
  margin-top: 16px;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

4)新增 src/components/HistoryPanel.vue

<template>
  <div>
    <div v-if="historyList.length === 0" class="empty">暂无历史记录</div>

    <div v-for="item in historyList" :key="item.id" class="result-card">
      <div class="card-header">
        <h2>记录 #{{ item.id }}</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', item.id)">
            {{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', item)">
            {{ item.is_wrong ? '取消错题' : '加入错题本' }}
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ item.question }}</p>

      <h3>答案</h3>
      <p>{{ item.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ item.similar_question }}</p>

      <div v-if="regeneratedMap[item.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[item.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[item.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { HistoryItem, PracticeQuestionItem } from '../api/math'

defineProps<{
  historyList: HistoryItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'toggle-wrong', item: HistoryItem): void
  (e: 'regenerate', id: number): void
}>()
</script>

<style scoped>
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

5)新增 src/components/WrongPanel.vue

<template>
  <div>
    <div v-if="wrongList.length === 0" class="empty">暂无错题</div>

    <div v-for="item in wrongList" :key="item.id" class="result-card">
      <div class="card-header">
        <h2>错题 #{{ item.id }}</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', item.id)">
            {{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', item)">
            取消错题
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ item.question }}</p>

      <h3>答案</h3>
      <p>{{ item.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ item.similar_question }}</p>

      <div v-if="regeneratedMap[item.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[item.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[item.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { HistoryItem, PracticeQuestionItem } from '../api/math'

defineProps<{
  wrongList: HistoryItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'toggle-wrong', item: HistoryItem): void
  (e: 'regenerate', id: number): void
}>()
</script>

<style scoped>
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

6)修改 src/App.vue

直接把 template 部分 替换成下面这个版本:

<template>
  <div class="page">
    <div class="container">
      <h1>AI 数学辅导老师</h1>

      <StudentBar
        v-model:currentStudentId="currentStudentId"
        v-model:newStudentName="newStudentName"
        :student-list="studentList"
        @student-change="handleStudentChange"
        @create-student="handleCreateStudent"
        @export-report="handleExportReport"
      />

      <TabNav
        :active-tab="activeTab"
        @change="handleTabChange"
      />

      <SolvePanel
        v-if="activeTab === 'solve'"
        v-model:question="question"
        v-model:practiceKnowledge="practiceKnowledge"
        :loading="loading"
        :image-loading="imageLoading"
        :result="result"
        :practice-loading="practiceLoading"
        :practice-list="practiceList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @submit="handleSubmit"
        @image-change="handleImageChange"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
        @generate-practice="handleGeneratePractice"
      />

      <HistoryPanel
        v-else-if="activeTab === 'history'"
        :history-list="historyList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
      />

      <WrongPanel
        v-else-if="activeTab === 'wrong'"
        :wrong-list="wrongList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
      />

      <template v-else-if="activeTab === 'report'">
        <div v-if="reportLoading" class="empty">学习报告加载中...</div>

        <div v-else-if="learningReport" class="report-panel">
          <div class="report-summary">
            <div class="summary-card">
              <div class="summary-label">总题数</div>
              <div class="summary-value">{{ learningReport.total_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">错题数</div>
              <div class="summary-value">{{ learningReport.wrong_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">正确数</div>
              <div class="summary-value">{{ learningReport.correct_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">错题率</div>
              <div class="summary-value">{{ learningReport.wrong_rate }}%</div>
            </div>
          </div>

          <div class="result-card">
            <h2>高频知识点 Top 5</h2>
            <div v-if="learningReport.top_knowledge_points.length === 0" class="empty">
              暂无知识点统计
            </div>
            <ul v-else class="stat-list">
              <li
                v-for="(item, index) in learningReport.top_knowledge_points"
                :key="index"
                class="stat-item"
              >
                <span>{{ item.name }}</span>
                <strong>{{ item.count }}</strong>
              </li>
            </ul>
          </div>

          <div class="result-card">
            <h2>最近练习</h2>
            <div v-if="learningReport.recent_records.length === 0" class="empty">
              暂无记录
            </div>

            <div
              v-for="item in learningReport.recent_records"
              :key="item.id"
              class="recent-item"
            >
              <div class="recent-header">
                <span>题目 #{{ item.id }}</span>
                <span :class="['status-tag', item.is_wrong ? 'wrong' : 'correct']">
                  {{ item.is_wrong ? '错题' : '正常' }}
                </span>
              </div>

              <div class="recent-question">{{ item.question }}</div>

              <div class="recent-kp">
                <span
                  v-for="(kp, idx) in item.knowledge_points"
                  :key="idx"
                  class="kp-tag"
                >
                  {{ kp }}
                </span>
              </div>
            </div>
          </div>
        </div>
      </template>

      <template v-else>
        <div v-if="suggestionLoading" class="empty">学习建议加载中...</div>

        <div v-else-if="studySuggestion" class="report-panel">
          <div class="result-card">
            <h2>整体学习建议</h2>
            <p>{{ studySuggestion.overall_suggestion }}</p>
          </div>

          <div class="result-card">
            <h2>薄弱知识点分析</h2>

            <div v-if="studySuggestion.weak_knowledge_points.length === 0" class="empty">
              暂无薄弱知识点
            </div>

            <div
              v-for="(item, index) in studySuggestion.weak_knowledge_points"
              :key="index"
              class="weak-item"
            >
              <div class="weak-header">
                <strong>{{ item.name }}</strong>
                <span class="weak-rate">错误率 {{ item.wrong_rate }}%</span>
              </div>

              <div class="weak-meta">
                错误 {{ item.wrong_count }} 次 / 共出现 {{ item.total_count }} 次
              </div>

              <div class="weak-suggestion">
                {{ item.suggestion }}
              </div>

              <button
                class="retry-btn"
                @click="handleGenerateWeakPractice(item.name)"
              >
                生成该知识点练习题
              </button>
            </div>
          </div>
        </div>
      </template>
    </div>
  </div>
</template>

7)src/App.vue 的 script 只补充这些 import

在顶部新增:

import StudentBar from './components/StudentBar.vue'
import TabNav from './components/TabNav.vue'
import SolvePanel from './components/SolvePanel.vue'
import HistoryPanel from './components/HistoryPanel.vue'
import WrongPanel from './components/WrongPanel.vue'

8)src/App.vue 的 script 新增两个方法

加到 script setup 里:

const handleTabChange = async (tab: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion') => {
  activeTab.value = tab

  if (tab === 'history') {
    await loadHistory()
  } else if (tab === 'wrong') {
    await loadWrongList()
  } else if (tab === 'report') {
    await loadReport()
  } else if (tab === 'suggestion') {
    await loadStudySuggestion()
  }
}

const handleGenerateWeakPractice = async (knowledgeName: string) => {
  practiceKnowledge.value = knowledgeName
  activeTab.value = 'solve'
  await handleGeneratePractice()
}

9)src/App.vue 的 style 删除这些已拆走的样式

可以从 App.vue 里删掉这些,避免重复:

.student-bar
.student-select,
.student-input
.tabs
.tab-btn
.tab-btn.active
.upload-area
.upload-btn
.file-input
.question-input
.card-header
.card-actions
.retry-btn
.wrong-btn
.practice-panel
.practice-form
.practice-input
.practice-list
.regenerated-box

保留这些全局页面级样式:

.page {
  min-height: 100vh;
  background: #f5f7fa;
  padding: 40px 16px;
}
.container {
  max-width: 900px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 12px;
}
.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}
.report-panel {
  margin-top: 24px;
}
.report-summary {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  margin-bottom: 24px;
}
.summary-card {
  padding: 20px;
  background: #fafafa;
  border-radius: 12px;
  text-align: center;
}
.summary-label {
  color: #666;
  font-size: 14px;
  margin-bottom: 8px;
}
.summary-value {
  font-size: 28px;
  font-weight: 700;
  color: #18a058;
}
.stat-list {
  padding: 0;
  margin: 0;
  list-style: none;
}
.stat-item {
  display: flex;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
}
.recent-item {
  padding: 16px 0;
  border-bottom: 1px solid #eee;
}
.recent-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.recent-question {
  margin-bottom: 10px;
  color: #333;
}
.recent-kp {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}
.kp-tag {
  display: inline-block;
  padding: 4px 10px;
  background: #f3f3f3;
  border-radius: 999px;
  font-size: 12px;
}
.status-tag {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  color: #fff;
}
.status-tag.wrong {
  background: #d03050;
}
.status-tag.correct {
  background: #18a058;
}
.weak-item {
  padding: 16px 0;
  border-bottom: 1px solid #eee;
}
.weak-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.weak-rate {
  color: #d03050;
  font-weight: 600;
}
.weak-meta {
  color: #666;
  font-size: 14px;
  margin-bottom: 8px;
}
.weak-suggestion {
  margin-bottom: 12px;
  color: #333;
  line-height: 1.7;
}

10)效果

这次拆分完:

  • App.vue 只保留页面编排和状态
  • 学生切换独立
  • tab 独立
  • 解析区独立
  • 历史记录独立
  • 错题本独立

后面再拆:

  • ReportPanel.vue
  • SuggestionPanel.vue

功能都测试一遍 没问题

image.png

image.png

nice !

昨天以前首页

练习单导出

作者 前端付豪
2026年3月14日 09:10

新增:

  • 导出当前解析结果
  • 导出当前学生错题本
  • 导出学习建议
  • 新开页面生成练习单
  • 浏览器里直接 打印 / 存成 PDF

后端新增导出接口

1)修改 backend/app/main.py

先补充 import:

from fastapi.responses import HTMLResponse

2)新增一个导出页面接口

把下面接口加到 main.py 里:

@app.get("/api/export/report", response_class=HTMLResponse)
def export_report_html(
    student_id: int = Query(1),
    db: Session = Depends(get_db)
):
    student = db.query(Student).filter(Student.id == student_id).first()
    student_name = student.name if student else f"学生{student_id}"

    rows = (
        db.query(QuestionHistory)
        .filter(QuestionHistory.student_id == student_id)
        .order_by(QuestionHistory.id.desc())
        .all()
    )

    report = build_learning_report(db, student_id)
    suggestion = build_study_suggestion(db, student_id)

    wrong_rows = [row for row in rows if row.is_wrong][:10]

    wrong_html = ""
    for row in wrong_rows:
        try:
            knowledge_points = json.loads(row.knowledge_points or "[]")
        except Exception:
            knowledge_points = []

        try:
            steps = json.loads(row.steps or "[]")
        except Exception:
            steps = []

        wrong_html += f"""
        <div class="card">
          <h3>错题 #{row.id}</h3>
          <p><strong>题目:</strong>{row.question}</p>
          <p><strong>答案:</strong>{row.answer}</p>
          <p><strong>知识点:</strong>{'、'.join(knowledge_points) if knowledge_points else '无'}</p>
          <div>
            <strong>步骤解析:</strong>
            <ol>
              {''.join([f'<li>{step}</li>' for step in steps])}
            </ol>
          </div>
        </div>
        """

    top_kp_html = "".join([
        f"<li>{item['name']}({item['count']}次)</li>"
        for item in report["top_knowledge_points"]
    ])

    weak_html = "".join([
        f"""
        <div class="card">
          <h3>{item['name']}</h3>
          <p>错误 {item['wrong_count']} 次 / 共出现 {item['total_count']} 次 / 错误率 {item['wrong_rate']}%</p>
          <p>{item['suggestion']}</p>
        </div>
        """
        for item in suggestion["weak_knowledge_points"]
    ])

    html = f"""
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
      <meta charset="UTF-8" />
      <title>{student_name} - 学习练习单</title>
      <style>
        body {{
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          margin: 0;
          padding: 24px;
          color: #222;
          background: #f7f8fa;
        }}
        .container {{
          max-width: 960px;
          margin: 0 auto;
          background: #fff;
          padding: 32px;
          border-radius: 16px;
        }}
        h1, h2, h3 {{
          margin-top: 0;
        }}
        .summary {{
          display: grid;
          grid-template-columns: repeat(4, 1fr);
          gap: 12px;
          margin-bottom: 24px;
        }}
        .summary-item {{
          background: #f5f7fa;
          border-radius: 12px;
          padding: 16px;
          text-align: center;
        }}
        .summary-label {{
          color: #666;
          font-size: 14px;
          margin-bottom: 8px;
        }}
        .summary-value {{
          font-size: 28px;
          font-weight: bold;
          color: #18a058;
        }}
        .card {{
          background: #fafafa;
          border-radius: 12px;
          padding: 16px;
          margin-bottom: 16px;
        }}
        .section {{
          margin-top: 32px;
        }}
        .print-bar {{
          margin-bottom: 24px;
        }}
        .print-btn {{
          padding: 10px 16px;
          border: none;
          background: #18a058;
          color: #fff;
          border-radius: 8px;
          cursor: pointer;
        }}
        @media print {{
          body {{
            background: #fff;
            padding: 0;
          }}
          .container {{
            max-width: none;
            border-radius: 0;
            padding: 0;
          }}
          .print-bar {{
            display: none;
          }}
        }}
      </style>
    </head>
    <body>
      <div class="container">
        <div class="print-bar">
          <button class="print-btn" onclick="window.print()">打印 / 保存为 PDF</button>
        </div>

        <h1>{student_name} - 学习练习单</h1>

        <div class="summary">
          <div class="summary-item">
            <div class="summary-label">总题数</div>
            <div class="summary-value">{report['total_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">错题数</div>
            <div class="summary-value">{report['wrong_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">正确数</div>
            <div class="summary-value">{report['correct_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">错题率</div>
            <div class="summary-value">{report['wrong_rate']}%</div>
          </div>
        </div>

        <div class="section">
          <h2>整体学习建议</h2>
          <div class="card">{suggestion['overall_suggestion']}</div>
        </div>

        <div class="section">
          <h2>高频知识点</h2>
          <div class="card">
            <ul>{top_kp_html or '<li>暂无</li>'}</ul>
          </div>
        </div>

        <div class="section">
          <h2>薄弱知识点分析</h2>
          {weak_html or '<div class="card">暂无薄弱知识点</div>'}
        </div>

        <div class="section">
          <h2>错题本(最近10题)</h2>
          {wrong_html or '<div class="card">暂无错题</div>'}
        </div>
      </div>
    </body>
    </html>
    """
    return html

前端新增导出按钮

修改 frontend/src/api/math.ts

新增一个方法:

export function getExportReportUrl(student_id: number) {
  return `http://127.0.0.1:8000/api/export/report?student_id=${student_id}`
}

修改 frontend/src/App.vue

1)补充 import

getExportReportUrl 加进去:

import {
  solveMathQuestion,
  solveMathImage,
  getHistoryList,
  getWrongQuestionList,
  markWrongQuestion,
  generatePracticeByKnowledge,
  regenerateQuestion,
  getLearningReport,
  getStudySuggestion,
  getStudentList,
  createStudent,
  getExportReportUrl,
  type SolveResponse,
  type HistoryItem,
  type PracticeQuestionItem,
  type LearningReportResponse,
  type StudySuggestionResponse,
  type StudentItem,
} from './api/math'

2)新增导出方法

script setup 里新增:

const handleExportReport = () => {
  const url = getExportReportUrl(currentStudentId.value)
  window.open(url, '_blank')
}

页面里加导出入口

修改 frontend/src/App.vue

在学生切换区域 student-bar 里,最后加一个按钮:

<button class="wrong-btn" @click="handleExportReport">
  导出练习单
</button>

重启看效果

重启后端

uvicorn app.main:app --reload --port 8000

重启前端

npm run dev

image.png

image.png

image.png

不同学生 也没问题

image.png

image.png

拍照识题 OCR

作者 前端付豪
2026年3月12日 09:58

本节添加这些内容

  • 手动输入题目解析

  • 上传题目图片

  • OCR 识别文字

  • 自动调用 AI 解析

  • 自动写入历史记录 / 错题本

后端先装依赖

backend 目录执行:

pip install pillow paddleocr

后端新增文件

新增 app/ocr_service.py

from paddleocr import PaddleOCR
from PIL import Image

ocr = PaddleOCR(use_angle_cls=True, lang="ch")


def extract_text_from_image(image_path: str) -> str:
    result = ocr.ocr(image_path, cls=True)

    lines = []
    for block in result:
        if not block:
            continue
        for item in block:
            if len(item) < 2:
                continue
            text = item[1][0].strip()
            if text:
                lines.append(text)

    return "\n".join(lines)

后端修改 app/schemas.py

只新增下面这个响应结构

class OCRSolveResponse(SolveQuestionResponse):
    question: str
    ocr_text: str

后端修改 app/main.py

1)先补充 import

在顶部 import 区域,新增这几行:

import os
import tempfile
from fastapi import File, UploadFile
from app.ocr_service import extract_text_from_image
from app.schemas import OCRSolveResponse

2)新增图片解析接口

把这个接口加到 main.py 里:

@app.post("/api/solve-image", response_model=OCRSolveResponse)
async def solve_image(file: UploadFile = File(...), db: Session = Depends(get_db)):
    temp_path = None
    try:
        suffix = os.path.splitext(file.filename or "")[-1] or ".png"
        with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
            content = await file.read()
            temp_file.write(content)
            temp_path = temp_file.name

        ocr_text = extract_text_from_image(temp_path).strip()
        if not ocr_text:
            raise HTTPException(status_code=400, detail="OCR 未识别到题目内容")

        result = solve_math_question(ocr_text)

        row = QuestionHistory(
            question=ocr_text,
            answer=result["answer"],
            steps=json.dumps(result["steps"], ensure_ascii=False),
            knowledge_points=json.dumps(result["knowledge_points"], ensure_ascii=False),
            similar_question=result["similar_question"],
            is_wrong=False,
        )
        db.add(row)
        db.commit()
        db.refresh(row)

        return OCRSolveResponse(
            id=row.id,
            question=ocr_text,
            ocr_text=ocr_text,
            answer=row.answer,
            steps=json.loads(row.steps),
            knowledge_points=json.loads(row.knowledge_points),
            similar_question=row.similar_question,
            is_wrong=row.is_wrong,
        )
    except HTTPException:
        db.rollback()
        raise
    except Exception as e:
        db.rollback()
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        if temp_path and os.path.exists(temp_path):
            os.remove(temp_path)

前端修改 src/api/math.ts

1)新增 OCR 返回类型

export interface OCRSolveResponse extends HistoryItem {
  question: string
  ocr_text: string
}

2)新增图片解析接口方法

export function solveMathImage(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  return request.post<OCRSolveResponse>('/api/solve-image', formData, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}

前端修改 src/App.vue

1)修改 import

import {
  solveMathQuestion,
  solveMathImage,
  getHistoryList,
  getWrongQuestionList,
  markWrongQuestion,
  type SolveResponse,
  type HistoryItem,
} from './api/math'

2)新增上传状态

script setup 里,新增:

const imageLoading = ref(false)

3)新增图片上传解析方法

script setup 里,新增:

const handleImageChange = async (event: Event) => {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
  if (!file) return

  imageLoading.value = true
  try {
    const { data } = await solveMathImage(file)
    result.value = {
      ...data,
      question: data.question,
    }
    activeTab.value = 'solve'
    await loadHistory()
    await loadWrongList()
  } catch (error: any) {
    console.error('图片解析失败:', error)
    alert(error?.response?.data?.detail || '图片解析失败,请检查后端日志')
  } finally {
    imageLoading.value = false
    target.value = ''
  }
}

4)修改“题目解析”区域模板

找到 activeTab === 'solve' 这一段,在 textarea 上方插入下面这块:

<div class="upload-area">
  <label class="upload-btn">
    {{ imageLoading ? '识别中...' : '上传题目图片' }}
    <input
      type="file"
      accept="image/*"
      class="file-input"
      :disabled="imageLoading"
      @change="handleImageChange"
    />
  </label>
</div>

5)补充样式

style scoped 里新增:

.upload-area {
  margin-bottom: 16px;
}

.upload-btn {
  display: inline-flex;
  align-items: center;
  padding: 10px 16px;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.file-input {
  display: none;
}

重启

1)重启后端

uvicorn app.main:app --reload --port 8000

2)重启前端

npm run dev

预期结果

  • 上传一张题目图片
  • OCR 提取题目
  • 自动解析
  • 页面展示答案 / 步骤 / 知识点 / 相似题
  • 历史记录新增一条

错误处理

import paddle ModuleNotFoundError: No module named 'paddle'

解决

pip install paddlepaddle==2.6.2

uvicorn app.main:app --reload --port 8000

报错

AttributeError: 'paddle.base.libpaddle.AnalysisConfig' object has no attribute 'set_optimization_level'. Did you mean: 'tensorrt_optimization_level'?

PaddleOCR 和 PaddlePaddle 版本不兼容 导致的。

pip uninstall paddleocr paddlepaddle -y

pip install paddlepaddle==2.6.2
pip install paddleocr==2.7.3

报错

25, in <module> import cv2 ImportError: numpy.core.multiarray failed to import

解决

pip uninstall -y numpy opencv-python opencv-contrib-python opencv-python-headless

pip install numpy==1.26.4
pip install opencv-python-headless==4.10.0.84

效果

上传这个图片

test.png

image.png

image.png

非常不错!!!

AI 数学辅导老师项目构想和初始化

作者 前端付豪
2026年3月10日 11:24

项目产生原因

拍照搜题是个非常有用的操作,尤其对于学生学习辅助帮助很大,市面上这类的软件也有很多,但能不能结合 AI 做出核心功能,并且可以商用? 而不是技术的自嗨模式,真的有用户使用上,先能产生公益价值。

项目初始化

结构

ai-math-tutor/
  frontend/
  backend/

后端初始化

1)进入 backend 目录:

mkdir ai-math-tutor
cd ai-math-tutor
mkdir backend
cd backend
python3 -m venv venv

激活虚拟环境:

macOS / Linux

source venv/bin/activate

2)安装依赖

pip install fastapi uvicorn python-dotenv openai

3)创建目录结构

backend/
  app/
    main.py
    schemas.py
    llm_service.py
  .env
  requirements.txt

llm_service.py

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

client = OpenAI(
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_BASE_URL"),
)

MODEL = os.getenv("OPENAI_MODEL", "moonshot-v1-8k")


SYSTEM_PROMPT = """
你是一位专业的初中数学辅导老师,擅长:
1. 一元一次方程
2. 二元一次方程
3. 几何基础
4. 分数与整数运算
5. 一次函数

请严格返回 JSON:
{
  "answer": "最终答案",
  "steps": ["步骤1", "步骤2"],
  "knowledge_points": ["知识点1"],
  "similar_question": "类似题"
}

要求:
1. 只返回 JSON
2. 每个步骤必须适合学生理解
3. 不要省略关键推导
4. 如果题目超出初中范围,也要说明
"""

def solve_math_question(question: str):
    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0.3,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"题目:{question}"},
        ],
    )

    content = resp.choices[0].message.content.strip()

    try:
        data = json.loads(content)
        return data
    except Exception:
        return {
            "answer": "解析失败",
            "steps": [content],
            "knowledge_points": ["待识别"],
            "similar_question": "请再输入一道类似题目",
        }

main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.schemas import SolveQuestionRequest, SolveQuestionResponse
from app.llm_service import solve_math_question

app = FastAPI(title="AI Math Tutor API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def health():
    return {"message": "AI Math Tutor API is running"}


@app.post("/api/solve", response_model=SolveQuestionResponse)
def solve_question(req: SolveQuestionRequest):
    result = solve_math_question(req.question)
    return SolveQuestionResponse(**result)

schemas.py

from pydantic import BaseModel
from typing import List


class SolveQuestionRequest(BaseModel):
    question: str


class SolveQuestionResponse(BaseModel):
    answer: str
    steps: List[str]
    knowledge_points: List[str]
    similar_question: str

4)写 .env

如果你用 Moonshot/OpenAI 兼容接口,可以这样:

OPENAI_API_KEY=你的key
OPENAI_BASE_URL=https://api.moonshot.cn/v1
OPENAI_MODEL=moonshot-v1-8k

如果是 OpenAI 官方兼容接口,就改成对应地址。

启动

backend 目录下执行:

uvicorn app.main:app --reload --port 8000

打开:

http://127.0.0.1:8000/docs

你可以直接在 Swagger 页面测试接口。

前端初始化

1)创建前端项目

回到项目根目录:

cd ..
npm create vite@latest frontend

选择:

  • Vue
  • JavaScript 或 TypeScript 都行

建议你用 TypeScript。

进入目录:

cd frontend
npm install
npm install axios naive-ui

2)前端目录建议

frontend/
  src/
    api/
      math.ts
    views/
      Home.vue
    App.vue
    main.ts

3)写 src/api/math.ts

import axios from 'axios'

const request = axios.create({
  baseURL: 'http://127.0.0.1:8000',
  timeout: 30000,
})

export interface SolveRequest {
  question: string
}

export interface SolveResponse {
  answer: string
  steps: string[]
  knowledge_points: string[]
  similar_question: string
}

export function solveMathQuestion(data: SolveRequest) {
  return request.post<SolveResponse>('/api/solve', data)
}

4)写 src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

5)写 src/App.vue

<template>
  <div class="page">
    <div class="container">
      <h1>AI 数学辅导老师</h1>

      <textarea
        v-model="question"
        class="question-input"
        placeholder="请输入一道数学题,例如:解方程 3x + 5 = 11"
      />

      <button class="submit-btn" @click="handleSubmit" :disabled="loading">
        {{ loading ? '解析中...' : '开始解析' }}
      </button>

      <div v-if="result" class="result-card">
        <h2>答案</h2>
        <p>{{ result.answer }}</p>

        <h2>步骤解析</h2>
        <ol>
          <li v-for="(item, index) in result.steps" :key="index">
            {{ item }}
          </li>
        </ol>

        <h2>知识点</h2>
        <ul>
          <li v-for="(item, index) in result.knowledge_points" :key="index">
            {{ item }}
          </li>
        </ul>

        <h2>相似题</h2>
        <p>{{ result.similar_question }}</p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { solveMathQuestion, type SolveResponse } from './api/math'

const question = ref('')
const loading = ref(false)
const result = ref<SolveResponse | null>(null)

const handleSubmit = async () => {
  if (!question.value.trim()) return

  loading.value = true
  try {
    const { data } = await solveMathQuestion({
      question: question.value,
    })
    result.value = data
  } catch (error) {
    console.error(error)
    alert('解析失败,请检查后端是否启动')
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f7fa;
  padding: 40px 16px;
}
.container {
  max-width: 800px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 12px;
}
.question-input {
  width: 100%;
  min-height: 140px;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font-size: 16px;
  box-sizing: border-box;
}
.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}
</style>

6)启动前端

npm run dev

image.png

运行结果

image.png

nice !

Nest 项目小实践之图书增删改查

作者 前端付豪
2026年3月6日 11:05

写图书新增、修改、删除、详情功能

新建 BookManage/CreateBookModal.tsx

import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";

interface CreateBookModalProps {
    isOpen: boolean;
    handleClose: Function
}
const layout = {
    labelCol: { span: 6 },
    wrapperCol: { span: 18 }
}

export interface CreateBook {
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function CreateBookModal(props: CreateBookModalProps) {

    const [form] = useForm<CreateBook>();

    const handleOk = async function() {

    }

    return <Modal title="新增图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'创建'}>
        <Form
            form={form}
            colon={false}
            {...layout}
        >
            <Form.Item
                label="图书名称"
                name="name"
                rules={[
                    { required: true, message: '请输入图书名称!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="作者"
                name="author"
                rules={[
                    { required: true, message: '请输入图书作者!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="描述"
                name="description"
                rules={[
                    { required: true, message: '请输入图书描述!' },
                ]}
            >
                <TextArea/>
            </Form.Item>
            <Form.Item
                label="封面"
                name="cover"
                rules={[
                    { required: true, message: '请上传图书封面!' },
                ]}
            >
                <Input/>
            </Form.Item>
        </Form>
    </Modal>
}

在 BookManage/index.tsx 调用

const [isCreateBookModalOpen, setCreateBookModalOpen] = useState(false);
    
 <Button type="primary" htmlType="submit" style={{background: 'green'}} onClick={ () =>setCreateBookModalOpen(true)}>
  添加图书
   </Button>
   
 <CreateBookModal isOpen={isCreateBookModalOpen} handleClose={() => {
  setCreateBookModalOpen(false);
  }}></CreateBookModal>

点击添加图书就会展示

image.png

在 interfaces/index.ts 里添加 /book/create 接口

export async function create(book: CreateBook) {
  return await axiosInstance.post("/book/create", {
    name: book.name,
    author: book.author,
    description: book.description,
    cover: book.cover,
  });
}

在 CreateBookModal 组件调用

const handleOk = async function() {
    await form.validateFields();

    const values = form.getFieldsValue();

    try {
        const res = await create(values);

        if(res.status === 201 || res.status === 200) {
            message.success('创建成功');
            form.resetFields();
            props.handleClose();
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

可以新增图书

image.png

只是没有封面图

添加 BookManage/Coverupload.tsx 组件

import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { type DraggerProps } from "antd/es/upload/Dragger";

interface CoverUploadProps {
    value?: string;
    onChange?: Function
}

let onChange: Function;

const props: DraggerProps = {
    name: 'file',
    action: 'http://localhost:3000/book/upload',
    method: 'post',
    onChange(info) {
        const { status } = info.file;
        if (status === 'done') {
            onChange(info.file.response);
            message.success(`${info.file.name} 文件上传成功`);
        } else if (status === 'error') {
            message.error(`${info.file.name} 文件上传失败`);
        }
    }
};

const dragger = <Dragger {...props}>
    <p className="ant-upload-drag-icon">
        <InboxOutlined />
    </p>
    <p className="ant-upload-text">点击或拖拽文件到区域上传</p>
</Dragger>

export function CoverUpload(props: CoverUploadProps) {

    onChange = props.onChange!

    return props?.value ? <div>
        <img src={'http://localhost:3000/' + props.value} alt="封面" width="100" height="100"/>
        {dragger}
    </div>: <div>
        {dragger}
    </div>
}

image.png

添加成功

image.png

修改如何做 ? 新建 BookManage/UpdateBookModal.tsx

需要带上 id

import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
import { CoverUpload } from "./CoverUpload";

interface UpdateBookModalProps {
    id: number;
    isOpen: boolean;
    handleClose: Function
}
const layout = {
    labelCol: { span: 6 },
    wrapperCol: { span: 18 }
}

export interface UpdateBook {
    id: number;
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function UpdateBookModal(props: UpdateBookModalProps) {

    const [form] = useForm<UpdateBook>();

    const handleOk = async function() {

    }

    return <Modal title="更新图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'更新'}>
        <Form
            form={form}
            colon={false}
            {...layout}
        >
            <Form.Item
                label="图书名称"
                name="name"
                rules={[
                    { required: true, message: '请输入图书名称!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="作者"
                name="author"
                rules={[
                    { required: true, message: '请输入图书作者!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="描述"
                name="description"
                rules={[
                    { required: true, message: '请输入图书描述!' },
                ]}
            >
                <TextArea/>
            </Form.Item>
            <Form.Item
                label="封面"
                name="cover"
                rules={[
                    { required: true, message: '请上传图书封面!' },
                ]}
            >
                <CoverUpload></CoverUpload>
            </Form.Item>
        </Form>
    </Modal>
}

在 BookManage index.tsx 引入使用

const [isUpdateBookModalOpen, setUpdateBookModalOpen] = useState(false); const [updateId, setUpdateId] = useState(0);

<UpdateBookModal id={updateId} isOpen={isUpdateBookModalOpen} handleClose={() => { setUpdateBookModalOpen(false); setName(''); }}></UpdateBookModal>

<a href="#" onClick={() => { setUpdateId(book.id); setUpdateBookModalOpen(true); }}>编辑</a>

点击编辑

image.png

interfaces/index.ts 里加一下接口

export async function detail(id: number) {
  return await axiosInstance.get(`/book/${id}`);
}

UpdateBookModal.tsx 添加

async function query() {
    if(!props.id) {
        return;
    }
    try{
        const res = await detail(props.id);
        const { data } = res;
        debugger;
        if(res.status === 200 || res.status === 201) {
            form.setFieldValue('id', data.id);
            form.setFieldValue('name', data.name);
            form.setFieldValue('author', data.author);
            form.setFieldValue('description', data.description);
            form.setFieldValue('cover', data.cover);
        } 
    } catch(e: any){
        message.error(e.response.data.message);
    }
}

useEffect(() => {
    query();
}, [props.id]);

点击编辑 就会带出已有的信息

image.png

更新 interfaces/index.ts

export async function update(book: UpdateBook) {
    return await axiosInstance.put('/book/update', {
        id: book.id,
        name: book.name,
        author: book.author,
        description: book.description,
        cover: book.cover
    });
}

UpdateBookModal.tsx 调用

    const handleOk = async function() {
    await form.validateFields();

    const values = form.getFieldsValue();

    try {
        const res = await update({...values, id: props.id});

        if(res.status === 201 || res.status === 200) {
            message.success('更新成功');
            props.handleClose();
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

可以成功更新

image.png

BookManage index.tsx

<Popconfirm
    title="图书删除"
    description="确认删除吗?"
    onConfirm={() => handleDelete(book.id)}
    okText="Yes"
    cancelText="No"
>  
    <a href="#">删除</a>
</Popconfirm>

async function handleDelete(id: number) {
    try {
        await deleteBook(id);        
        message.success('删除成功');
        setNum(Math.random())
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

interfaces/index.ts

export async function deleteBook(id: number) { return await axiosInstance.delete(`/book/delete/${id}`); }

成功删除

image.png

后续会新增一些优化部分

  • 登录之后怎么保存登录状态?比如有的接口需要登录才能访问,怎么控制?

这需要用 session + cookie 或 jwt 的方式来实现登录状态的保存。

  • 数据保存在文件里并不方便,还有啥更好的方式?

保存在 mysql 数据库,用 TypeORM 作为 ORM 框架。

  • 后端接口怎么提供 api 文档?

这需要用 swagger

  • 文件保存在文件目录下,如果磁盘空间满了怎么办?

可以换用 minio 或者阿里 OSS 等对象存储服务。

  • 怎么部署?

前端用 nginx,后端代码用 docker 和 docker compose

  • 如何实现验证码?

可以用 nodemailer 发送邮件,然后用 redis 保存验证码数据。

Nest 项目小实践之图书展示和搜索

作者 前端付豪
2026年3月4日 14:25

更新 图书列表

pages/BookManage/index.tsx

import { Button, Card, Form, Input } from 'antd';
import './index.css';

export function BookManage(){
    return <div id="bookManage">
        <h1>图书管理系统</h1>
        <div className="content">
            <div className='book-search'>
                <Form
                    name="search"
                    layout='inline'
                    colon={false}
                >
                    <Form.Item label="图书名称" name="name">
                        <Input />
                    </Form.Item>
                    <Form.Item label=" ">
                        <Button type="primary" htmlType="submit">
                            搜索图书
                        </Button>
                        <Button type="primary" htmlType="submit" style={{background: 'green'}} >
                            添加图书
                        </Button>
                    </Form.Item>
                </Form>
            </div>
            <div className="book-list">
                {
                    [1,2,3,4,5,6,7].map(item => {
                        return <Card
                            className='card'
                            hoverable
                            style={{ width: 300 }}
                            cover={<img alt="example" src="https://upload.wikimedia.org/wikipedia/zh/7/7d/%E5%B9%B3%E5%87%A1%E7%9A%84%E4%B8%96%E7%95%8C2014.jpg" />}
                        >
                            <h2>平凡的世界</h2>
                            <div>路遥</div>
                            <div className='links'>
                                <a href="#">详情</a>
                                <a href="#">编辑</a>
                                <a href="#">删除</a>
                            </div>
                        </Card>
                    })
                }    
            </div>
        </div>
    </div>
}

index.css

#bookManage {
    display: flex;
    flex-direction: column;
}

#bookManage h1 {
    height: 80px;
    line-height: 80px;
    border-bottom: 2px solid #ccc;
    padding-left: 20px;
}

#bookManage .content {
    padding: 20px;
}

#bookManage .book-list{
    padding: 20px;
    display: flex;
    flex-wrap: wrap;
}

#bookManage .book-list .card{
    margin-left: 30px;
    margin-bottom: 30px;
}

#bookManage .book-list .links{
    display: flex;
    
    flex-direction: row;
    justify-content: space-around;
}

现在页面这样

image.png

在 interfaces/index.ts 里加下图书列表的请求

image.png

在组件里调用

import { Button, Card, Form, Input, message } from 'antd';
import './index.css';
import { useEffect, useState } from 'react';
import { list } from '../../interfaces';

interface Book {
    id: number;
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function BookManage(){

    const [bookList, setBookList] = useState<Array<Book>>([]);

    async function fetchData() {
        try {
            const data = await list();
            
            if(data.status === 201 || data.status === 200) {
                setBookList(data.data);
            }
        } catch(e: any) {
            message.error(e.response.data.message);
        }
    }

    useEffect(() => {
        fetchData();
    }, []);

    return <div id="bookManage">
        <h1>图书管理系统</h1>
        <div className="content">
            <div className='book-search'>
                <Form
                    name="search"
                    layout='inline'
                    colon={false}
                >
                    <Form.Item label="图书名称" name="name">
                        <Input />
                    </Form.Item>
                    <Form.Item label=" ">
                        <Button type="primary" htmlType="submit">
                            搜索图书
                        </Button>
                        <Button type="primary" htmlType="submit" style={{background: 'green'}} >
                            添加图书
                        </Button>
                    </Form.Item>
                </Form>
            </div>
            <div className="book-list">
                {
                    bookList.map(book => {
                        return <Card
                            className='card'
                            hoverable
                            style={{ width: 300 }}
                            cover={<img alt="example" src={`http://localhost:3000/${book.cover}`} />}
                        >
                            <h2>{book.name}</h2>
                            <div>{book.author}</div>
                            <div className='links'>
                                <a href="#">详情</a>
                                <a href="#">编辑</a>
                                <a href="#">删除</a>
                            </div>
                        </Card>
                    })
                }    
            </div>
        </div>
    </div>
}

book.json 信息这样

image.png

目前展示这样

image.png

book.controller.ts 更新搜索

  @Get('list')
  async list(@Query('name') name: string) {
    return this.bookService.list(name);
  }

book.service.ts

  async list(name: string) {
    const books: Book[] = await this.dbService.read();
    return name
      ? books.filter((book) => {
          return book.name.includes(name);
        })
      : books;
  }

不带参数查全部

image.png

带参数查询到匹配的内容

image.png

image.png

image.png

前端 interfaces/index.ts更新

export async function list(name: string) {
  return await axiosInstance.get("/book/list", {
    params: {
      name,
    },
  });
}

image.png

更新前端 BookManage/index.tsx

import { Button, Card, Form, Input, message } from 'antd';
import './index.css';
import { useEffect, useState } from 'react';
import { list } from '../../interfaces';

interface Book {
    id: number;
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function BookManage(){

    const [bookList, setBookList] = useState<Array<Book>>([]);
    const [name, setName] = useState('');

    async function fetchData() {
        try {
            const data = await list(name);
            
            if(data.status === 201 || data.status === 200) {
                setBookList(data.data);
            }
        } catch(e: any) {
            message.error(e.response.data.message);
        }
    }

    useEffect(() => {
        fetchData();
    }, [name]);

    async function searchBook(values: { name: string}) {
        setName(values.name);
    }

    return <div id="bookManage">
        <h1>图书管理系统</h1>
        <div className="content">
            <div className='book-search'>
                <Form
                    onFinish={searchBook}
                    name="search"
                    layout='inline'
                    colon={false}
                >
                    <Form.Item label="图书名称" name="name">
                        <Input />
                    </Form.Item>
                    <Form.Item label=" ">
                        <Button type="primary" htmlType="submit">
                            搜索图书
                        </Button>
                        <Button type="primary" htmlType="submit" style={{background: 'green'}} >
                            添加图书
                        </Button>
                    </Form.Item>
                </Form>
            </div>
            <div className="book-list">
                {
                    bookList.map(book => {
                        return <Card
                            className='card'
                            hoverable
                            style={{ width: 300 }}
                            cover={<img alt="example" src={`http://localhost:3000/${book.cover}`} />}
                        >
                            <h2>{book.name}</h2>
                            <div>{book.author}</div>
                            <div className='links'>
                                <a href="#">详情</a>
                                <a href="#">编辑</a>
                                <a href="#">删除</a>
                            </div>
                        </Card>
                    })
                }    
            </div>
        </div>
    </div>
}

image.png

image.png

非常nice !

❌
❌