前端项目初始化提交

This commit is contained in:
2026-06-03 13:16:30 +08:00
commit 0910ba9cbe
163 changed files with 110032 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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)
}
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,489 @@
# GeneratePanel 布局重构实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 重构GeneratePanel组件为顶底主从式布局解决内容截断问题并优化用户体验
**Architecture:** 采用Flexbox垂直布局配置面板flex-shrink: 0固定在顶部结果展示区flex: 1自适应填充剩余空间并独立滚动。表单内部使用CSS Grid双列布局提高信息密度。
**Tech Stack:** Vue 3 (Composition API), CSS Grid, Flexbox, Scoped Styles
---
## 文件结构
### 需要修改的文件:
- **Modify:** `src/components/exam/GeneratePanel.vue` - 主要重构目标
- Template部分重新组织DOM结构分离配置区和结果区
- Script部分无需改动保持现有逻辑
- Style部分完全重写布局样式
---
### Task 1: 重构GeneratePanel容器布局 - 实现顶底主从式结构
**Files:**
- Modify: `src/components/exam/GeneratePanel.vue` (Template + Style)
- [ ] **Step 1: 重新组织Template结构**
将现有的两个ContentCard包裹在语义化的容器中
```vue
<template>
<div class="generate-panel">
<!-- 页面头部和统计保持不变 -->
<PageHeader
title="AI智能出题系统"
description="基于AI技术自动生成高质量试题"
/>
<StatsRow :stats="examTypeStats" />
<!-- 新增配置面板容器 -->
<div class="config-panel">
<ContentCard title="智能出题配置">
<template #header>
<h2 class="card-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
智能出题配置
</h2>
</template>
<!-- 表单内容将在Task 2中优化 -->
<div class="form-content">
<!-- 现有表单代码保持不变 -->
<div class="form-item">...</div>
<div class="form-item">...</div>
<div class="form-item">...</div>
<div class="action-bar">...</div>
</div>
</ContentCard>
</div>
<!-- 新增结果展示区容器 -->
<div class="result-panel">
<ContentCard title="待审核题目列表">
<div v-if="isGenerating" class="loading-state">...</div>
<div v-else-if="generatedQuestions.length === 0" class="empty-state">...</div>
<div v-else class="question-list">
<!-- 现有题目列表代码保持不变 -->
</div>
</ContentCard>
</div>
</div>
</template>
```
- [ ] **Step 2: 重写容器级CSS样式**
```css
.generate-panel {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px;
overflow: hidden; /* 外层不滚动 */
}
/* 配置面板 - 固定高度,不压缩 */
.config-panel {
flex-shrink: 0;
margin-bottom: 12px;
}
/* 结果展示区 - 自适应填充剩余空间 */
.result-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.result-panel :deep(.content-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.result-panel :deep(.card-body) {
flex: 1;
overflow-y: auto; /* 内容独立滚动 */
}
```
- [ ] **Step 3: 验证基础布局结构**
刷新浏览器,确认:
- ✅ 配置面板显示在顶部且完整可见
- ✅ 结果展示区占据下方所有剩余空间
- ✅ 整体无滚动条(除了结果区的内部滚动)
---
### Task 2: 优化配置表单为双列Grid布局 - 提高信息密度
**Files:**
- Modify: `src/components/exam/GeneratePanel.vue` (Template中的.form-content部分)
- [ ] **Step 1: 将表单项重组为Grid布局**
替换`.form-content`内部的HTML结构
```vue
<div class="form-content">
<!-- 文件选择器 - 跨两列 -->
<div class="form-item form-item--full">
<label class="form-label">选择制度文件 <span class="required-mark">*</span></label>
<FileSelector
v-model="selectedDoc"
@change="onDocSelected"
@collection-change="onCollectionChange"
placeholder="先选择知识库,再选择制度文件..."
/>
<div v-if="selectedCollectionName" class="collection-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>当前知识库: <strong>{{ selectedCollectionName }}</strong></span>
</div>
<div v-if="selectedDocData" class="selected-file-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>{{ selectedDocData.title || selectedDocData.rawFileName }}</span>
<span>v{{ selectedDocData.version }}</span>
</div>
</div>
<!-- 难度等级 + 总计题数 - 并排显示 -->
<div class="form-item">
<label class="form-label">难度等级</label>
<select v-model="difficulty" class="form-select">
<option :value="1">简单</option>
<option :value="2">中等</option>
<option :value="3">困难</option>
</select>
</div>
<div class="form-item form-item--total">
<label class="form-label">总计题数</label>
<div class="total-display">
<strong>{{ totalQuestionCount }}</strong>
</div>
</div>
<!-- 题型与数量配置 - 跨两列 -->
<div class="form-item form-item--full">
<label class="form-label">题型与数量配置</label>
<div class="type-count-grid">
<div class="type-count-item" v-for="qt in questionTypeOptions" :key="qt.value">
<label class="checkbox-label" :class="{ active: questionTypeCounts[qt.value] > 0 }">
<input type="checkbox" :checked="questionTypeCounts[qt.value] > 0" @change="toggleQuestionType(qt.value)" />
{{ qt.label }}
</label>
<input
type="number"
v-model.number="questionTypeCounts[qt.value]"
min="0"
max="50"
class="form-input-sm"
:disabled="questionTypeCounts[qt.value] <= 0"
placeholder="0"
/>
</div>
</div>
</div>
<!-- 操作按钮 - 跨两列右对齐 -->
<div class="action-bar">
<button class="btn btn-primary" @click="handleGenerateQuestions" :disabled="isGenerating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path>
</svg>
{{ isGenerating ? '生成中...' : '开始生成题目' }}
</button>
<button class="btn btn-secondary" @click="generatedQuestions = []">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
清空结果
</button>
</div>
</div>
```
- [ ] **Step 2: 编写Grid布局CSS**
```css
.form-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
align-items: start;
}
/* 跨列元素 */
.form-item--full {
grid-column: 1 / -1;
}
/* 难度等级和总计的特殊样式 */
.form-item--total .total-display {
padding: 6px 12px;
background: #F8F9FA;
border: 1px solid #DEE2E6;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
color: #212529;
text-align: center;
}
/* 按钮栏右对齐 */
.action-bar {
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #E9ECEF;
margin-top: 4px;
}
/* 题型网格优化为3列 */
.type-count-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 6px;
}
```
- [ ] **Step 3: 验证双列布局效果**
确认:
- ✅ 文件选择器独占一行跨2列
- ✅ 难度等级和总计题数左右并排
- ✅ 题型配置以3列网格显示
- ✅ 操作按钮右对齐且跨2列
---
### Task 3: 实现结果展示区独立滚动 - 自适应高度填充
**Files:**
- Modify: `src/components/exam/GeneratePanel.vue` (Style部分)
- [ ] **Step 1: 优化空状态和加载状态样式**
```css
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: #6C757D;
min-height: 150px;
}
.loading-icon,
.empty-icon {
font-size: 36px;
margin-bottom: 10px;
}
.empty-state p {
font-size: 14px;
font-weight: 500;
color: #495057;
margin-bottom: 4px;
}
.empty-hint {
font-size: 13px;
color: #6C757D;
margin-top: 6px;
max-width: 400px;
text-align: center;
line-height: 1.5;
}
```
- [ ] **Step 2: 优化题目列表样式**
```css
.question-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px;
}
.question-item {
padding: 14px;
background: #FFFFFF;
border: 1px solid #E9ECEF;
border-radius: 8px;
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.question-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
```
- [ ] **Step 3: 测试滚动行为**
生成一些测试数据或使用loading状态验证
- ✅ 结果区可以独立滚动
- ✅ 配置面板保持固定不动
- ✅ 无整体页面滚动条
- ✅ 滚动条样式美观(可选优化)
---
### Task 4: 响应式适配与细节优化 - 移动端降级处理
**Files:**
- Modify: `src/components/exam/GeneratePanel.vue` (Style部分添加媒体查询)
- [ ] **Step 1: 添加移动端响应式规则**
```css
/* 平板设备 (≤1024px) */
@media (max-width: 1024px) {
.type-count-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-content {
gap: 10px 12px;
}
}
/* 移动设备 (≤768px) */
@media (max-width: 768px) {
.generate-panel {
padding: 12px;
}
/* 降级为单列布局 */
.form-content {
grid-template-columns: 1fr;
gap: 10px;
}
.form-item--full,
.action-bar {
grid-column: 1;
}
/* 题型降为2列 */
.type-count-grid {
grid-template-columns: repeat(2, 1fr);
}
/* 按钮全宽 */
.action-bar {
flex-direction: column;
}
.action-bar .btn {
width: 100%;
}
/* 减少间距 */
.config-panel {
margin-bottom: 8px;
}
/* 缩小字体和内边距 */
.form-label {
font-size: 12px;
}
.type-count-item {
padding: 6px 8px;
}
}
/* 小屏手机 (≤480px) */
@media (max-width: 480px) {
.type-count-grid {
grid-template-columns: 1fr;
}
.stats-row {
font-size: 12px;
}
}
```
- [ ] **Step 2: 测试不同屏幕尺寸**
使用浏览器开发者工具测试:
- ✅ 桌面端 (1920x1080): 双列布局完美呈现
- ✅ 笔记本 (1366x768): 正常显示
- ✅ 平板 (768x1024): 降级为单列,按钮堆叠
- ✅ 手机 (375x667): 单列紧凑布局
- [ ] **Step 3: 最终视觉检查**
确认所有细节:
- ✅ 无内容溢出或被截断
- ✅ 所有交互元素可点击
- ✅ 样式符合项目白色体系规范
- ✅ 过渡动画流畅自然
- ✅ 空状态、加载状态、正常状态均正确显示
---
## 验收标准
完成所有任务后GeneratePanel应满足以下要求
1. **布局正确性**
- 配置面板始终可见,不被遮挡
- 结果区自适应填充剩余空间
- 仅结果区支持独立滚动
2. **表单可用性**
- 双列Grid布局信息密度合理
- 所有表单项完整显示
- 操作按钮易于触达
3. **响应式表现**
- 桌面端:双列布局
- 移动端:自动降级为单列
- 无横向滚动条
4. **视觉一致性**
- 符合项目白色体系设计规范
- 与其他模块风格统一
- 间距、字号、圆角等细节一致
---
## 执行选项
**Plan complete and saved to this document. Two execution options:**
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
**Which approach?**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff