前端项目初始化提交
This commit is contained in:
785
docs/superpowers/plans/2026-05-10-exam-api-integration.md
Normal file
785
docs/superpowers/plans/2026-05-10-exam-api-integration.md
Normal file
@@ -0,0 +1,785 @@
|
||||
# 考试管理API对接实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 将「考察与训练」模块中「智能组卷」和「互动训练」子功能对接后端考试管理API,替换模拟数据
|
||||
|
||||
**架构:**
|
||||
- 智能组卷使用 `POST /exam/paper/generate` 从题库自动组卷,结果仅前端暂存
|
||||
- 互动训练闯关模式使用预设5级固定关卡,每关调用组卷接口生成题目
|
||||
- 错题本通过 `POST /exam/answers/query` 查询答题记录,筛选错题展示
|
||||
|
||||
**Tech Stack:** Vue 3, Axios, Ant Design Vue
|
||||
|
||||
---
|
||||
|
||||
### Task 1: exam.js 新增 API 方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api/exam.js`
|
||||
|
||||
- [ ] **Step 1: 新增 generatePaper 方法**
|
||||
|
||||
在 `exam.js` 的 `examAPI` 对象末尾、`gradeAnswers` 之后添加:
|
||||
|
||||
```javascript
|
||||
generatePaper: (params) => {
|
||||
console.log('[examAPI] 生成试卷 - 参数:', params)
|
||||
|
||||
const requestBody = {
|
||||
single_choice_count: params.single_choice_count || 0,
|
||||
multiple_choice_count: params.multiple_choice_count || 0,
|
||||
true_false_count: params.true_false_count || 0,
|
||||
fill_blank_count: params.fill_blank_count || 0,
|
||||
subjective_count: params.subjective_count || 0,
|
||||
include_personal: params.include_personal || false,
|
||||
difficulty: params.difficulty || 2,
|
||||
collection: params.collection,
|
||||
collection_name: params.collection_name,
|
||||
file_ids: params.file_ids || []
|
||||
}
|
||||
|
||||
return apiClient.post('/exam/paper/generate', requestBody, {
|
||||
timeout: 120000
|
||||
}).then(handleExamResponse).then(response => {
|
||||
console.log('[examAPI] 试卷生成成功:', response)
|
||||
return response
|
||||
})
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 queryUserAnswers 方法**
|
||||
|
||||
在 `generatePaper` 之后添加:
|
||||
|
||||
```javascript
|
||||
queryUserAnswers: (request) => {
|
||||
console.log('[examAPI] 查询答题记录 - 参数:', request)
|
||||
|
||||
return apiClient.post('/exam/answers/query', request, {
|
||||
timeout: 30000
|
||||
}).then(response => {
|
||||
const data = response.data
|
||||
if (data.code === 200 && data.data) {
|
||||
return data.data
|
||||
}
|
||||
throw new Error(data.message || '查询答题记录失败')
|
||||
})
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 智能组卷 - 改造 handleGeneratePaper
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换 handleGeneratePaper 函数**
|
||||
|
||||
找到 `const handleGeneratePaper = async () => {` 函数(约2281行),将其整体替换为:
|
||||
|
||||
```javascript
|
||||
const handleGeneratePaper = async () => {
|
||||
if (!paperConfig.doc) {
|
||||
message.warning('请先选择制度文件')
|
||||
return
|
||||
}
|
||||
|
||||
const totalCount = paperConfig.singleCount + paperConfig.multipleCount + paperConfig.judgmentCount + paperConfig.essayCount
|
||||
if (totalCount <= 0) {
|
||||
message.warning('请至少设置一种题型的数量')
|
||||
return
|
||||
}
|
||||
|
||||
isGeneratingPaper.value = true
|
||||
showGeneratePaperModal.value = true
|
||||
|
||||
try {
|
||||
let collection = 'public_kb'
|
||||
let collectionName = ''
|
||||
let fileIds = []
|
||||
|
||||
if (paperConfig.doc) {
|
||||
if (typeof paperConfig.doc === 'object') {
|
||||
if (paperConfig.doc.collectionName) {
|
||||
collection = paperConfig.doc.collectionName
|
||||
collectionName = paperConfig.doc.collectionName
|
||||
}
|
||||
if (paperConfig.doc.id) {
|
||||
fileIds = [paperConfig.doc.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 难度百分比 → 1-5 加权计算
|
||||
const weightedDifficulty = Math.round(
|
||||
(paperConfig.easyPercent * 1 + paperConfig.mediumPercent * 3 + paperConfig.hardPercent * 5) / 100
|
||||
)
|
||||
|
||||
const params = {
|
||||
single_choice_count: paperConfig.singleCount,
|
||||
multiple_choice_count: paperConfig.multipleCount,
|
||||
true_false_count: paperConfig.judgmentCount,
|
||||
subjective_count: paperConfig.essayCount,
|
||||
fill_blank_count: 0,
|
||||
include_personal: false,
|
||||
difficulty: weightedDifficulty,
|
||||
collection: collection,
|
||||
collection_name: collectionName,
|
||||
file_ids: fileIds
|
||||
}
|
||||
|
||||
const result = await examAPI.generatePaper(params)
|
||||
|
||||
const questions = (result.questions || []).map((q, index) => ({
|
||||
id: index + 1,
|
||||
questionId: q.question_id,
|
||||
type: q.question_type,
|
||||
typeLabel: q.question_type_name || q.question_type,
|
||||
difficulty: q.difficulty,
|
||||
score: q.score,
|
||||
stem: q.content?.stem || '',
|
||||
options: q.content?.data?.options || q.content?.options || [],
|
||||
answer: q.content?.answer || '',
|
||||
explanation: q.content?.explanation || '',
|
||||
studentAnswer: null
|
||||
}))
|
||||
|
||||
const newPaper = {
|
||||
id: Date.now(),
|
||||
paperId: result.paper_id,
|
||||
name: paperConfig.name || '智能组卷',
|
||||
doc: typeof paperConfig.doc === 'object' ? (paperConfig.doc.title || paperConfig.doc.rawFileName || '') : paperConfig.doc,
|
||||
questions: questions.length,
|
||||
totalScore: result.total_score || 100,
|
||||
duration: paperConfig.duration,
|
||||
status: 'draft',
|
||||
questionList: questions
|
||||
}
|
||||
|
||||
papers.value.unshift(newPaper)
|
||||
message.success(`试卷生成成功!共 ${questions.length} 道题`)
|
||||
} catch (error) {
|
||||
console.error('智能组卷失败:', error)
|
||||
message.error('智能组卷失败: ' + (error.message || '请检查网络连接'))
|
||||
} finally {
|
||||
isGeneratingPaper.value = false
|
||||
setTimeout(() => {
|
||||
showGeneratePaperModal.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 isGeneratingPaper ref**
|
||||
|
||||
在文件 script 部分的 ref 声明区(约1550行附近),找到 `const isGenerating = ref(false)` 所在区块,在其附近添加:
|
||||
|
||||
```javascript
|
||||
const isGeneratingPaper = ref(false)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 智能组卷 - 更新查看试卷弹窗
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 更新查看试卷弹窗以展示真实题目**
|
||||
|
||||
找到 `showViewPaperModal` 对应弹窗的模板(约964-1018行),将弹窗内容替换为:
|
||||
|
||||
```html
|
||||
<a-modal
|
||||
title="查看试卷"
|
||||
v-model:open="showViewPaperModal"
|
||||
@cancel="showViewPaperModal = false"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="currentPaper">
|
||||
<a-descriptions bordered :column="1" size="middle">
|
||||
<a-descriptions-item label="试卷名称">
|
||||
{{ currentPaper.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关联制度">
|
||||
{{ currentPaper.doc }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="题目数量">
|
||||
{{ currentPaper.questions }} 题
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总分">
|
||||
{{ currentPaper.totalScore || 100 }} 分
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="考试时长">
|
||||
{{ currentPaper.duration }} 分钟
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="试卷状态">
|
||||
<a-badge :status="currentPaper.status === 'published' ? 'success' : 'warning'" :text="currentPaper.status === 'published' ? '已发布' : '草稿'" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h4>试卷题目预览</h4>
|
||||
<div v-if="currentPaper.questionList && currentPaper.questionList.length > 0">
|
||||
<div v-for="(q, idx) in currentPaper.questionList" :key="idx" class="preview-question-item" style="margin-top: 16px; padding: 16px; background-color: #fafafa; border-radius: 8px; border: 1px solid #e8e8e8;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span class="type-badge">{{ q.typeLabel || q.type }}</span>
|
||||
<strong style="margin-left: 8px;">第 {{ idx + 1 }} 题({{ q.score || 5 }} 分)</strong>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">{{ q.stem }}</div>
|
||||
<div v-if="q.options && q.options.length > 0" style="margin-left: 16px;">
|
||||
<div v-for="opt in q.options" :key="opt.key" style="margin-bottom: 4px;">
|
||||
{{ opt.key }}. {{ opt.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align: center; padding: 40px; color: #999;">
|
||||
暂无题目预览
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 互动训练 - 预设闯关关卡
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换 trainingData 为 ref 响应式数据**
|
||||
|
||||
找到 `const trainingData = {` 定义(约1819行),替换为:
|
||||
|
||||
```javascript
|
||||
const trainingData = ref({
|
||||
checkpoints: [
|
||||
{ id: 1, name: '入门挑战', status: 'in-progress', score: 0, singleCount: 3, multipleCount: 0, trueFalseCount: 2, fillBlankCount: 0, subjectiveCount: 0, difficulty: 1 },
|
||||
{ id: 2, name: '基础巩固', status: 'pending', score: 0, singleCount: 4, multipleCount: 2, trueFalseCount: 2, fillBlankCount: 0, subjectiveCount: 0, difficulty: 2 },
|
||||
{ id: 3, name: '进阶提升', status: 'pending', score: 0, singleCount: 4, multipleCount: 3, trueFalseCount: 3, fillBlankCount: 0, subjectiveCount: 0, difficulty: 3 },
|
||||
{ id: 4, name: '高级挑战', status: 'pending', score: 0, singleCount: 3, multipleCount: 3, trueFalseCount: 2, fillBlankCount: 0, subjectiveCount: 2, difficulty: 4 },
|
||||
{ id: 5, name: '大师试炼', status: 'pending', score: 0, singleCount: 4, multipleCount: 4, trueFalseCount: 2, fillBlankCount: 0, subjectiveCount: 2, difficulty: 5 }
|
||||
],
|
||||
dailyExercises: [],
|
||||
wrongQuestions: []
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换闯关模式模板中涉及 trainingData 的属性访问**
|
||||
|
||||
找到模板中所有 `trainingData.` 开头的属性访问,替换为 `trainingData.` -> `trainingData.`(Vue ref 在模板中自动解包,用 `.value` 或在 script 中通过 computed 适配)。
|
||||
|
||||
将模板中的统计部分(约613-634行):
|
||||
|
||||
```html
|
||||
<div class="stat-value">{{ trainingData.checkpoints.filter(c => c.status === 'completed').length }}</div>
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```html
|
||||
<div class="stat-value">{{ completedCheckpointCount }}</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加计算属性**
|
||||
|
||||
在 script 区添加计算属性以适配模板中的统计展示:
|
||||
|
||||
```javascript
|
||||
const completedCheckpointCount = computed(() => {
|
||||
return trainingData.value.checkpoints.filter(c => c.status === 'completed').length
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 替换闯关模式模板的关卡列表渲染**
|
||||
|
||||
找到闯关模式面板的模板(约659-693行),替换为:
|
||||
|
||||
```html
|
||||
<div v-if="trainTab === 'checkpoint'" class="exam-card">
|
||||
<div class="card-header">
|
||||
<h2>🗺️ 闯关地图</h2>
|
||||
<span class="status-badge status-info">共 {{ trainingData.value.checkpoints.length }} 关</span>
|
||||
</div>
|
||||
|
||||
<div class="checkpoint-map">
|
||||
<div
|
||||
v-for="(checkpoint, index) in trainingData.value.checkpoints"
|
||||
:key="checkpoint.id"
|
||||
:class="['checkpoint-item', checkpoint.status]"
|
||||
>
|
||||
<div class="checkpoint-node" @click="handleStartCheckpoint(checkpoint)">
|
||||
<div class="node-circle">
|
||||
{{ checkpoint.id }}
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<div class="node-name">{{ checkpoint.name }}</div>
|
||||
<div :class="['node-status', checkpoint.status]">
|
||||
{{ checkpoint.status === 'completed' ? `✅ 得分:${checkpoint.score}分` :
|
||||
checkpoint.status === 'in-progress' ? '🔄 进行中...' : '⏳ 未开始' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:class="['btn', 'btn-primary']"
|
||||
@click.stop="handleStartCheckpoint(checkpoint)"
|
||||
>
|
||||
{{ checkpoint.status === 'completed' ? '重新挑战' : checkpoint.status === 'in-progress' ? '继续挑战' : '🔒 开始挑战' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="index < trainingData.value.checkpoints.length - 1" class="checkpoint-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 更新 handleStartCheckpoint 函数**
|
||||
|
||||
找到该函数(约2386行),替换为:
|
||||
|
||||
```javascript
|
||||
const handleStartCheckpoint = async (checkpoint) => {
|
||||
try {
|
||||
const params = {
|
||||
single_choice_count: checkpoint.singleCount || 0,
|
||||
multiple_choice_count: checkpoint.multipleCount || 0,
|
||||
true_false_count: checkpoint.trueFalseCount || 0,
|
||||
fill_blank_count: checkpoint.fillBlankCount || 0,
|
||||
subjective_count: checkpoint.subjectiveCount || 0,
|
||||
include_personal: false,
|
||||
difficulty: checkpoint.difficulty || 2,
|
||||
collection: 'public_kb'
|
||||
}
|
||||
|
||||
message.loading({ content: '正在生成闯关题目...', key: 'checkpoint' })
|
||||
|
||||
const result = await examAPI.generatePaper(params)
|
||||
|
||||
currentCheckpointQuestions.value = (result.questions || []).map((q, index) => ({
|
||||
id: index + 1,
|
||||
questionId: q.question_id,
|
||||
type: q.question_type,
|
||||
typeLabel: q.question_type_name || q.question_type,
|
||||
difficulty: q.difficulty,
|
||||
score: q.score || 5,
|
||||
stem: q.content?.stem || '',
|
||||
options: q.content?.data?.options || q.content?.options || [],
|
||||
answer: q.content?.answer || '',
|
||||
explanation: q.content?.explanation || '',
|
||||
studentAnswer: null
|
||||
}))
|
||||
|
||||
currentCheckpoint.value = {
|
||||
...checkpoint,
|
||||
paperId: result.paper_id,
|
||||
totalScore: result.total_score || 100
|
||||
}
|
||||
|
||||
showCheckpointModal.value = true
|
||||
message.destroy('checkpoint')
|
||||
} catch (error) {
|
||||
console.error('闯关题目生成失败:', error)
|
||||
message.error('题目加载失败,请重试')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 添加 currentCheckpointQuestions ref**
|
||||
|
||||
在 script 区的 ref 声明区添加:
|
||||
|
||||
```javascript
|
||||
const currentCheckpointQuestions = ref([])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 互动训练 - 闯关弹窗改造
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换闯关弹窗模板**
|
||||
|
||||
找到 `showCheckpointModal` 对应弹窗(约1020-1048行),替换为:
|
||||
|
||||
```html
|
||||
<a-modal
|
||||
title="闯关挑战"
|
||||
v-model:open="showCheckpointModal"
|
||||
@cancel="showCheckpointModal = false"
|
||||
width="700px"
|
||||
>
|
||||
<template #footer>
|
||||
<a-button @click="showCheckpointModal = false">退出</a-button>
|
||||
<a-button type="primary" @click="handleSubmitCheckpoint" :loading="isGrading" :disabled="!allCheckpointAnswered">
|
||||
{{ isGrading ? '批改中...' : '提交答案' }}
|
||||
</a-button>
|
||||
</template>
|
||||
<div v-if="currentCheckpoint && currentCheckpointQuestions.length > 0">
|
||||
<h3 style="margin-bottom: 16px; color: #1890ff;">{{ currentCheckpoint.name }}</h3>
|
||||
|
||||
<div v-for="(q, index) in currentCheckpointQuestions" :key="q.id" style="margin-bottom: 20px; padding: 16px; background: #fafafa; border-radius: 8px; border: 1px solid #e8e8e8;">
|
||||
<div class="question-header" style="margin-bottom: 8px;">
|
||||
<span :class="['type-badge', q.type]">{{ q.typeLabel }}</span>
|
||||
<span class="question-number" style="margin-left: 8px;">第 {{ index + 1 }} 题({{ q.score }} 分)</span>
|
||||
</div>
|
||||
|
||||
<div class="question-content" style="margin-bottom: 12px;">{{ q.stem }}</div>
|
||||
|
||||
<div v-if="q.type === 'single_choice' && q.options?.length" class="question-options answer-options">
|
||||
<label v-for="opt in q.options" :key="opt.key" class="option-radio" :class="{ selected: q.studentAnswer === opt.key }" style="display: block; margin-bottom: 6px;">
|
||||
<input type="radio" :name="'cp_' + q.id" :value="opt.key" v-model="q.studentAnswer" />
|
||||
<span class="option-letter">{{ opt.key }}.</span>
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="q.type === 'multiple_choice' && q.options?.length" class="question-options answer-options">
|
||||
<label v-for="opt in q.options" :key="opt.key" class="option-checkbox" :class="{ selected: isMultiCheckpointSelected(q, opt.key) }" style="display: block; margin-bottom: 6px;">
|
||||
<input type="checkbox" :value="opt.key" @change="toggleMultiCheckpointAnswer(q, opt.key)" :checked="isMultiCheckpointSelected(q, opt.key)" />
|
||||
<span class="option-letter">{{ opt.key }}.</span>
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="q.type === 'true_false'" class="question-options answer-options">
|
||||
<label class="option-radio" :class="{ selected: q.studentAnswer === 'true' }" style="display: inline-block; margin-right: 16px;">
|
||||
<input type="radio" :name="'cp_' + q.id" value="true" v-model="q.studentAnswer" />
|
||||
<span class="option-letter">A.</span>
|
||||
<span class="option-text">正确</span>
|
||||
</label>
|
||||
<label class="option-radio" :class="{ selected: q.studentAnswer === 'false' }" style="display: inline-block;">
|
||||
<input type="radio" :name="'cp_' + q.id" value="false" v-model="q.studentAnswer" />
|
||||
<span class="option-letter">B.</span>
|
||||
<span class="option-text">错误</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="q.type === 'subjective'" class="subjective-answer">
|
||||
<label class="form-label">请作答:</label>
|
||||
<textarea v-model="q.studentAnswer" class="form-input essay-textarea" rows="3" placeholder="请输入您的答案..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align: center; padding: 40px; color: #999;">
|
||||
正在加载题目...
|
||||
</div>
|
||||
</a-modal>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加闯关辅助函数和 handleSubmitCheckpoint**
|
||||
|
||||
在 script 区添加:
|
||||
|
||||
```javascript
|
||||
const allCheckpointAnswered = computed(() => {
|
||||
return currentCheckpointQuestions.value.length > 0 &&
|
||||
currentCheckpointQuestions.value.every(q => q.studentAnswer != null && q.studentAnswer !== '')
|
||||
})
|
||||
|
||||
const isMultiCheckpointSelected = (q, key) => {
|
||||
if (!Array.isArray(q.studentAnswer)) {
|
||||
q.studentAnswer = []
|
||||
}
|
||||
return q.studentAnswer.includes(key)
|
||||
}
|
||||
|
||||
const toggleMultiCheckpointAnswer = (q, key) => {
|
||||
if (!Array.isArray(q.studentAnswer)) {
|
||||
q.studentAnswer = []
|
||||
}
|
||||
const idx = q.studentAnswer.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
q.studentAnswer.splice(idx, 1)
|
||||
} else {
|
||||
q.studentAnswer.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitCheckpoint = async () => {
|
||||
if (!allCheckpointAnswered.value) return
|
||||
|
||||
isGrading.value = true
|
||||
|
||||
try {
|
||||
const answers = currentCheckpointQuestions.value.map(q => ({
|
||||
question_id: q.questionId || `q_${q.id}`,
|
||||
question_type: q.type,
|
||||
question_content: {
|
||||
stem: q.stem,
|
||||
data: q.options && q.options.length > 0 ? { options: q.options } : undefined,
|
||||
answer: q.answer
|
||||
},
|
||||
student_answer: q.type === 'multiple_choice' ? (q.studentAnswer || []).join(',') : q.studentAnswer,
|
||||
max_score: q.score || 5
|
||||
}))
|
||||
|
||||
const result = await examAPI.gradeAnswers({
|
||||
request_id: `cp_${Date.now()}`,
|
||||
answers
|
||||
})
|
||||
|
||||
const totalScore = result.total_score || 0
|
||||
const totalMax = result.total_max_score || 0
|
||||
|
||||
// 更新关卡状态
|
||||
const checkpoint = trainingData.value.checkpoints.find(c => c.id === currentCheckpoint.value?.id)
|
||||
if (checkpoint) {
|
||||
checkpoint.status = 'completed'
|
||||
checkpoint.score = totalScore
|
||||
}
|
||||
|
||||
// 缓存错题
|
||||
if (result.results) {
|
||||
result.results.forEach((item, idx) => {
|
||||
if (item.correct === false) {
|
||||
const q = currentCheckpointQuestions.value[idx]
|
||||
if (q) {
|
||||
const existing = trainingData.value.wrongQuestions.find(w => w.questionId === q.questionId)
|
||||
if (!existing) {
|
||||
trainingData.value.wrongQuestions.push({
|
||||
id: Date.now() + idx,
|
||||
questionId: q.questionId,
|
||||
content: q.stem,
|
||||
type: q.type,
|
||||
typeLabel: q.typeLabel,
|
||||
options: q.options,
|
||||
correctAnswer: q.answer,
|
||||
studentAnswer: q.studentAnswer,
|
||||
score: item.score || 0,
|
||||
maxScore: item.max_score || q.score || 5,
|
||||
feedback: item.feedback || '',
|
||||
doc: currentCheckpoint.value?.name || '',
|
||||
times: 1,
|
||||
answeredAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
gradeResult.value = { ...result, checkpointName: currentCheckpoint.value?.name }
|
||||
showAnswerModal.value = true
|
||||
message.success(`闯关完成!得分: ${totalScore}/${totalMax}`)
|
||||
} catch (error) {
|
||||
console.error('闯关提交失败:', error)
|
||||
message.error('提交失败: ' + (error.message || '请重试'))
|
||||
} finally {
|
||||
isGrading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 互动训练 - 错题本对接
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换错题本模板**
|
||||
|
||||
找到错题本面板模板(约770-810行),替换为:
|
||||
|
||||
```html
|
||||
<div v-if="trainTab === 'wrong'" class="exam-card">
|
||||
<div class="card-header">
|
||||
<h2>📚 我的错题本</h2>
|
||||
<span class="status-badge status-warning">共 {{ trainingData.value.wrongQuestions.length }} 道错题</span>
|
||||
</div>
|
||||
|
||||
<div class="questions-list" v-if="trainingData.value.wrongQuestions.length > 0">
|
||||
<div v-for="item in trainingData.value.wrongQuestions" :key="item.id" class="question-item wrong-item">
|
||||
<div class="question-header">
|
||||
<span :class="['type-badge', item.type]">
|
||||
{{ item.type === 'single_choice' ? '单选题' : item.type === 'multiple_choice' ? '多选题' : item.type === 'true_false' ? '判断题' : item.type === 'subjective' ? '简答题' : item.type }}
|
||||
</span>
|
||||
<div class="question-actions">
|
||||
<button class="btn btn-primary btn-xs" @click="handleRetryWrongQuestion(item)">重练</button>
|
||||
<button class="btn btn-success btn-xs" @click="handleViewWrongQuestion(item)">查看解析</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-content">{{ item.content }}</div>
|
||||
|
||||
<div class="wrong-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">关联关卡:</span>
|
||||
<span class="meta-value">{{ item.doc }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">错误次数:</span>
|
||||
<span class="meta-value error-count">{{ item.times }} 次</span>
|
||||
</div>
|
||||
<div v-if="item.feedback" class="meta-item">
|
||||
<span class="meta-label">批改反馈:</span>
|
||||
<span class="meta-value">{{ item.feedback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">🎉</div>
|
||||
<div class="empty-text">太棒了!目前没有错题</div>
|
||||
<div class="empty-hint">继续保持,加油学习!</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 handleRetryWrongQuestion 函数**
|
||||
|
||||
在 script 区添加:
|
||||
|
||||
```javascript
|
||||
const handleRetryWrongQuestion = (item) => {
|
||||
// 将该错题的对应关卡设置为 in-progress 状态
|
||||
const checkpoint = trainingData.value.checkpoints.find(c => c.name === item.doc)
|
||||
if (checkpoint) {
|
||||
checkpoint.status = 'in-progress'
|
||||
checkpoint.score = 0
|
||||
message.success(`已重置关卡「${checkpoint.name}」,前往闯关模式重新挑战`)
|
||||
trainTab.value = 'checkpoint'
|
||||
} else {
|
||||
message.info('请前往闯关模式重新挑战')
|
||||
trainTab.value = 'checkpoint'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 更新每日一练提交后的错题缓存
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 在 handleSubmitDailyExercise 成功后添加错题缓存**
|
||||
|
||||
找到 `handleSubmitDailyExercise` 函数中的 `gradeResult.value = result` 行(约2441行),在其后添加:
|
||||
|
||||
```javascript
|
||||
// 缓存错题
|
||||
if (result.results) {
|
||||
result.results.forEach((item, idx) => {
|
||||
if (item.correct === false) {
|
||||
const q = generatedQuestions.value[idx]
|
||||
if (q) {
|
||||
const existing = trainingData.value.wrongQuestions.find(w => w.questionId === `q_${q.id}`)
|
||||
if (!existing) {
|
||||
trainingData.value.wrongQuestions.push({
|
||||
id: Date.now() + idx,
|
||||
questionId: `q_${q.id}`,
|
||||
content: q.stem,
|
||||
type: q.type,
|
||||
typeLabel: q.typeLabel,
|
||||
options: q.options,
|
||||
correctAnswer: q.answer,
|
||||
studentAnswer: q.studentAnswer,
|
||||
score: item.score || 0,
|
||||
maxScore: item.max_score || q.maxScore || 5,
|
||||
feedback: item.feedback || '',
|
||||
doc: '每日一练',
|
||||
times: 1,
|
||||
answeredAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 更新互动训练统计展示
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换模板中的 trainingData 属性引用**
|
||||
|
||||
找到模板中所有剩余的 `trainingData.xxx` 引用(约618-634行,训练统计部分),替换为通过 `trainingData.value` 访问的等价表达式:
|
||||
|
||||
```html
|
||||
<div class="stat-value">{{ completedCheckpointCount }}</div>
|
||||
...
|
||||
<div class="stat-value">{{ trainingData.value.dailyExercises.length || generatedQuestions.length }}</div>
|
||||
...
|
||||
<div class="stat-value">{{ trainingData.value.wrongQuestions.length }}</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 dailyExerciseCount 计算属性
|
||||
|
||||
```javascript
|
||||
const dailyExerciseCount = computed(() => {
|
||||
return trainingData.value.dailyExercises.length || generatedQuestions.value.length
|
||||
})
|
||||
```
|
||||
|
||||
然后模板中用 `{{ dailyExerciseCount }}` 替换 `{{ trainingData.value.dailyExercises.length || generatedQuestions.length }}`。
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 错题本查看解析弹窗改造
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ExamModule.vue`
|
||||
|
||||
- [ ] **Step 1: 替换查看解析弹窗**
|
||||
|
||||
找到 `showAnalysisModal` 对应弹窗(约1116-1145行),替换为:
|
||||
|
||||
```html
|
||||
<a-modal
|
||||
title="查看解析"
|
||||
v-model:open="showAnalysisModal"
|
||||
@cancel="showAnalysisModal = false"
|
||||
width="700px"
|
||||
>
|
||||
<template #footer>
|
||||
<a-button @click="showAnalysisModal = false">关闭</a-button>
|
||||
</template>
|
||||
<div v-if="currentWrongQuestion">
|
||||
<h4 style="margin-bottom: 16px;">{{ currentWrongQuestion.content }}</h4>
|
||||
|
||||
<div v-if="currentWrongQuestion.options && currentWrongQuestion.options.length > 0" style="margin-bottom: 16px;">
|
||||
<h6>题目选项:</h6>
|
||||
<div v-for="opt in currentWrongQuestion.options" :key="opt.key" style="margin-bottom: 4px;">
|
||||
{{ opt.key }}. {{ opt.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h6>你的答案:</h6>
|
||||
<p style="color: #ff4d4f;">{{ formatAnswer(currentWrongQuestion.studentAnswer) }}</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h6>正确答案:</h6>
|
||||
<p style="color: #52c41a;">{{ formatAnswer(currentWrongQuestion.correctAnswer) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentWrongQuestion.feedback" style="margin-bottom: 16px;">
|
||||
<h6>AI评语:</h6>
|
||||
<p>{{ currentWrongQuestion.feedback }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 formatAnswer 工具函数
|
||||
|
||||
```javascript
|
||||
const formatAnswer = (answer) => {
|
||||
if (answer === null || answer === undefined) return '未作答'
|
||||
if (Array.isArray(answer)) return answer.join(', ')
|
||||
return String(answer)
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user