# 考试管理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
{{ currentPaper.name }}
{{ currentPaper.doc }}
{{ currentPaper.questions }} 题
{{ currentPaper.totalScore || 100 }} 分
{{ currentPaper.duration }} 分钟
试卷题目预览
{{ q.typeLabel || q.type }}
第 {{ idx + 1 }} 题({{ q.score || 5 }} 分)
{{ q.stem }}
{{ opt.key }}. {{ opt.content }}
暂无题目预览
```
---
### 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
{{ trainingData.checkpoints.filter(c => c.status === 'completed').length }}
```
改为:
```html
{{ completedCheckpointCount }}
```
- [ ] **Step 3: 添加计算属性**
在 script 区添加计算属性以适配模板中的统计展示:
```javascript
const completedCheckpointCount = computed(() => {
return trainingData.value.checkpoints.filter(c => c.status === 'completed').length
})
```
- [ ] **Step 4: 替换闯关模式模板的关卡列表渲染**
找到闯关模式面板的模板(约659-693行),替换为:
```html
{{ checkpoint.id }}
{{ checkpoint.name }}
{{ checkpoint.status === 'completed' ? `✅ 得分:${checkpoint.score}分` :
checkpoint.status === 'in-progress' ? '🔄 进行中...' : '⏳ 未开始' }}
```
- [ ] **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
退出
{{ isGrading ? '批改中...' : '提交答案' }}
{{ currentCheckpoint.name }}
正在加载题目...
```
- [ ] **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
```
- [ ] **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
{{ completedCheckpointCount }}
...
{{ trainingData.value.dailyExercises.length || generatedQuestions.length }}
...
{{ trainingData.value.wrongQuestions.length }}
```
- [ ] **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
关闭
{{ currentWrongQuestion.content }}
题目选项:
{{ opt.key }}. {{ opt.content }}
你的答案:
{{ formatAnswer(currentWrongQuestion.studentAnswer) }}
正确答案:
{{ formatAnswer(currentWrongQuestion.correctAnswer) }}
AI评语:
{{ currentWrongQuestion.feedback }}
```
- [ ] **Step 2: 添加 formatAnswer 工具函数
```javascript
const formatAnswer = (answer) => {
if (answer === null || answer === undefined) return '未作答'
if (Array.isArray(answer)) return answer.join(', ')
return String(answer)
}
```