前端项目初始化提交
This commit is contained in:
1035
docs/superpowers/plans/2026-04-24-aichat-sse-refactor.md
Normal file
1035
docs/superpowers/plans/2026-04-24-aichat-sse-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
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)
|
||||
}
|
||||
```
|
||||
1570
docs/superpowers/plans/2026-05-14-exam-module-refactoring-plan.md
Normal file
1570
docs/superpowers/plans/2026-05-14-exam-module-refactoring-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1115
docs/superpowers/plans/2026-05-14-frontend-white-system-refactor.md
Normal file
1115
docs/superpowers/plans/2026-05-14-frontend-white-system-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1749
docs/superpowers/plans/2026-05-29-paper-compose-redesign-plan.md
Normal file
1749
docs/superpowers/plans/2026-05-29-paper-compose-redesign-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1139
docs/superpowers/plans/2026-05-29-upload-task-panel-plan.md
Normal file
1139
docs/superpowers/plans/2026-05-29-upload-task-panel-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2460
docs/superpowers/plans/2026-05-30-universal-search-engine-plan.md
Normal file
2460
docs/superpowers/plans/2026-05-30-universal-search-engine-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
153
docs/superpowers/specs/2026-05-10-exam-api-integration-design.md
Normal file
153
docs/superpowers/specs/2026-05-10-exam-api-integration-design.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 考试管理API对接设计文档
|
||||
|
||||
## 一、概述
|
||||
|
||||
### 1.1 目标
|
||||
对「考察与训练」模块中的「智能组卷」和「互动训练」两个子功能进行后端API对接,替换现有的模拟数据,实现与后端考试管理接口的真实数据交互。
|
||||
|
||||
### 1.2 范围
|
||||
- **智能组卷**: 使用 `POST /exam/paper/generate` 从题库自动组卷
|
||||
- **互动训练-闯关模式**: 预设固定关卡,每关调用组卷接口生成题目
|
||||
- **互动训练-错题本**: 使用 `POST /exam/answers/query` 查询答题记录
|
||||
- **互动训练-每日一练**: 保持现有逻辑(已对接 `POST /exam/grade`)
|
||||
|
||||
### 1.3 约束
|
||||
- 组卷结果仅前端暂存,不持久化到后端
|
||||
- 闯关模式为预设固定关卡
|
||||
- 仅修改前端,不修改后端代码
|
||||
|
||||
---
|
||||
|
||||
## 二、涉及接口
|
||||
|
||||
### 2.1 生成试卷 `POST /api/exam/paper/generate`
|
||||
|
||||
**请求参数**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| single_choice_count | Integer | 单选题数量 |
|
||||
| multiple_choice_count | Integer | 多选题数量 |
|
||||
| true_false_count | Integer | 判断题数量 |
|
||||
| fill_blank_count | Integer | 填空题数量 |
|
||||
| subjective_count | Integer | 简答题数量 |
|
||||
| include_personal | Boolean | 是否包含个人题目 |
|
||||
| difficulty | Integer | 难度等级(1-5) |
|
||||
| file_ids | List<Long> | 关联文件ID列表 |
|
||||
| collection | String | 向量库名称 |
|
||||
| collection_name | String | 向量库名称(备选) |
|
||||
|
||||
**响应结构**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "试卷生成成功",
|
||||
"data": {
|
||||
"success": true,
|
||||
"paper_id": "paper_xxx",
|
||||
"paper_title": "试卷标题",
|
||||
"total_score": 100,
|
||||
"question_count": 10,
|
||||
"generated_at": "2026-05-10T10:00:00",
|
||||
"permission_scope": "department",
|
||||
"warnings": {},
|
||||
"questions": [
|
||||
{
|
||||
"question_id": "q-xxx",
|
||||
"question_type": "single_choice",
|
||||
"question_type_name": "单选题",
|
||||
"difficulty": 2,
|
||||
"score": 10,
|
||||
"content": { "stem": "...", "data": { "options": [...] }, "answer": "A" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 批改答案 `POST /api/exam/grade`
|
||||
已对接,无需修改。
|
||||
|
||||
### 2.3 查询答题记录 `POST /api/exam/answers/query`
|
||||
|
||||
**请求参数**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| paper_id | String | 试卷ID(与session_id二选一) |
|
||||
| session_id | String | 会话ID(与paper_id二选一) |
|
||||
|
||||
**响应结构**: 包含分组答题记录,其中 `is_correct` 为0时表示错误题目。
|
||||
|
||||
---
|
||||
|
||||
## 三、智能组卷改造
|
||||
|
||||
### 3.1 当前状态
|
||||
`handleGeneratePaper()` 错误地调用了 `examAPI.generateQuestions()`(AI出题接口),而不是组卷专用接口。
|
||||
|
||||
### 3.2 改造内容
|
||||
|
||||
#### 3.2.1 新增 API 方法
|
||||
在 [exam.js](file:///c:/Users/33520/Desktop/制度文件管理学习AI智能体 vue版本/src/api/exam.js) 中新增:
|
||||
- `generatePaper(params)` → 调 `POST /api/exam/paper/generate`
|
||||
|
||||
#### 3.2.2 参数映射
|
||||
| paperConfig | API字段 | 转换逻辑 |
|
||||
|---|---|---|
|
||||
| singleCount | single_choice_count | 直接映射 |
|
||||
| multipleCount | multiple_choice_count | 直接映射 |
|
||||
| judgmentCount | true_false_count | 直接映射 |
|
||||
| essayCount | subjective_count | 直接映射 |
|
||||
| difficulty (百分比) | difficulty | 加权计算: easy%×1 + medium%×3 + hard%×5 / 100 |
|
||||
| doc | collection/collection_name/file_ids | 从FileSelector对象提取 |
|
||||
|
||||
#### 3.2.3 响应处理
|
||||
API返回的 `data.questions` 包含完整的题目列表,直接作为试卷的题目内容保存在前端的 `papers` 数组中。
|
||||
|
||||
---
|
||||
|
||||
## 四、互动训练改造
|
||||
|
||||
### 4.1 闯关模式
|
||||
|
||||
#### 4.1.1 预设关卡设计
|
||||
| 关卡 | 名称 | 题目数 | 难度 | 题型组成 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 入门挑战 | 5 | 1 | 3单选+2判断 |
|
||||
| 2 | 基础巩固 | 8 | 2 | 4单选+2多选+2判断 |
|
||||
| 3 | 进阶提升 | 10 | 3 | 4单选+3多选+3判断 |
|
||||
| 4 | 高级挑战 | 10 | 4 | 3单选+3多选+2判断+2简答 |
|
||||
| 5 | 大师试炼 | 12 | 5 | 4单选+4多选+2判断+2简答 |
|
||||
|
||||
每关调用 `POST /exam/paper/generate` 生成对应配置的题目。
|
||||
|
||||
#### 4.1.2 闯关流程
|
||||
1. 用户点击关卡 → 调 `generatePaper()` 生成题目
|
||||
2. 用户在弹窗中逐题作答
|
||||
3. 点击提交 → 调 `examAPI.gradeAnswers()` 批改
|
||||
4. 显示分数和结果 → 更新关卡状态
|
||||
|
||||
### 4.2 错题本
|
||||
|
||||
#### 4.2.1 数据来源
|
||||
用户每次在闯关模式/每日一练中提交答案后:
|
||||
1. 调用 `POST /exam/answers/query` 查询答题记录
|
||||
2. 筛选 `is_correct === 0` 的题目作为错题
|
||||
3. 按时间倒序排列展示
|
||||
|
||||
#### 4.2.2 本地缓存
|
||||
每次批改后,将错题信息缓存到前端的 `wrongQuestions` 数组中,避免频繁调用查询接口。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件修改清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/api/exam.js` | 新增 `generatePaper()` 和 `queryUserAnswers()` 方法 |
|
||||
| `src/components/ExamModule.vue` | 改造智能组卷和互动训练的数据流和API调用逻辑 |
|
||||
|
||||
## 六、错误处理
|
||||
|
||||
- API 调用失败时在控制台输出详细错误信息,并在界面上给出用户友好的提示
|
||||
- 闯关模式中题目加载失败时显示重试按钮
|
||||
- 错题本查询失败时降级显示空列表,不影响其他功能使用
|
||||
234
docs/superpowers/specs/2026-05-10-exam-assessment-design.md
Normal file
234
docs/superpowers/specs/2026-05-10-exam-assessment-design.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 试卷考核功能设计文档
|
||||
|
||||
> 日期: 2026-05-10
|
||||
> 状态: 已批准
|
||||
> 模块: 考察与训练 - 试卷考核子功能
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
在现有「考察与训练」模块中新增 **试卷考核** 主标签页,为用户提供完整的考试流程:接收考试 → 答题 → 批改 → 查看成绩 → 错题练习。同时保留用户自行生成测试卷的能力。
|
||||
|
||||
## 二、模块架构
|
||||
|
||||
### 2.1 改造后的标签页结构
|
||||
|
||||
```
|
||||
考察与训练 (exam)
|
||||
├── 🤖 AI出题 (generate) — exam:ai — 管理员/有权限
|
||||
├── 📚 题库管理 (bank) — exam:bank — 管理员/有权限
|
||||
├── 📋 智能组卷 (paper) — exam:paper — 管理员专用(可编辑+发布)
|
||||
├── 📝 试卷考核 (exam) — exam:exam — 全员(答题+错题+自测)
|
||||
└── 🎯 互动训练 (train) — exam:train — 全员(闯关+每日一练)
|
||||
```
|
||||
|
||||
### 2.2 试卷考核子标签页
|
||||
|
||||
```
|
||||
试卷考核 [exam]
|
||||
├── 📋 待考试 (pending) — 管理员发布的待完成试卷列表
|
||||
├── ✏️ 答题中 (taking) — 全屏答题界面(当前正在答的试卷)
|
||||
├── 📊 成绩单 (results) — 已完成的试卷批改结果
|
||||
├── ❌ 错题本 (wrong) — 调用 wrong-questions/list API
|
||||
└── 🔧 自测组卷 (selftest) — 用户自行生成试卷(只生成不编辑,直接做题)
|
||||
```
|
||||
|
||||
## 三、权限模型
|
||||
|
||||
### 3.1 新增权限代码
|
||||
|
||||
在 `permission.js` 的 `permissionCodeToModule` 中新增:
|
||||
|
||||
```javascript
|
||||
'exam:exam': 'exam', // 试卷考核 - 查看、答题、成绩、错题
|
||||
'exam:exam:selftest': 'exam', // 自测组卷 - 用户自行生成试卷
|
||||
```
|
||||
|
||||
### 3.2 数据库新增权限记录
|
||||
|
||||
需在 `permission` 表中插入:
|
||||
|
||||
| permissionCode | permissionName | parentId(=exam节点ID) |
|
||||
|---------------|---------------|---------------------|
|
||||
| `exam:exam` | 试卷考核 | [exam父节点ID] |
|
||||
|
||||
### 3.3 角色分配建议
|
||||
|
||||
| 角色 | 可见标签页 |
|
||||
|-----|----------|
|
||||
| 超级管理员/管理员 | 全部5个(含智能组卷编辑发布) |
|
||||
| 普通员工 | AI出题 + 试卷考核 + 互动训练 |
|
||||
|
||||
### 3.4 前端权限控制变量
|
||||
|
||||
```javascript
|
||||
const canShowExam = computed(() =>
|
||||
hasChildPermission('exam:exam') || hasChildPermission('exam')
|
||||
)
|
||||
```
|
||||
|
||||
## 四、核心功能详细设计
|
||||
|
||||
### 4.1 待考试列表 (pending)
|
||||
|
||||
**功能**: 展示管理员通过「智能组卷」发布后、用户尚未完成的试卷。
|
||||
|
||||
**数据来源**: `GET /api/exam/my/papers` (§15.1)
|
||||
|
||||
**展示字段**:
|
||||
- 试卷标题 (paper_title)
|
||||
- 出卷人/来源
|
||||
- 题目数量 (question_count)
|
||||
- 总分 (total_score)
|
||||
- 发布时间 (generated_at)
|
||||
- 状态标签: `待考试`
|
||||
|
||||
**交互**: 点击卡片 → 进入全屏答题页面
|
||||
|
||||
**空状态**: "暂无待考试试卷,请等待管理员发布"
|
||||
|
||||
### 4.2 答题页面 (taking)
|
||||
|
||||
**功能**: 全屏沉浸式答题界面,支持5种题型的答案输入。
|
||||
|
||||
**触发**: 从待考试列表点击进入 / 从自测组卷生成后自动进入
|
||||
|
||||
**界面布局**:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⬅ 返回 📝 《试卷名称》 倒计时 ⏱️ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 第 1/13 题 [单选题] ★ 5分 │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ 根据公司考勤制度,迟到15分钟以内的处罚是? │
|
||||
│ │
|
||||
│ ○ A. 口头警告 │
|
||||
│ ○ B. 扣款50元 │
|
||||
│ ● C. 扣款100元 │
|
||||
│ ○ D. 视为旷工 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ < 上一题 下一题 > 提交试卷 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**题型渲染规则**:
|
||||
| question_type | 控件 | 答案格式 |
|
||||
|--------------|------|---------|
|
||||
| single_choice | Radio 单选 | 字符串 "A" |
|
||||
| multiple_choice | Checkbox 多选 | 数组 ["A","C"] |
|
||||
| true_false | Radio 是/否 | 字符串 "true"/"false" |
|
||||
| fill_blank | Input 输入框 | 字符串 |
|
||||
| subjective | Textarea 文本域 | 字符串 |
|
||||
|
||||
**状态管理**:
|
||||
```javascript
|
||||
const currentTakingPaper = ref(null) // 当前正在答的试卷
|
||||
const currentQuestionIndex = ref(0) // 当前题目索引
|
||||
const takingAnswers = ref({}) // { questionId: answer }
|
||||
const isSubmitting = ref(false) // 提交中状态
|
||||
```
|
||||
|
||||
**提交逻辑**:
|
||||
1. 收集所有答案 → 构建 `answers` 数组
|
||||
2. 调用 `POST /api/exam/grade` (§14.3)
|
||||
3. 收到批改结果 → 自动跳转到成绩单视图
|
||||
4. 错题自动标记 → 可在错题本中查看
|
||||
|
||||
### 4.3 成绩单 (results)
|
||||
|
||||
**功能**: 展示已完成的试卷批改结果。
|
||||
|
||||
**数据来源**: 提交批改时的响应 + `POST /api/exam/answers/query` (§14.4)
|
||||
|
||||
**展示内容**:
|
||||
- 总分 / 得分 / 得分率
|
||||
- 每道题的对错状态、得分、反馈
|
||||
- 用时统计
|
||||
- 操作按钮: 「查看解析」「重做错题」
|
||||
|
||||
### 4.4 错题本 (wrong)
|
||||
|
||||
**功能**: 展示用户的错题记录,支持查看解析和重做。
|
||||
|
||||
**数据来源**: `POST /api/wrong-questions/list` (§18.1)
|
||||
|
||||
**操作**:
|
||||
- 查看解析: 显示标准答案 + AI反馈 + 选项高亮
|
||||
- 重做: 调用 `POST /api/wrong-questions/redo` (§18.3) → 进入答题模式
|
||||
- 收藏: 调用 `POST /api/wrong-questions/collection/toggle` (§18.2)
|
||||
|
||||
**与互动训练错题本的关系**:
|
||||
- 互动训练的错题本是前端本地缓存(闯关/每日一练的错题)
|
||||
- 试卷考核的错题本调用后端API,是持久化的真实错题数据
|
||||
- 两者独立存在,互不影响
|
||||
|
||||
### 4.5 自测组卷 (selftest)
|
||||
|
||||
**功能**: 用户自行配置参数生成测试卷,生成后直接进入答题。
|
||||
|
||||
**API**: `POST /api/exam/paper/generate` (§14.1) — 用户版
|
||||
|
||||
**与智能组卷的区别**:
|
||||
|
||||
| 维度 | 智能组卷(管理员) | 自测组卷(用户) |
|
||||
|-----|----------------|--------------|
|
||||
| API | 同一个接口 | 同一个接口 |
|
||||
| 生成后行为 | 进入编辑预览 | **直接进入答题** |
|
||||
| 可编辑题目 | ✅ 编辑题干/选项/答案 | ❌ |
|
||||
| 可发布给他人 | ✅ 发布按钮 | ❌ 仅自己使用 |
|
||||
| 保存到试卷列表 | ✅ 存入后端 | ❌ 前端暂存 |
|
||||
| 权限要求 | `exam:paper` | `exam:exam:selftest` |
|
||||
|
||||
**交互流程**:
|
||||
1. 配置参数(题型数量、难度)→ 可选关联制度文件
|
||||
2. 点击「开始测试」→ 调用 generatePaper API
|
||||
3. 生成成功 → 自动切换到答题页面(taking)
|
||||
4. 答题 → 提交批改 → 显示成绩
|
||||
|
||||
## 五、智能组卷改造(管理员增强)
|
||||
|
||||
### 5.1 新增能力
|
||||
|
||||
在现有的智能组卷功能上增加:
|
||||
|
||||
1. **编辑题目**: 在预览弹窗中点击题目旁的「编辑」按钮
|
||||
- 可修改: 题干(stem)、选项(options)、答案(answer)、分值(score)
|
||||
- 使用已有的 `showEditQuestionModal` 逻辑
|
||||
|
||||
2. **发布试卷**: 在预览弹窗底部增加「发布试卷」按钮
|
||||
- 调用后端发布接口(如需要新增接口则后续补充)
|
||||
- 发布成功后有 `exam:exam` 权限的用户可在「试卷考核-待考试」中看到
|
||||
|
||||
3. **试卷列表**: 增加本地已生成试卷的管理面板
|
||||
- 显示所有已生成的试卷(草稿/已发布)
|
||||
- 支持预览、编辑草稿、发布、删除
|
||||
|
||||
### 5.2 权限隔离
|
||||
|
||||
```javascript
|
||||
// 只有拥有 exam:paper 权限的用户才能看到编辑和发布按钮
|
||||
const canEditAndPublish = computed(() => hasChildPermission('exam:paper'))
|
||||
```
|
||||
|
||||
## 六、API 接口清单
|
||||
|
||||
| 功能 | 方法 | 路径 | 文档章节 |
|
||||
|-----|------|------|---------|
|
||||
| 生成试卷(共用) | POST | `/api/exam/paper/generate` | §14.1 |
|
||||
| 批改答案 | POST | `/api/exam/grade` | §14.3 |
|
||||
| 查询答题记录 | POST | `/api/exam/answers/query` | §14.4 |
|
||||
| 我的试卷列表 | GET | `/api/exam/my/papers` | §15.1 |
|
||||
| 试卷详情 | GET | `/api/exam/paper/{paperId}` | §15.2 |
|
||||
| 错题列表 | POST | `/api/wrong-questions/list` | §18.1 |
|
||||
| 收藏错题 | POST | `/api/wrong-questions/collection/toggle` | §18.2 |
|
||||
| 重做错题 | POST | `/api/wrong-questions/redo` | §18.3 |
|
||||
|
||||
## 七、文件变更清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|-----|---------|------|
|
||||
| `src/utils/permission.js` | 修改 | 新增 `exam:exam` 权限映射 |
|
||||
| `src/api/exam.js` | 已完成 | 所有API方法已在之前添加 |
|
||||
| `src/components/ExamModule.vue` | 大幅修改 | 新增试卷考核标签页及全部子功能 |
|
||||
| 数据库 permission 表 | 新增记录 | INSERT exam:exam 权限 |
|
||||
943
docs/superpowers/specs/2026-05-14-sidebar-navigation-design.md
Normal file
943
docs/superpowers/specs/2026-05-14-sidebar-navigation-design.md
Normal file
@@ -0,0 +1,943 @@
|
||||
# 侧边栏导航系统设计方案
|
||||
|
||||
**项目名称**: 制度文件管理学习AI智能体 - 前端优化
|
||||
**设计日期**: 2026-05-14
|
||||
**版本**: v1.0
|
||||
**状态**: 已批准
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计背景与目标
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
现有系统采用**顶部水平导航栏**布局,存在以下问题:
|
||||
- **空间利用率低**: 7 个功能模块占用顶部空间,在宽屏显示器上浪费水平空间
|
||||
- **可扩展性差**: 新增功能模块会导致顶部导航拥挤
|
||||
- **视觉层级不清晰**: 系统标题、导航、用户信息混在同一行,缺乏层次感
|
||||
- **移动端体验差**: 水平导航在小屏幕上需要滚动或换行
|
||||
|
||||
### 1.2 设计目标
|
||||
|
||||
将现有的顶部 header 导航重构为**左侧固定侧边栏**系统,实现:
|
||||
|
||||
1. **提升空间利用率**: 垂直导航释放顶部和水平空间
|
||||
2. **增强可扩展性**: 支持更多功能模块而不影响布局
|
||||
3. **改善视觉层级**: 清晰分离品牌区、导航区、用户区
|
||||
4. **优化响应式体验**: 桌面/平板/移动端均有最佳表现
|
||||
5. **保持一致性**: 遵循已有的白色体系设计规范
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计决策记录
|
||||
|
||||
### 2.1 关键选择
|
||||
|
||||
| 决策项 | 选择 | 理由 |
|
||||
|-------|------|------|
|
||||
| **侧边栏位置** | 左侧 | 符合主流商业系统习惯(VS Code、Notion、Slack) |
|
||||
| **标题与用户信息位置** | 侧边栏内 | 整体感强,减少页面元素碎片化 |
|
||||
| **默认宽度规格** | 展开时 200px / 收起时 56px | 紧凑型设计,最大化主内容区空间 |
|
||||
| **实现方案** | 方案 A:经典固定侧边栏 | 实现简单、性能优、符合企业级应用标准 |
|
||||
|
||||
### 2.2 未采用的替代方案
|
||||
|
||||
- **方案 B(可拖拽调整宽度)**: 实现复杂度高,当前需求不需要此功能
|
||||
- **方案 C(全响应式混合导航)**: 过度工程化,增加维护成本
|
||||
- **右侧边栏**: 不符合用户阅读习惯(从左到右)
|
||||
|
||||
---
|
||||
|
||||
## 3. 整体布局架构
|
||||
|
||||
### 3.1 布局结构图
|
||||
|
||||
```
|
||||
桌面端(≥768px):
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┬──────────────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ 侧边栏 │ 主内容区域 │ │
|
||||
│ │ (200px) │ (剩余所有空间) │ │
|
||||
│ │ │ │ │
|
||||
│ │ [品牌区] │ - ReadModule │ │
|
||||
│ │ [导航项] │ - ManageModule │ │
|
||||
│ │ [用户区] │ - QAModule │ │
|
||||
│ │ │ - ExamModule │ │
|
||||
│ └──────────┴──────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
收起状态(56px):
|
||||
┌────────┬───────────────────────────────────────────┐
|
||||
│ │ │
|
||||
│ 56px │ 主内容区域 │
|
||||
│ │ (宽度 = 视口宽度 - 56px) │
|
||||
│ 图标 │ │
|
||||
│ 仅显示 │ │
|
||||
└────────┴───────────────────────────────────────────┘
|
||||
|
||||
移动端(<768px):
|
||||
┌──────────────────────────┐
|
||||
│ ☰ 制度文件管理学习AI智能体 │ ← 顶部栏(固定,48px高)
|
||||
├──────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ 主内容区域 │ ← 全屏显示
|
||||
│ │
|
||||
│ │
|
||||
└──────────────────────────┘
|
||||
|
||||
点击 ☰ 后(Overlay模式):
|
||||
┌────┬─────────────────────┐
|
||||
│ │ ✕ │
|
||||
│ 侧 │ │
|
||||
│ 边 │ 主内容区(暗化) │
|
||||
│ 栏 │ │
|
||||
│ │ │
|
||||
└────┴─────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 技术实现要点
|
||||
|
||||
- **布局方式**: Flexbox(外层容器 `display: flex`)
|
||||
- **侧边栏定位**: 固定定位或正常流(`flex-shrink: 0`)
|
||||
- **主内容区**: `flex: 1; overflow: auto`
|
||||
- **高度控制**: 侧边栏 `height: 100vh`
|
||||
|
||||
---
|
||||
|
||||
## 4. 侧边栏内部组件设计
|
||||
|
||||
### 4.1 组件架构
|
||||
|
||||
```
|
||||
AppSidebar.vue (主容器)
|
||||
├── SidebarHeader.vue (品牌区)
|
||||
│ ├── Logo/图标
|
||||
│ ├── 系统名称
|
||||
│ └── 折叠按钮
|
||||
├── SidebarNav.vue (导航菜单)
|
||||
│ └── SidebarNavItem.vue × N (单个导航项)
|
||||
│ ├── 图标
|
||||
│ ├── 文字标签
|
||||
│ └── Tooltip (收起状态)
|
||||
├── <div class="sidebar-spacer"> (弹性空间)
|
||||
└── SidebarUser.vue (用户信息区)
|
||||
├── 用户头像
|
||||
├── 用户名 + 角色
|
||||
└── 操作按钮 (设置/退出)
|
||||
```
|
||||
|
||||
### 4.2 品牌区设计 (SidebarHeader)
|
||||
|
||||
#### 展开状态 (200px):
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ 🤖 制度文件管理 │
|
||||
│ 学习AI智能体 │
|
||||
│ [←] │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
#### 收起状态 (56px):
|
||||
```
|
||||
┌────┐
|
||||
│ 🤖 │
|
||||
│ [→] │
|
||||
└────┘
|
||||
```
|
||||
|
||||
**组件接口:**
|
||||
```vue
|
||||
<SidebarHeader
|
||||
:collapsed="Boolean"
|
||||
@toggle="Function"
|
||||
/>
|
||||
```
|
||||
|
||||
**样式规范:**
|
||||
```css
|
||||
.sidebar-header {
|
||||
height: var(--sidebar-header-height, 64px);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--border-sidebar);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
align-self: flex-end;
|
||||
margin-top: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 导航菜单设计 (SidebarNav)
|
||||
|
||||
#### 导航项配置数据结构:
|
||||
|
||||
```javascript
|
||||
const navItems = [
|
||||
{ key: 'read', label: '文件查看', icon: '📄', permission: 'read' },
|
||||
{ key: 'manage', label: '文件管理', icon: '📁', permission: 'manage' },
|
||||
{ key: 'qa', label: '知识问答', icon: '💬', permission: 'qa' },
|
||||
{ key: 'exam', label: '考察训练', icon: '🔍', permission: 'exam' },
|
||||
{ key: 'mind', label: '纲要学习', icon: '📋', permission: 'mind' },
|
||||
{ key: 'dashboard', label: '综合看板', icon: '📊', permission: 'dashboard' },
|
||||
{ key: 'permission', label: '权限管理', icon: '👥', permission: 'permission' }
|
||||
]
|
||||
```
|
||||
|
||||
#### 视觉状态对比:
|
||||
|
||||
| 状态 | 展开时 (200px) | 收起时 (56px) |
|
||||
|-----|---------------|--------------|
|
||||
| **默认** | `📄 文件查看` (灰色文字) | `📄` (灰色图标) |
|
||||
| **悬停** | 浅灰背景 + 深色文字 | 浅灰背景 + Tooltip 显示 "文件查看" |
|
||||
| **激活** | 蓝色浅背景 + 蓝色粗体文字 + 左侧蓝色指示条 | 蓝色图标 + 左侧蓝色指示条 |
|
||||
|
||||
**组件接口:**
|
||||
```vue
|
||||
<SidebarNav
|
||||
:items="Array"
|
||||
:active-key="String"
|
||||
:collapsed="Boolean"
|
||||
@select="Function(key)"
|
||||
/>
|
||||
```
|
||||
|
||||
**样式规范:**
|
||||
```css
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: var(--sidebar-nav-item-height, 40px);
|
||||
padding: 0 16px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-sidebar-hover, #f5f7fa);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--bg-sidebar-active, #e6f4ff);
|
||||
color: var(--color-primary, #3b82f6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
```
|
||||
|
||||
**Tooltip 实现(收起状态):**
|
||||
```css
|
||||
.nav-tooltip {
|
||||
position: absolute;
|
||||
left: calc(100% + 12px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
padding: 6px 12px;
|
||||
background: rgba(26, 26, 46, 0.92);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: all 200ms ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.nav-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-right-color: rgba(26, 26, 46, 0.92);
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 用户信息区设计 (SidebarUser)
|
||||
|
||||
#### 展开状态:
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ ┌─┐ │
|
||||
│ │张│ 系统管理员 │
|
||||
│ └─┘ 角色:管理员 │
|
||||
│ ⚙️ 设置 🚪退出 │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
#### 收起状态:
|
||||
```
|
||||
┌────┐
|
||||
│ 👤 │ ← hover: tooltip "系统管理员"
|
||||
│ ⚙️ │
|
||||
│ 🚪 │
|
||||
└────┘
|
||||
```
|
||||
|
||||
**组件接口:**
|
||||
```vue
|
||||
<SidebarUser
|
||||
:collapsed="Boolean"
|
||||
:user-info="Object"
|
||||
:role-label="String"
|
||||
@logout="Function"
|
||||
@settings="Function"
|
||||
/>
|
||||
```
|
||||
|
||||
**样式规范:**
|
||||
```css
|
||||
.sidebar-user {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-sidebar);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 交互细节与动画效果
|
||||
|
||||
### 5.1 展开/收起切换机制
|
||||
|
||||
#### 触发方式:
|
||||
1. **主要方式**: 点击侧边栏顶部的折叠按钮(箭头图标)
|
||||
2. **辅助方式**(可选): 键盘快捷键 `Ctrl + B`
|
||||
|
||||
#### 状态管理逻辑:
|
||||
|
||||
```javascript
|
||||
// composables/useSidebar.js
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useSidebar() {
|
||||
const collapsed = ref(false)
|
||||
const isMobile = ref(false)
|
||||
const mobileOpen = ref(false)
|
||||
|
||||
// 从 localStorage 恢复状态
|
||||
onMounted(() => {
|
||||
const savedState = localStorage.getItem('sidebar-collapsed')
|
||||
if (savedState !== null) {
|
||||
collapsed.value = savedState === 'true'
|
||||
}
|
||||
|
||||
// 初始化移动端检测
|
||||
checkMobile()
|
||||
window.addEventListener('resize', debounce(checkMobile, 150))
|
||||
})
|
||||
|
||||
// 监听变化并持久化
|
||||
watch(collapsed, (val) => {
|
||||
localStorage.setItem('sidebar-collapsed', String(val))
|
||||
}, { immediate: false })
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
if (isMobile.value && mobileOpen.value) {
|
||||
mobileOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCollapse = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
const openMobile = () => {
|
||||
mobileOpen.value = true
|
||||
}
|
||||
|
||||
const closeMobile = () => {
|
||||
mobileOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
collapsed,
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
toggleCollapse,
|
||||
openMobile,
|
||||
closeMobile
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 动画时间线:
|
||||
|
||||
```
|
||||
时间轴(展开 → 收起):
|
||||
0ms ──── 点击折叠按钮 ────→
|
||||
↓
|
||||
0-280ms CSS transition: width 200px → 56px
|
||||
↓
|
||||
100ms Vue transition: 文字 opacity 1 → 0 (提前消失)
|
||||
↓
|
||||
280ms 动画完成,进入完全收起状态
|
||||
|
||||
时间轴(收起 → 展开):
|
||||
0ms ──── 点击展开按钮 ────→
|
||||
↓
|
||||
0-280ms CSS transition: width 56px → 200px
|
||||
↓
|
||||
180ms Vue transition: 文字 opacity 0 → 1 (延迟出现)
|
||||
↓
|
||||
280ms 动画完成,进入完全展开状态
|
||||
```
|
||||
|
||||
#### CSS 过渡定义:
|
||||
|
||||
```css
|
||||
.sidebar {
|
||||
width: var(--sidebar-width, 200px);
|
||||
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-easing);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed-width, 56px);
|
||||
}
|
||||
|
||||
/* 文字淡入淡出 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 主内容区适配 */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width, 200px);
|
||||
transition: margin-left var(--sidebar-transition-duration) var(--sidebar-transition-easing);
|
||||
will-change: margin-left;
|
||||
}
|
||||
|
||||
.main-content.sidebar-collapsed {
|
||||
margin-left: var(--sidebar-collapsed-width, 56px);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 移动端适配策略
|
||||
|
||||
#### 断点定义:
|
||||
|
||||
| 设备类型 | 屏幕宽度 | 行为 |
|
||||
|---------|---------|------|
|
||||
| **桌面端** | ≥ 1024px | 完整侧边栏(200px),支持手动折叠 |
|
||||
| **平板端** | 768px - 1023px | 默认收起(56px),悬停自动展开 |
|
||||
| **移动端** | < 768px | 隐藏侧边栏,汉堡菜单按钮呼出(overlay) |
|
||||
|
||||
#### 移动端交互流程:
|
||||
|
||||
1. **初始状态**: 侧边栏隐藏,左上角显示 ☰ 按钮
|
||||
2. **打开侧边栏**: 点击 ☰ → 侧边栏从左侧滑入 + 半透明遮罩层
|
||||
3. **使用导航**: 点击导航项 → 切换模块 + 自动关闭侧边栏
|
||||
4. **关闭侧边栏**:
|
||||
- 点击遮罩层
|
||||
- 点击 ✕ 关闭按钮
|
||||
- 在侧边栏内向右滑动(可选手势)
|
||||
|
||||
#### 移动端样式实现:
|
||||
|
||||
```css
|
||||
@media (max-width: 767px) {
|
||||
.mobile-menu-btn {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1002;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 1001;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.mobile-overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能优化策略
|
||||
|
||||
### 6.1 渲染性能优化
|
||||
|
||||
| 优化技术 | 应用场景 | 预期收益 |
|
||||
|---------|---------|---------|
|
||||
| **GPU 加速动画** | 侧边栏宽度过渡、主内容区 margin 过渡 | 避免 layout thrashing |
|
||||
| **will-change 提示** | `.sidebar`, `.main-content` 元素 | 提前创建合成层 |
|
||||
| **事件防抖** | resize 事件监听器 | 减少不必要的重计算 |
|
||||
| **被动监听** | touch 事件、scroll 事件 | 提升滚动流畅度 |
|
||||
| **按需加载** | MobileMenuButton 组件 | 减少首屏加载体积 |
|
||||
|
||||
### 6.2 内存优化
|
||||
|
||||
- **避免内存泄漏**: 在 `onUnmounted` 中移除事件监听器
|
||||
- **合理使用 ref/computed**: 避免不必要的响应式依赖
|
||||
- **虚拟列表**(未来扩展): 如果导航项超过 20 个,使用虚拟滚动
|
||||
|
||||
### 6.3 可访问性 (Accessibility)
|
||||
|
||||
- **键盘导航**: 支持 Tab 键切换导航项,Enter 键激活
|
||||
- **ARIA 标签**: 为侧边栏添加 `role="navigation"` 和 `aria-label`
|
||||
- **焦点管理**: 打开/关闭侧边栏时正确管理焦点陷阱
|
||||
- **屏幕阅读器**: 为图标添加 `aria-label` 或隐藏的文字标签
|
||||
- **颜色对比度**: 确保所有文本满足 WCAG 2.1 AA 标准(4.5:1)
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件结构与组件清单
|
||||
|
||||
### 7.1 新增文件
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ └── layout/
|
||||
│ ├── AppSidebar.vue # 侧边栏主组件(~200行)
|
||||
│ ├── SidebarHeader.vue # 品牌区组件(~80行)
|
||||
│ ├── SidebarNav.vue # 导航菜单容器(~60行)
|
||||
│ ├── SidebarNavItem.vue # 单个导航项(~100行)
|
||||
│ ├── SidebarUser.vue # 用户信息区(~120行)
|
||||
│ └── MobileMenuButton.vue # 移动端汉堡按钮(~40行)
|
||||
│
|
||||
├── composables/
|
||||
│ └── useSidebar.js # 侧边栏状态管理(~80行)
|
||||
│
|
||||
└── utils/
|
||||
└── debounce.js # 防抖工具函数(~15行)
|
||||
```
|
||||
|
||||
### 7.2 修改文件
|
||||
|
||||
```
|
||||
src/
|
||||
├── views/
|
||||
│ └── Home.vue # 重构:移除 header,引入侧边栏布局
|
||||
│
|
||||
├── design-tokens.css # 扩展:添加侧边栏相关 CSS 变量
|
||||
│
|
||||
└── style.css # 更新:可能需要微调全局样式
|
||||
```
|
||||
|
||||
### 7.3 组件职责划分
|
||||
|
||||
| 组件 | 职责 | Props | Events |
|
||||
|-----|------|-------|--------|
|
||||
| **AppSidebar** | 侧边栏主容器,协调子组件 | `collapsed`, `navItems`, `activeKey`, `userInfo`, `isMobile`, `mobileOpen` | `update:collapsed`, `navigate`, `logout`, `settings` |
|
||||
| **SidebarHeader** | 显示品牌标识和折叠按钮 | `collapsed` | `toggle` |
|
||||
| **SidebarNav** | 渲染导航项列表 | `items[]`, `activeKey`, `collapsed` | `select(key)` |
|
||||
| **SidebarNavItem** | 单个导航项的展示和交互 | `item{key,label,icon}`, `active`, `collapsed` | `click` |
|
||||
| **SidebarUser** | 显示用户信息和操作按钮 | `collapsed`, `userInfo{}`, `roleLabel` | `logout`, `settings` |
|
||||
| **MobileMenuButton** | 移动端的菜单触发按钮 | 无 | `click` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 样式变量扩展
|
||||
|
||||
在现有的 `src/design-tokens.css` 中添加以下变量:
|
||||
|
||||
```css
|
||||
/* ========== 侧边栏系统 ========== */
|
||||
:root {
|
||||
/* 尺寸规范 */
|
||||
--sidebar-width: 200px;
|
||||
--sidebar-collapsed-width: 56px;
|
||||
--sidebar-nav-item-height: 40px;
|
||||
--sidebar-header-height: 64px;
|
||||
--sidebar-user-height: auto;
|
||||
|
||||
/* 颜色规范 */
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-sidebar-hover: #f5f7fa;
|
||||
--bg-sidebar-active: #e6f4ff;
|
||||
--border-sidebar: #e5e7eb;
|
||||
|
||||
/* 动画规范 */
|
||||
--sidebar-transition-duration: 280ms;
|
||||
--sidebar-transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* 响应式断点 */
|
||||
--breakpoint-mobile: 768px;
|
||||
--breakpoint-tablet: 1024px;
|
||||
|
||||
/* 圆角规范 */
|
||||
--radius-nav-item: 6px;
|
||||
--radius-tooltip: 6px;
|
||||
|
||||
/* 阴影规范 */
|
||||
--shadow-sidebar: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-mobile-overlay: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
- **useSidebar composable**:
|
||||
- 测试初始状态从 localStorage 正确恢复
|
||||
- 测试 `toggleCollapse` 函数切换状态
|
||||
- 测试移动端检测逻辑
|
||||
- 测试状态变更后正确保存到 localStorage
|
||||
|
||||
- **各组件渲染测试**:
|
||||
- 测试展开/收起状态下正确的 DOM 结构
|
||||
- 测试 props 正确传递给子组件
|
||||
- 测试 events 正确触发
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- **Home.vue 集成**:
|
||||
- 测试侧边栏与主内容区的协同工作
|
||||
- 测试导航切换功能是否正常
|
||||
- 测试用户登出流程
|
||||
|
||||
### 9.3 视觉回归测试
|
||||
|
||||
- 使用 Playwright 或 Cypress 进行截图对比
|
||||
- 测试不同断点下的布局表现
|
||||
- 验证动画流畅性
|
||||
|
||||
### 9.4 手动测试清单
|
||||
|
||||
- [ ] 桌面端:侧边栏正常展开/收起
|
||||
- [ ] 桌面端:点击导航项正确切换模块
|
||||
- [ ] 桌面端:刷新页面后保持上次的状态
|
||||
- [ ] 平板端:默认收起,悬停显示 tooltip
|
||||
- [ ] 移动端:汉堡菜单按钮可见且可点击
|
||||
- [ ] 移动端:侧边栏以 overlay 模式滑出
|
||||
- [ ] 移动端:点击遮罩层可关闭侧边栏
|
||||
- [ ] 键盘导航:Tab 键可在导航项间切换
|
||||
- [ ] 无障碍:屏幕阅读器可正确朗读导航项
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移计划与风险控制
|
||||
|
||||
### 10.1 分阶段实施
|
||||
|
||||
**Phase 1: 基础设施搭建(预计 2 小时)**
|
||||
- 创建组件目录结构
|
||||
- 实现 `useSidebar` composable
|
||||
- 扩展 `design-tokens.css`
|
||||
|
||||
**Phase 2: 组件开发(预计 3 小时)**
|
||||
- 实现 AppSidebar 及其子组件
|
||||
- 实现展开/收起动画
|
||||
- 实现 Tooltip 功能
|
||||
|
||||
**Phase 3: 集成与适配(预计 2 小时)**
|
||||
- 修改 Home.vue,移除旧 header
|
||||
- 集成新的侧边栏布局
|
||||
- 实现移动端响应式
|
||||
|
||||
**Phase 4: 测试与优化(预计 1 小时)**
|
||||
- 手动测试所有场景
|
||||
- 性能优化
|
||||
- 修复边界情况 bug
|
||||
|
||||
**总预计工时**: ~8 小时
|
||||
|
||||
### 10.2 风险识别与缓解
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|-----|-------|------|---------|
|
||||
| 与现有样式冲突 | 中 | 高 | 使用 scoped styles + BEM 命名 |
|
||||
| 移动端兼容性问题 | 低 | 中 | 充分测试主流设备 |
|
||||
| 性能问题(大量 DOM 操作) | 低 | 中 | 使用 Vue 的 transition 组件 |
|
||||
| 用户习惯改变导致困惑 | 中 | 低 | 提供引导提示(首次使用) |
|
||||
| localStorage 不可用 | 极低 | 低 | try-catch 包裹,降级处理 |
|
||||
|
||||
### 10.3 回滚方案
|
||||
|
||||
如果新版本出现严重问题,可以通过 Git 快速回滚到上一个稳定版本:
|
||||
|
||||
```bash
|
||||
git revert <commit-hash>
|
||||
# 或
|
||||
git reset --hard <previous-stable-commit>
|
||||
```
|
||||
|
||||
建议在合并前打 tag 以便快速回滚:
|
||||
|
||||
```bash
|
||||
git tag -a v1.0-before-sidebar-refactor -m "Pre sidebar refactor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 成功标准
|
||||
|
||||
### 11.1 功能完整性
|
||||
|
||||
- [x] 所有 7 个功能模块均可通过侧边栏访问
|
||||
- [x] 展开/收起功能正常工作
|
||||
- [x] 状态记忆功能正常(localStorage)
|
||||
- [x] Tooltip 在收起状态下正确显示
|
||||
- [x] 移动端 overlay 模式正常工作
|
||||
|
||||
### 11.2 性能指标
|
||||
|
||||
- [x] 展开/收起动画帧率 ≥ 60fps
|
||||
- [x] 首屏加载时间增加 < 200ms
|
||||
- [x] 内存占用增加 < 5MB
|
||||
- [x] 无明显的布局抖动(CLS < 0.1)
|
||||
|
||||
### 11.3 用户体验指标
|
||||
|
||||
- [x] 用户可在 3 秒内理解如何使用侧边栏
|
||||
- [x] 导航切换操作步骤 ≤ 2 步
|
||||
- [x] 视觉风格与整体白色体系一致
|
||||
- [x] 不同屏幕尺寸下均表现良好
|
||||
|
||||
---
|
||||
|
||||
## 12. 未来迭代方向(超出本次范围)
|
||||
|
||||
### 12.1 短期增强(可选)
|
||||
|
||||
1. **拖拽调整宽度**(方案 B 特性)
|
||||
2. **多级嵌套菜单**(如果功能模块有子分类)
|
||||
3. **搜索框集成**(在侧边栏顶部添加全局搜索)
|
||||
4. **通知徽章**(在导航项右上角显示未读数量)
|
||||
|
||||
### 12.2 中期优化
|
||||
|
||||
1. **主题定制**(深色模式、自定义配色)
|
||||
2. **键盘快捷键完整支持**
|
||||
3. **手势导航增强**(边缘滑动、长按预览)
|
||||
4. **国际化(i18n)支持**
|
||||
|
||||
### 12.3 长期规划
|
||||
|
||||
1. **插件系统**(允许第三方扩展侧边栏功能)
|
||||
2. **AI 辅助导航**(根据使用频率智能排序)
|
||||
3. **跨应用同步**(多标签页间同步侧边栏状态)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 参考资料
|
||||
|
||||
- [Ant Design Pro 侧边栏布局](https://pro.ant.design/layout/)
|
||||
- [Vue 3 Composition API 文档](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
- [WCAG 2.1 可访问性指南](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [Material Design Navigation Drawer](https://material.io/components/navigation-drawer)
|
||||
|
||||
---
|
||||
|
||||
## 附录 B: 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|-----|------|
|
||||
| **Sidebar** | 侧边栏,垂直排列的导航面板 |
|
||||
| **Collapsed** | 收起状态,仅显示图标 |
|
||||
| **Expanded** | 展开状态,显示图标+文字 |
|
||||
| **Overlay** | 遮罩层,半透明背景覆盖主内容区 |
|
||||
| **Tooltip** | 工具提示,鼠标悬停时显示的文字说明 |
|
||||
| **Responsive** | 响应式,适应不同屏幕尺寸 |
|
||||
| **localStorage** | 浏览器本地存储,用于持久化用户偏好 |
|
||||
|
||||
---
|
||||
|
||||
**文档维护者**: AI Assistant
|
||||
**最后更新**: 2026-05-14
|
||||
**下次评审日期**: 实施完成后
|
||||
745
docs/superpowers/specs/2026-05-16-question-management-design.md
Normal file
745
docs/superpowers/specs/2026-05-16-question-management-design.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# 题目管理系统设计方案
|
||||
|
||||
**日期**: 2026-05-16
|
||||
**基于**: 前端优化方案与设计规范.md
|
||||
**模式**: 集成式设计(生成→待审核列表→操作)
|
||||
|
||||
---
|
||||
|
||||
## 一、系统架构
|
||||
|
||||
### 1.1 数据流图
|
||||
|
||||
```
|
||||
[用户操作]
|
||||
↓
|
||||
[GeneratePanel.vue]
|
||||
├─ 配置区域 (文件选择/题型/难度)
|
||||
├─ 生成按钮 → POST /api/exam/generate
|
||||
↓
|
||||
[待审核列表区域]
|
||||
├─ 初始化: GET /api/question/pending (自动加载)
|
||||
├─ 搜索/筛选
|
||||
├─ 题目卡片展示
|
||||
│ ├─ 审批: PUT /api/question/review/{id}
|
||||
│ ├─ 编辑: PUT /api/question/{id}
|
||||
│ └─ 发布: PUT /api/question/review/{id} (status=published)
|
||||
└─ 批量操作
|
||||
```
|
||||
|
||||
### 1.2 状态管理
|
||||
|
||||
```javascript
|
||||
// useQuestionManagement.js (新增composable)
|
||||
const state = reactive({
|
||||
// 生成相关
|
||||
isGenerating: false,
|
||||
generatedCount: 0,
|
||||
|
||||
// 列表相关
|
||||
pendingQuestions: [], // 待审核题目列表
|
||||
loading: false,
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
|
||||
// 筛选条件
|
||||
searchKeyword: '',
|
||||
statusFilter: 'all', // all/pending/approved/rejected/published
|
||||
typeFilter: 'all', // all/single_choice/multiple_choice/...
|
||||
|
||||
// 编辑状态
|
||||
editingQuestion: null, // 当前编辑的题目
|
||||
showEditModal: false,
|
||||
|
||||
// 批量操作
|
||||
selectedIds: [], // 选中的题目ID
|
||||
})
|
||||
```
|
||||
|
||||
### 1.3 接口调用规划
|
||||
|
||||
| 功能 | 方法 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| **生成题目** | `POST` | `/api/exam/generate` | 点击"开始生成"按钮 |
|
||||
| **获取待审核列表** | `GET` | `/api/question/pending` | 页面加载/生成完成/刷新 |
|
||||
| **搜索题目** | `GET` | `/api/question/pending?keyword=xxx` | 输入搜索关键词 |
|
||||
| **筛选题目** | `GET` | `/api/question/pending?status=xxx&type=xxx` | 选择筛选条件 |
|
||||
| **审批通过** | `PUT` | `/api/question/review/{id}?status=approved` | 点击"通过"按钮 |
|
||||
| **驳回题目** | `PUT` | `/api/question/review/{id}?status=rejected&comment=xxx` | 点击"驳回"+填写原因 |
|
||||
| **编辑题目** | `PUT` | `/api/question/{id}` | 点击"编辑"→修改→保存 |
|
||||
| **发布题目** | `PUT` | `/api/question/review/{id}?status=published` | 点击"发布"按钮 |
|
||||
| **批量审批** | `PUT` | `/api/question/batch-review` | 选择多条→批量操作 |
|
||||
| **删除题目** | `DELETE` | `/api/question/{id}` | 点击"删除" |
|
||||
|
||||
---
|
||||
|
||||
## 二、UI组件设计(遵循设计规范)
|
||||
|
||||
### 2.1 组件结构树
|
||||
|
||||
```
|
||||
GeneratePanel.vue (主容器)
|
||||
├── PageHeader.vue (标题栏)
|
||||
├── StatsRow.vue (统计信息)
|
||||
├── ContentCard: "智能出题配置"
|
||||
│ ├── FileSelector (文件选择器)
|
||||
│ ├── DifficultySelect (难度选择)
|
||||
│ ├── TypeConfigGrid (题型数量配置)
|
||||
│ └── GenerateButton (生成按钮)
|
||||
├── ContentCard: "待审核题目管理"
|
||||
│ ├── Toolbar (工具栏)
|
||||
│ │ ├── SearchInput (搜索框)
|
||||
│ │ ├── StatusFilter (状态筛选)
|
||||
│ │ ├── TypeFilter (题型筛选)
|
||||
│ │ └── BatchActions (批量操作按钮组)
|
||||
│ ├── QuestionList (题目列表)
|
||||
│ │ └── QuestionCard (题目卡片) *N
|
||||
│ │ ├── QuestionHeader (题号+类型+状态)
|
||||
│ │ ├── QuestionContent (题干内容)
|
||||
│ │ ├── QuestionOptions (选项展示)
|
||||
│ │ └── ActionButtons (操作按钮组)
|
||||
│ └── Pagination (分页器)
|
||||
└── EditModal.vue (编辑弹窗) [可选]
|
||||
```
|
||||
|
||||
### 2.2 题目卡片设计(QuestionCard)
|
||||
|
||||
#### 视觉规范
|
||||
|
||||
```css
|
||||
/* 白色体系 + 设计规范 */
|
||||
.question-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E9ECEF; /* --border-light */
|
||||
border-radius: 8px; /* --radius-lg */
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04); /* --shadow-sm */
|
||||
padding: 16px; /* --space-lg */
|
||||
margin-bottom: 12px; /* --space-md */
|
||||
transition: all 0.15s ease; /* --transition-fast */
|
||||
}
|
||||
|
||||
.question-card:hover {
|
||||
border-color: #DEE2E6; /* --border-default */
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* --shadow-md */
|
||||
}
|
||||
```
|
||||
|
||||
#### 卡片内容布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [☑] 第1题 [单选题] [待审核] 中等难度 │ ← Header
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ 在按价位段自选投放工作指引中,用于评估样本零售客户 │ ← Content
|
||||
│ 库存水平与销售速度匹配程度的核心公式是? │
|
||||
│ │
|
||||
│ A. 客户实际订货数量/订货客户订单提报需求数量*100% │ ← Options
|
||||
│ B. 样本零售客户期末库存/月销量 │
|
||||
│ C. 实际零售价格/零售指导价格*100% │
|
||||
│ D. 特定聚类内市场状态结果按销量加权平均 │
|
||||
│ │
|
||||
│ [✓ 通过] [✗ 驳回] [✎ 编辑] [📤 发布] [🗑️ 删除] │ ← Actions
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 状态徽章设计
|
||||
|
||||
```css
|
||||
/* 状态标识 - 仅使用功能色 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px; /* --radius-sm */
|
||||
font-size: 12px; /* --font-sm */
|
||||
font-weight: 500; /* --weight-medium */
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #FFF9DB; /* --bg-warning */
|
||||
color: #E67700; /* --color-warning */
|
||||
border: 1px solid #FFE066;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background: #EBFBEE; /* --bg-success */
|
||||
color: #2B8A3E; /* --color-success */
|
||||
border: 1px solid #B2F2BB;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background: #FFF5F5; /* --bg-error */
|
||||
color: #C92A2A; /* --color-error */
|
||||
border: 1px solid #FFC9C9;
|
||||
}
|
||||
|
||||
.status-published {
|
||||
background: #E7F5FF; /* --bg-info */
|
||||
color: #1C7ED6; /* --color-info */
|
||||
border: 1px solid #A5D8FF;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 操作按钮设计
|
||||
|
||||
```css
|
||||
/* 按钮组 - 遵循设计规范 5.3 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 6px; /* --space-xs + 2px */
|
||||
margin-top: 12px; /* --space-md */
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #E9ECEF; /* --border-light */
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px; /* 紧凑尺寸 */
|
||||
border-radius: 6px; /* --radius-md */
|
||||
font-size: 12px; /* --font-sm */
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: #EBFBEE; /* 浅绿底 */
|
||||
color: #2B8A3E; /* 绿色文字 */
|
||||
border-color: #B2F2BB;
|
||||
}
|
||||
.btn-approve:hover {
|
||||
background: #D3F9D8;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: #FFF5F5; /* 浅红底 */
|
||||
color: #C92A2A; /* 红色文字 */
|
||||
border-color: #FFC9C9;
|
||||
}
|
||||
.btn-reject:hover {
|
||||
background: #FFE3E3;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #FFFFFF;
|
||||
color: #495057; /* --text-secondary */
|
||||
border-color: #DEE2E6; /* --border-default */
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: #F8F9FA; /* --bg-container */
|
||||
border-color: #ADB5BD;
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
background: #E7F5FF; /* 浅蓝底 */
|
||||
color: #1C7ED6; /* 蓝色文字 */
|
||||
border-color: #A5D8FF;
|
||||
}
|
||||
.btn-publish:hover {
|
||||
background: #D0EBFF;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #FFFFFF;
|
||||
color: #C92A2A; /* 红色文字 */
|
||||
border-color: #FFC9C9;
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background: #FFF5F5;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 工具栏设计(Toolbar)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🔍 搜索题目... [状态: 全部 ▼] [题型: 全部 ▼] │
|
||||
│ [✓ 批量通过] [✗ 批量驳回] [刷新] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```css
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #F8F9FA; /* --bg-container */
|
||||
border: 1px solid #E9ECEF;
|
||||
border-radius: 8px; /* --radius-lg */
|
||||
margin-bottom: 16px; /* --space-lg */
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
height: 36px; /* 表单规范 */
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #DEE2E6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px; /* --font-md */
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
height: 36px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #DEE2E6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、交互流程设计
|
||||
|
||||
### 3.1 生成题目流程
|
||||
|
||||
```
|
||||
1. 用户配置参数(文件/题型/难度)
|
||||
↓
|
||||
2. 点击"开始生成"按钮
|
||||
↓
|
||||
3. 显示loading状态 + 进度提示
|
||||
↓
|
||||
4. 调用 POST /api/exam/generate
|
||||
↓
|
||||
5. 轮询检查生成状态(已有逻辑)
|
||||
↓
|
||||
6. 生成完成后:
|
||||
- 显示成功提示:"成功生成 X 道题目"
|
||||
- 自动调用 GET /api/question/pending 刷新列表
|
||||
- 滚动到待审核列表区域
|
||||
↓
|
||||
7. 展示待审核题目列表
|
||||
```
|
||||
|
||||
### 3.2 审批流程
|
||||
|
||||
```
|
||||
单个审批:
|
||||
1. 用户点击"通过"/"驳回"按钮
|
||||
↓
|
||||
2. 弹出确认对话框(驳回时需要填写原因)
|
||||
↓
|
||||
3. 调用 PUT /api/question/review/{id}?status=approved/rejected
|
||||
↓
|
||||
4. 更新本地状态(乐观更新)
|
||||
↓
|
||||
5. 刷新列表数据
|
||||
|
||||
批量审批:
|
||||
1. 用户勾选多个题目复选框
|
||||
↓
|
||||
2. 点击"批量通过"/"批量驳回"
|
||||
↓
|
||||
3. 弹出确认对话框
|
||||
↓
|
||||
4. 循环调用单个接口 或 调用批量接口
|
||||
↓
|
||||
5. 刷新列表
|
||||
```
|
||||
|
||||
### 3.3 编辑流程
|
||||
|
||||
```
|
||||
方式1:内联编辑(推荐简单字段)
|
||||
1. 点击"编辑"按钮
|
||||
↓
|
||||
2. 题目卡片变为编辑模式
|
||||
↓
|
||||
3. 直接修改题干/选项
|
||||
↓
|
||||
4. 点击"保存"/"取消"
|
||||
|
||||
方式2:弹窗编辑(推荐复杂编辑)
|
||||
1. 点击"编辑"按钮
|
||||
↓
|
||||
2. 打开 EditModal 弹窗
|
||||
↓
|
||||
3. 完整的表单编辑界面
|
||||
↓
|
||||
4. 点击"保存"/"取消"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、响应式与滚动策略
|
||||
|
||||
### 4.1 布局原则(解决之前的滚动问题)
|
||||
|
||||
```css
|
||||
/* 主容器 - 允许自然流动,不限制高度 */
|
||||
.generate-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* 允许收缩 */
|
||||
/* 移除 height: 100% 和 overflow: hidden */
|
||||
}
|
||||
|
||||
/* 配置面板 - 固定不滚动 */
|
||||
.config-panel {
|
||||
flex-shrink: 0; /* 不压缩 */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 结果面板 - 内容可撑开 */
|
||||
.result-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 不设置 max-height 或 overflow */
|
||||
}
|
||||
|
||||
/* 题目列表 - 自然流动 */
|
||||
.question-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
/* 不设置 overflow-y: auto */
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 配置区域固定在顶部
|
||||
- ✅ 题目列表随内容自然增长
|
||||
- ✅ 当内容超出视口时,整个页面可滚动
|
||||
- ✅ 无局部滚动条
|
||||
|
||||
### 4.2 分页策略
|
||||
|
||||
当题目数量较多时(>20道),使用分页:
|
||||
|
||||
```javascript
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10, // 每页10道
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
})
|
||||
|
||||
// 分页变化时重新请求
|
||||
const handlePageChange = async (page, size) => {
|
||||
pagination.current = page
|
||||
pagination.pageSize = size
|
||||
await fetchPendingQuestions()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、错误处理与边界情况
|
||||
|
||||
### 5.1 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 生成失败 | 显示错误消息,保留配置不变 |
|
||||
| 网络超时 | 提供重试按钮 |
|
||||
| 审批接口失败 | 回滚到之前状态,显示错误 |
|
||||
| 并发冲突 | 提示"数据已被其他人修改",刷新列表 |
|
||||
| 权限不足 | 显示"无权限操作",隐藏操作按钮 |
|
||||
|
||||
### 5.2 空状态处理
|
||||
|
||||
```html
|
||||
<!-- 无待审核题目 -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p class="empty-title">暂无需审核的题目</p>
|
||||
<p class="empty-hint">
|
||||
您可以:<br/>
|
||||
• 使用上方配置生成新题目<br/>
|
||||
• 所有题目已审核完毕 ✨
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.3 加载状态
|
||||
|
||||
```html
|
||||
<!-- 加载中 -->
|
||||
<div class="loading-state">
|
||||
<a-spin size="large" />
|
||||
<p>正在加载待审核题目...</p>
|
||||
</div>
|
||||
|
||||
<!-- 生成中 -->
|
||||
<div class="generating-state">
|
||||
<a-spin tip="AI正在生成题目,请耐心等待..." />
|
||||
<progress :percent="generateProgress" />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
### Phase 1: 核心功能(优先级:P0)
|
||||
|
||||
**目标**:实现基本的生成+展示+审批流程
|
||||
|
||||
1. **修改 useGenerateState.js**
|
||||
- ✅ 修复接口调用:生成后调用 `/question/pending`
|
||||
- ✅ 添加 `question_count` 参数
|
||||
- ✅ 保留现有的轮询逻辑
|
||||
|
||||
2. **创建 useQuestionManagement.js**
|
||||
- 实现 `fetchPendingQuestions()`
|
||||
- 实现 `approveQuestion(id)`
|
||||
- 实现 `rejectQuestion(id, comment)`
|
||||
- 实现搜索/筛选逻辑
|
||||
|
||||
3. **重构 GeneratePanel.vue**
|
||||
- 添加工具栏(搜索/筛选)
|
||||
- 重构题目列表为卡片式
|
||||
- 添加操作按钮组
|
||||
- 实现分页功能
|
||||
|
||||
### Phase 2: 编辑功能(优先级:P1)
|
||||
|
||||
4. **创建 EditModal.vue**
|
||||
- 题干编辑器
|
||||
- 选项编辑器(动态增删)
|
||||
- 答案设置
|
||||
- 表单验证
|
||||
|
||||
5. **集成编辑功能**
|
||||
- 调用 `PUT /question/{id}`
|
||||
- 保存后刷新列表
|
||||
|
||||
### Phase 3: 批量操作(优先级:P2)
|
||||
|
||||
6. **实现批量选择**
|
||||
- 复选框
|
||||
- 全选/反选
|
||||
- 批量审批接口
|
||||
|
||||
### Phase 4: 体验优化(优先级:P3)
|
||||
|
||||
7. **性能优化**
|
||||
- 虚拟滚动(大量题目时)
|
||||
- 防抖搜索
|
||||
- 缓存策略
|
||||
|
||||
8. **视觉优化**
|
||||
- 动画过渡
|
||||
- 拖拽排序
|
||||
- 快捷键支持
|
||||
|
||||
---
|
||||
|
||||
## 七、技术要点
|
||||
|
||||
### 7.1 关键代码示例
|
||||
|
||||
#### 获取待审核题目
|
||||
|
||||
```javascript
|
||||
// useQuestionManagement.js
|
||||
const fetchPendingQuestions = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
}
|
||||
|
||||
if (searchKeyword.value) {
|
||||
params.keyword = searchKeyword.value
|
||||
}
|
||||
|
||||
if (statusFilter.value !== 'all') {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
|
||||
if (typeFilter.value !== 'all') {
|
||||
params.type = typeFilter.value
|
||||
}
|
||||
|
||||
const res = await questionAPI.getPendingQuestions(params)
|
||||
|
||||
pendingQuestions.value = transformQuestionData(res.data?.records || [])
|
||||
pagination.total = res.data?.total || 0
|
||||
|
||||
console.log(`✅ 获取待审核题目: ${pendingQuestions.value.length} 条`)
|
||||
} catch (error) {
|
||||
console.error('❌ 获取待审核题目失败:', error)
|
||||
message.error('加载题目列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 审批操作
|
||||
|
||||
```javascript
|
||||
const approveQuestion = async (questionId) => {
|
||||
try {
|
||||
await questionAPI.reviewQuestion(questionId, 'approved')
|
||||
|
||||
message.success('审批通过 ✓')
|
||||
|
||||
// 乐观更新:立即更新UI
|
||||
const index = pendingQuestions.value.findIndex(q => q.id === questionId)
|
||||
if (index !== -1) {
|
||||
pendingQuestions.value[index].status = 'approved'
|
||||
}
|
||||
|
||||
// 可选:延迟刷新确保数据一致性
|
||||
setTimeout(() => fetchPendingQuestions(), 500)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 审批失败:', error)
|
||||
message.error('审批操作失败,请重试')
|
||||
|
||||
// 回滚:重新获取数据
|
||||
await fetchPendingQuestions()
|
||||
}
|
||||
}
|
||||
|
||||
const rejectQuestion = async (questionId, comment) => {
|
||||
try {
|
||||
await questionAPI.reviewQuestion(questionId, 'rejected', comment)
|
||||
|
||||
message.success('已驳回')
|
||||
|
||||
// 更新本地状态
|
||||
const index = pendingQuestions.value.findIndex(q => q.id === questionId)
|
||||
if (index !== -1) {
|
||||
pendingQuestions.value[index].status = 'rejected'
|
||||
}
|
||||
|
||||
setTimeout(() => fetchPendingQuestions(), 500)
|
||||
|
||||
} catch (error) {
|
||||
message.error('驳回操作失败')
|
||||
await fetchPendingQuestions()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 数据转换函数(复用)
|
||||
|
||||
```javascript
|
||||
// 与 useGenerateState.js 保持一致
|
||||
const transformQuestionData = (rawQuestions) => {
|
||||
return rawQuestions.map((q, qIdx) => {
|
||||
let contentObj = {}
|
||||
|
||||
try {
|
||||
if (typeof q.content === 'string' && q.content.startsWith('{')) {
|
||||
contentObj = JSON.parse(q.content || '{}')
|
||||
} else if (typeof q.content === 'object') {
|
||||
contentObj = q.content
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`题目${qIdx} content解析失败:`, e.message)
|
||||
}
|
||||
|
||||
return {
|
||||
id: q.id,
|
||||
questionId: q.questionId,
|
||||
type: String(q.questionType || q.type || '').toLowerCase(),
|
||||
typeLabel: formatQuestionType(q.questionType),
|
||||
difficulty: q.difficulty || 2,
|
||||
score: Number(q.score) || 5,
|
||||
stem: contentObj.stem || q.stem || (typeof q.content === 'string' ? q.content : ''),
|
||||
options: formatOptions(contentObj.options || q.options || []),
|
||||
answer: q.answer || contentObj.answer || '',
|
||||
status: q.status || 'pending',
|
||||
reviewerComment: q.reviewerComment || '',
|
||||
createdAt: q.createdAt,
|
||||
updatedAt: q.updatedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、测试场景
|
||||
|
||||
### 8.1 功能测试
|
||||
|
||||
- [ ] 生成5道题目,验证列表显示正确
|
||||
- [ ] 搜索题目,验证过滤结果准确
|
||||
- [ ] 按状态筛选(待审核/已通过/已驳回/已发布)
|
||||
- [ ] 单个审批通过/驳回
|
||||
- [ ] 批量审批多道题目
|
||||
- [ ] 编辑题目并保存
|
||||
- [ ] 发布已通过的题目
|
||||
- [ ] 删除题目(带确认)
|
||||
- [ ] 分页切换(第1页/第2页/每页20条)
|
||||
|
||||
### 8.2 边界测试
|
||||
|
||||
- [ ] 生成了0道题目(空状态显示)
|
||||
- [ ] 生成了100道题目(性能测试)
|
||||
- [ ] 同时点击多次生成(防重复提交)
|
||||
- [ ] 网络断开时的错误处理
|
||||
- [ ] 权限不足时的UI反馈
|
||||
|
||||
### 8.3 UI/UX测试
|
||||
|
||||
- [ ] 页面滚动流畅(无局部滚动条)
|
||||
- [ ] 按钮hover效果正常
|
||||
- [ ] 加载动画显示正确
|
||||
- [ ] 响应式布局适配不同屏幕
|
||||
- [ ] 空状态提示友好
|
||||
|
||||
---
|
||||
|
||||
## 九、成功标准
|
||||
|
||||
### 9.1 功能完整性
|
||||
|
||||
✅ 用户可以完整执行以下流程:
|
||||
1. 选择文件 → 配置参数 → 生成题目
|
||||
2. 查看待审核题目列表
|
||||
3. 对每道题目进行审批/编辑/发布
|
||||
4. 搜索、筛选、分页浏览
|
||||
|
||||
### 9.2 用户体验
|
||||
|
||||
✅ 符合设计规范:
|
||||
- 白色体系配色
|
||||
- 紧凑但清晰的布局
|
||||
- 一致的组件样式
|
||||
- 流畅的交互动画
|
||||
- 友好的错误提示
|
||||
|
||||
### 9.3 技术质量
|
||||
|
||||
✅ 代码质量:
|
||||
- 无语法错误
|
||||
- 接口调用正确
|
||||
- 状态管理清晰
|
||||
- 错误处理完善
|
||||
- 性能可接受
|
||||
|
||||
---
|
||||
|
||||
## 十、后续扩展方向
|
||||
|
||||
1. **题目版本管理**:记录每次编辑的历史版本
|
||||
2. **批量导入导出**:支持Excel格式导入/导出题目
|
||||
3. **AI辅助审核**:AI自动预审,人工复核
|
||||
4. **统计分析**:题目通过率、平均审核时间等
|
||||
5. **权限细化**:角色权限(管理员/审核员/出题人)
|
||||
6. **移动端适配**:响应式布局优化手机端体验
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-05-16
|
||||
**状态**: 待用户确认
|
||||
@@ -0,0 +1,562 @@
|
||||
# PaperManagementPanel 题目配置组件重设计
|
||||
|
||||
> 日期: 2026-05-29
|
||||
> 状态: 待审核
|
||||
> 范围: PaperManagementPanel.vue 全面模块化拆分与视觉优化
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状问题
|
||||
|
||||
`src/components/exam/PaperManagementPanel.vue` 是一个 **2235 行的巨型单文件组件**,包含 6 个独立功能区全部耦合在一起:
|
||||
|
||||
| 维度 | 当前数值 |
|
||||
|------|----------|
|
||||
| Template | 567 行 |
|
||||
| Script | ~1065 行 |
|
||||
| Style | ~600 行 |
|
||||
| 功能区数 | 6+ 个 |
|
||||
| 硬编码颜色值 | ~30 处(未使用 design-tokens) |
|
||||
| console.log | ~40 处(调试残留) |
|
||||
|
||||
### 1.2 设计目标
|
||||
|
||||
1. **组件拆分**: 将单文件拆分为 7 个子组件 + 2 个 composable,每个 < 350 行
|
||||
2. **代码精简**: 移除冗余逻辑、调试日志、硬编码颜色
|
||||
3. **视觉优化**: 延续现有扁平设计风格,统一使用 design-tokens
|
||||
4. **交互改进**: 优化组卷配置区控件、预览区展示、弹窗体验
|
||||
5. **响应式**: 保持移动端/平板/桌面断点适配
|
||||
|
||||
## 2. 组件架构
|
||||
|
||||
### 2.1 文件结构
|
||||
|
||||
```
|
||||
src/components/exam/
|
||||
├── PaperManagementPanel.vue # 主容器 (~80行)
|
||||
├── ComposePanel.vue # 智能组卷面板 - 左右分栏容器 (~50行)
|
||||
│ ├── ComposeConfigSidebar.vue # 左侧配置栏 (~200行)
|
||||
│ └── ComposePreviewPane.vue # 右侧预览区 (~180行)
|
||||
├── PaperListTab.vue # 草稿/已发布列表 - 复用型 (~150行)
|
||||
├── PaperDetailModal.vue # 试卷详情弹窗 (~250行)
|
||||
├── PublishDialog.vue # 发布弹窗 (~300行)
|
||||
├── EntityPickerModal.vue # 通用实体选择器弹窗 (~180行)
|
||||
├── composables/
|
||||
│ ├── usePaperManagement.js # 共享状态管理 (~400行)
|
||||
│ └── useQuestionParser.js # 题目数据解析工具 (~150行)
|
||||
└── common/ # 已有公共组件 (不变更)
|
||||
├── PageHeader.vue
|
||||
├── StatsRow.vue
|
||||
└── ContentCard.vue
|
||||
```
|
||||
|
||||
### 2.2 组件树与数据流
|
||||
|
||||
```
|
||||
PaperManagementPanel (主容器)
|
||||
│
|
||||
├─ usePaperManagement (composable: 全局状态中心)
|
||||
│
|
||||
├─► Tab Navigation (内置: 智能组卷 / 我的草稿 / 已发布试卷)
|
||||
│
|
||||
├─► [activeTab='compose'] ComposePanel
|
||||
│ ├─► ComposeConfigSidebar
|
||||
│ │ props: form, selectedFiles, allFiles, loadingFiles
|
||||
│ │ emit: generate, update:form
|
||||
│ └─► ComposePreviewPane
|
||||
│ props: result, composing, totalScore
|
||||
│ emit: save
|
||||
│
|
||||
├─► [activeTab='drafts'] PaperListTab(mode='drafts')
|
||||
│ props: papers, loading
|
||||
│ emit: view, publish, delete
|
||||
│
|
||||
├─► [activeTab='published'] PaperListTab(mode='published')
|
||||
│ props: papers, loading
|
||||
│ emit: view, revoke
|
||||
│
|
||||
├─► PaperDetailModal (Teleport to body)
|
||||
│ v-model: visible
|
||||
│ props: rawData
|
||||
│
|
||||
├─► PublishDialog (Teleport to body)
|
||||
│ v-model: visible
|
||||
│ props: paperId
|
||||
│ emit: confirm
|
||||
│ └─► EntityPickerModal (Teleport to body)
|
||||
│ v-model: visible
|
||||
│ props: type, mode, sourceList, selectedIds
|
||||
│ emit: confirm
|
||||
└─► EntityPickerModal (独立使用场景预留)
|
||||
```
|
||||
|
||||
### 2.3 通信原则
|
||||
|
||||
- **单向数据流**: 父 → 子通过 props,子 → 父通过 emits
|
||||
- **状态提升**: 所有业务状态集中在 `usePaperManagement` composable
|
||||
- **v-model 模式**: 弹窗类组件支持 `v-model:visible` 双向绑定
|
||||
- **接口最小化**: 每个子组件只暴露必要的 props/emits
|
||||
|
||||
## 3. 各组件详细设计
|
||||
|
||||
### 3.1 PaperManagementPanel.vue — 主容器
|
||||
|
||||
**职责**: Tab 导航 + 子组件编排 + 状态初始化
|
||||
|
||||
**Template 结构**:
|
||||
```html
|
||||
<div class="pmp-panel">
|
||||
<PageHeader title="智能组卷" description="..." />
|
||||
<div class="pmp-tabs"> <!-- 3个Tab按钮 --> </div>
|
||||
|
||||
<ComposePanel v-if="activeTab==='compose'" ... />
|
||||
<PaperListTab v-else-if="activeTab==='drafts'" mode="drafts" ... />
|
||||
<PaperListTab v-else-if="activeTab==='published'" mode="published" ... />
|
||||
|
||||
<PaperDetailModal v-model="detailVisible" :raw-data="previewRaw" />
|
||||
<PublishDialog v-model="publishVisible" :paper-id="pubPaperId" @confirm="confirmPublish" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Script**: 仅做 composable 解构和事件转发,< 80 行
|
||||
|
||||
---
|
||||
|
||||
### 3.2 ComposeConfigSidebar.vue — 组卷配置栏
|
||||
|
||||
**职责**: 数据源选择、难度设置、题型数量配置、生成触发
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
modelValue: { // composeForm 对象
|
||||
difficulty: Number, // 1-5
|
||||
include_personal: Boolean,
|
||||
single_choice_count: Number,
|
||||
multiple_choice_count: Number,
|
||||
true_false_count: Number,
|
||||
fill_blank_count: Number,
|
||||
subjective_count: Number
|
||||
},
|
||||
selectedFiles: Array, // 已选文件列表
|
||||
allFiles: Array, // 全量文件列表
|
||||
loadingFiles: Boolean,
|
||||
totalCount: Number // 计算属性:各题型数量之和
|
||||
}
|
||||
```
|
||||
|
||||
#### Emits
|
||||
|
||||
```typescript
|
||||
{
|
||||
'update:modelValue': [form], // 表单变更
|
||||
'toggle-file': [file], // 文件选择切换
|
||||
'remove-file': [id], // 移除已选文件
|
||||
'open-picker': [], // 打开文件选择器
|
||||
'close-picker': [], // 收起文件选择器
|
||||
'generate': [] // 触发生成试卷
|
||||
}
|
||||
```
|
||||
|
||||
#### 视觉改进点
|
||||
|
||||
| 改进项 | 当前实现 | 新设计 |
|
||||
|--------|----------|--------|
|
||||
| Step 标识 | 圆形数字 badge (1/2/3) | 分隔线 + 小标题,减少视觉噪音 |
|
||||
| 题型数量控件 | `[-] 数字 [+]` 按钮 | Native `<select>` 下拉 (0-50),更紧凑 |
|
||||
| 难度选择 | 5 个并排按钮 | 保持按钮组,使用 design-tokens 颜色 |
|
||||
| 文件选择器 | 展开内嵌列表 | 限制 max-height: 180px,滚动溢出 |
|
||||
| 总计行 | 底部小字 "共 N 题" | 加粗数字 + `--bg-subtle` 色块突出 |
|
||||
| 生成按钮 | 全宽黑色 `#212529` | 使用 `--color-primary` token |
|
||||
|
||||
#### 布局草图
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 📋 配置 │
|
||||
├──────────────────────────────┤
|
||||
│ │
|
||||
│ ── 数据源 ────────────── │
|
||||
│ [从知识库选择文件] │
|
||||
│ [chip: 制度A ×] [chip: 规范B ×] │
|
||||
│ 💡 不选则从全部题库出题 │
|
||||
│ │
|
||||
│ ── 难度等级 ──────────── │
|
||||
│ [简单] [中等✓] [较难] [困难] │
|
||||
│ │
|
||||
│ ── 题型配置 ──────────── │
|
||||
│ 单选题 [▼ 5 ▲] │
|
||||
│ 多选题 [▼ 3 ▲] │
|
||||
│ 判断题 [▼ 0 ▲] │
|
||||
│ 填空题 [▼ 2 ▲] │
|
||||
│ 简答题 [▼ 0 ▲] │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ 共 10 题 │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ ✨ 生成试卷 │ │
|
||||
│ └──────────────────────┘ │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 ComposePreviewPane.vue — 组卷预览区
|
||||
|
||||
**职责**: 展示组卷结果(空态/加载态/题目预览)+ 保存操作
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
result: Object | null, // composeResult
|
||||
composing: Boolean,
|
||||
totalScore: Number
|
||||
}
|
||||
```
|
||||
|
||||
#### Emits
|
||||
|
||||
```typescript
|
||||
{ save: [] }
|
||||
```
|
||||
|
||||
#### 三态设计
|
||||
|
||||
1. **空态** (`!result && !composing`):
|
||||
- SVG 图标 + 引导文案 "选择文件(可选)、设置题型数量 → 点击「生成试卷」"
|
||||
- 使用 `--text-muted` 颜色
|
||||
|
||||
2. **加载态** (`composing`):
|
||||
- Spinner + "正在从题库中选题组卷..."
|
||||
|
||||
3. **结果态** (`result && !composing`):
|
||||
- 试卷预览卡片:
|
||||
- 标题 + 统计信息(题目数、总分)
|
||||
- 题目列表(每题:序号圆圈 + 类型标签 + 难度 + 题干)
|
||||
- hover 时边框高亮
|
||||
- 保存区域: 保存按钮 + 提示文字
|
||||
|
||||
#### 视觉改进
|
||||
|
||||
| 改进项 | 当前 | 新设计 |
|
||||
|--------|------|--------|
|
||||
| 预览卡边框 | `2px solid #228BE6` (蓝色粗边框) | `1px solid var(--border-default)` + 微妙阴影 |
|
||||
| 预览卡头部 | 渐变蓝背景 | `var(--bg-subtle)` 纯色背景 |
|
||||
| 题号圆圈 | `#F1F9FA` 灰底 | `var(--color-primary)` 主色底 + 白字 |
|
||||
| 保存按钮 | 黑色全宽 | `var(--color-primary)` token |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 PaperListTab.vue — 复用型列表组件
|
||||
|
||||
**职责**: 通过 mode prop 切换草稿/已发布两种列表模式
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
mode: 'drafts' | 'published',
|
||||
papers: Array,
|
||||
loading: Boolean
|
||||
}
|
||||
```
|
||||
|
||||
#### Emits
|
||||
|
||||
```typescript
|
||||
{
|
||||
view: [paper],
|
||||
publish: [paperId], // 仅 drafts 模式
|
||||
delete: [paperId], // 仅 drafts 模式
|
||||
revoke: [paperId] // 仅 published 模式
|
||||
}
|
||||
```
|
||||
|
||||
#### 视觉改进
|
||||
|
||||
| 改进项 | 当前 | 新设计 |
|
||||
|--------|------|--------|
|
||||
| 行左侧 | 无标识 | 彩色竖条(草稿=`--color-warning`, 发布=`--color-success`) |
|
||||
| 操作按钮 | 文字按钮 ("查看"/"发布"/"删除") | 图标按钮 + hover tooltip |
|
||||
| Badge 样式 | 自定义 CSS | 使用 design-token 颜色 |
|
||||
| 空态 | 内联 SVG | 统一空态组件风格 |
|
||||
|
||||
#### 条件渲染逻辑
|
||||
|
||||
```html
|
||||
<!-- 草稿模式独有 -->
|
||||
<button v-if="mode === 'drafts'" @click="$emit('publish', id)">发布</button>
|
||||
<button v-if="mode === 'drafts'" @click="$emit('delete', id)">删除</button>
|
||||
|
||||
<!-- 已发布模式独有 -->
|
||||
<button v-if="mode === 'published'" @click="$emit('revoke', id)">撤回</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 PaperDetailModal.vue — 试卷详情弹窗
|
||||
|
||||
**职责**: 展示试卷完整题目列表(选项/答案/解析/难度)
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
modelValue: Boolean, // 弹窗显隐
|
||||
rawData: Object | null // 原始 API 返回数据
|
||||
}
|
||||
```
|
||||
|
||||
#### 内部处理
|
||||
|
||||
- 使用 `useQuestionParser()` composable 解析原始数据
|
||||
- 解析逻辑包括: content 多格式兼容、选项提取、答案匹配、类型判断
|
||||
|
||||
#### 展示区块
|
||||
|
||||
1. **发布信息区** (如有): 目标部门/用户/时间/截止
|
||||
2. **概要行**: 总题数 + 总分
|
||||
3. **题目列表** (每题):
|
||||
- 头部: 序号(主色圆圈) + 类型标签 + 分数
|
||||
- 题干文本
|
||||
- 选择题: 选项列表 (正确选项绿色左边框 + ✓)
|
||||
- 判断题: 正确/错误文本
|
||||
填空/简答: 参考答案文本
|
||||
- 解析区: `--color-warning` 背景 (与 ExamPanel.vue 统一)
|
||||
- 难度标签 (如有)
|
||||
4. **Fallback 区**: 原始数据兜底展示 (当自动解析失败时)
|
||||
|
||||
---
|
||||
|
||||
### 3.6 PublishDialog.vue — 发布弹窗
|
||||
|
||||
**职责**: 设置发布参数(部门/用户范围/时间)
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
modelValue: Boolean,
|
||||
paperId: String | Number
|
||||
}
|
||||
```
|
||||
|
||||
#### Emits
|
||||
|
||||
```typescript
|
||||
{
|
||||
'update:modelValue': [Boolean],
|
||||
confirm: [payload] // { deptIds, userIds, excludeUserIds, publishTime?, deadline? }
|
||||
}
|
||||
```
|
||||
|
||||
#### 内部状态管理
|
||||
|
||||
所有发布相关状态(部门列表、用户列表、选择状态、模式等)从 `usePaperManagement` 中获取。
|
||||
|
||||
#### 子组件调用
|
||||
|
||||
- 部门选择 → 调用 `<EntityPickerModal type="dept" />`
|
||||
- 用户选择 → 调用 `<EntityPickerModal type="user" :mode="userMode" />`
|
||||
|
||||
#### 布局保持: 两列网格 (Grid 1fr 1fr)
|
||||
|
||||
---
|
||||
|
||||
### 3.7 EntityPickerModal.vue — 通用实体选择器
|
||||
|
||||
**职责**: 通用的部门/用户多选弹窗,可复用
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
modelValue: Boolean,
|
||||
type: 'dept' | 'user',
|
||||
mode: 'target' | 'exclude', // 仅 type='user' 时有效
|
||||
sourceList: Array, // 可选数据源
|
||||
selectedIds: Set, // 当前已选 ID 集合
|
||||
loading: Boolean,
|
||||
searchPlaceholder: String
|
||||
}
|
||||
```
|
||||
|
||||
#### Emits
|
||||
|
||||
```typescript
|
||||
{
|
||||
'update:modelValue': [Boolean],
|
||||
confirm: [selectedItems] // 选中的完整对象数组
|
||||
}
|
||||
```
|
||||
|
||||
#### 功能特性
|
||||
|
||||
- 搜索过滤 (实时)
|
||||
- 全选 / 清空
|
||||
- 已选计数显示
|
||||
- Checkbox 列表 + hover 高亮
|
||||
- 部门模式下显示部门名称
|
||||
- 用户模式下显示姓名 + 所属部门
|
||||
|
||||
## 4. Composables 设计
|
||||
|
||||
### 4.1 usePaperManagement.js
|
||||
|
||||
**定位**: PaperManagementPanel 的全局状态中心
|
||||
|
||||
**状态分组**:
|
||||
|
||||
```
|
||||
┌─ Tab 状态
|
||||
│ activeTab, tabs
|
||||
│
|
||||
├─ 组卷状态
|
||||
│ composeForm, composeResult, composing
|
||||
│ selectedFiles, allFiles, loadingFiles, showFilePicker
|
||||
│
|
||||
├─ 列表状态
|
||||
│ drafts, publishedPapers, loadingDrafts
|
||||
│
|
||||
├─ 详情状态
|
||||
│ previewRaw, previewQuestions, previewPaperTitle, previewPublishInfo
|
||||
│
|
||||
├─ 发布状态
|
||||
│ publishDialog, pubPaperId, publishing
|
||||
│ pub (reactive), pubUI (reactive)
|
||||
│ pickerDialog (reactive)
|
||||
│
|
||||
└─ 缓存
|
||||
_deptCache, _deptCacheTime, _userCache, CACHE_TTL
|
||||
```
|
||||
|
||||
**方法分组**:
|
||||
|
||||
| 分类 | 方法 |
|
||||
|------|------|
|
||||
| Tab | switchTab |
|
||||
| 组卷 | handleGeneratePaper, handleSavePaper, increment, decrement |
|
||||
| 文件 | toggleFile, removeFile, openFilePicker |
|
||||
| 列表 | loadPapers, deleteDraft, revokePublishedPaper |
|
||||
| 详情 | viewPaperDetail, clearPreview |
|
||||
| 发布 | openPublishDialog, closePublish, confirmPublish |
|
||||
| Picker | openPicker, closePicker, confirmPicker |
|
||||
| 部门 | loadDeptList, filterDeptList, toggleDept, removeDept, selectAllDepts, clearAllDepts |
|
||||
| 用户 | loadUserListForPicker, refreshUsersForDepts, toggleCurrentUser, removeUser, selectAllUsers, clearAllUsers |
|
||||
| 工具 | getId, getPaperTitle, formatQType, formatPubTime, isDeadlineNear |
|
||||
|
||||
### 4.2 useQuestionParser.js
|
||||
|
||||
**定位**: 从 viewPaperDetail() 中提取的纯函数集合,供 PaperDetailModal 使用
|
||||
|
||||
**导出函数**:
|
||||
|
||||
```javascript
|
||||
export function useQuestionParser() {
|
||||
function parseQuestionContent(rawQ) → { stem, options, answer, explanation, ... }
|
||||
function extractStem(q) → string
|
||||
function extractOptions(q) → Array<{ key, text }>
|
||||
function extractAnswer(q) → string
|
||||
function isChoiceQuestion(q) → boolean
|
||||
function isTextQuestion(q) → boolean
|
||||
function isOptionAnswer(opt, q) → boolean
|
||||
function formatTfAnswer(q) → string
|
||||
function formatDifficulty(d) → string
|
||||
function formatQType(t) → string
|
||||
|
||||
return { parseQuestionContent, extractStem, extractOptions, extractAnswer,
|
||||
isChoiceQuestion, isTextQuestion, isOptionAnswer,
|
||||
formatTfAnswer, formatDifficulty, formatQType }
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 样式规范
|
||||
|
||||
### 5.1 Design Token 映射
|
||||
|
||||
所有新组件必须使用项目 `design-tokens.css` 中定义的 CSS 变量,禁止硬编码颜色值:
|
||||
|
||||
| 当前硬编码 | 替换 Token | 用途 |
|
||||
|------------|-----------|------|
|
||||
| `#212529` | `var(--text-primary)` | 主文字 |
|
||||
| `#495057` | `var(--text-secondary)` | 次要文字 |
|
||||
| `#868E96` | `var(--text-muted)` | 辅助文字 |
|
||||
| `#ADB5BD` / `#6C757D` | `var(--text-muted)` | 占位符/提示 |
|
||||
| `#E9ECEF` | `var(--border-light)` | 浅边框 |
|
||||
| `#DEE2E6` / `#CED4DA` | `var(--border-default)` | 默认边框 |
|
||||
| `#F8F9FA` | `var(--bg-subtle)` | 浅背景 |
|
||||
| `#F1F3F5` | `var(--bg-hover)` | Hover 背景 |
|
||||
| `#FFF5F5` | `var(--bg-danger-subtle)` | 危险背景 |
|
||||
| `#EBFBEE` / `#D3F9D8` | `var(--bg-success-subtle)` | 成功背景 |
|
||||
| `#FFFBEB` / `#FFF9DB` | `var(--bg-warning-subtle)` | 警告背景 |
|
||||
| `#C92A2A` | `var(--color-danger)` | 危险色 |
|
||||
| `#2B8A3E` | `var(--color-success)` | 成功色 |
|
||||
| `#228BE6` / `#1C7ED6` | `var(--color-primary)` | 主色调 |
|
||||
| `#E67700` | `var(--color-warning)` | 警告色 |
|
||||
|
||||
### 5.2 CSS 命名规范
|
||||
|
||||
使用 BEM 变体命名,前缀按组件缩写:
|
||||
|
||||
```
|
||||
.pmp-{主容器}__{元素}--{修饰符}
|
||||
.cps-{ComposePanel}__{元素}--{修饰符}
|
||||
.ccs-{ComposeConfigSidebar}__{元素}--{修饰符}
|
||||
.cpp-{ComposePreviewPane}__{元素}--{修饰符}
|
||||
.plt-{PaperListTab}__{元素}--{修饰符}
|
||||
.pdm-{PaperDetailModal}__{元素}--{修饰符}
|
||||
.pbd-{PublishDialog}__{元素}--{修饰符}
|
||||
.epm-{EntityPickerModal}__{元素}--{修饰符}
|
||||
```
|
||||
|
||||
### 5.3 响应式断点
|
||||
|
||||
复用项目已有断点体系:
|
||||
|
||||
| 断点 | 宽度 | 布局调整 |
|
||||
|------|------|----------|
|
||||
| Mobile | ≤767px | 组卷分栏→上下堆叠;弹窗全屏;列表单列 |
|
||||
| Tablet | 768-1023px | 组卷侧栏收窄至 280px |
|
||||
| Desktop | 1024-1439px | 标准布局 |
|
||||
| Large | ≥1440px | 侧栏放宽至 380px;预览区更大 |
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
以下原样式将在拆分时移除(属于其他组件或冗余):
|
||||
|
||||
- `.answer-comparison`, `.your-answer`, `.correct-answer` — TrainingPanel 残留
|
||||
- `.wrong`, `.correct` — 错题本遗留(已删除功能)
|
||||
- `.explanation` — 与新 `.dqc-explanation` 重复
|
||||
- 重复定义的 `.loading-spinner` 和 `@keyframes spin` — 统一到公共样式中
|
||||
- 所有 `console.log` / `console.warn` 调试语句 — 移除或改为条件编译
|
||||
|
||||
## 6. 实施顺序建议
|
||||
|
||||
1. **Phase 1 - 基础设施**
|
||||
- 创建 `useQuestionParser.js`(纯函数,无依赖)
|
||||
- 创建 `usePaperManagement.js`(从原组件提取状态和方法)
|
||||
|
||||
2. **Phase 2 - 核心组件**
|
||||
- 创建 `ComposeConfigSidebar.vue`
|
||||
- 创建 `ComposePreviewPane.vue`
|
||||
- 创建 `ComposePanel.vue`(组合上述两个)
|
||||
- 创建 `PaperListTab.vue`
|
||||
|
||||
3. **Phase 3 - 弹窗组件**
|
||||
- 创建 `EntityPickerModal.vue`(通用,无业务依赖)
|
||||
- 创建 `PublishDialog.vue`(依赖 EntityPickerModal)
|
||||
- 创建 `PaperDetailModal.vue`(依赖 useQuestionParser)
|
||||
|
||||
4. **Phase 4 - 主容器整合**
|
||||
- 重构 `PaperManagementPanel.vue` 为薄容器
|
||||
- 接入所有子组件
|
||||
- 样式清理与 token 替换
|
||||
|
||||
5. **Phase 5 - 验证**
|
||||
- 功能回归测试(组卷/保存/发布/查看/删除/撤回)
|
||||
- 响应式布局测试
|
||||
- 构建验证 (npm run build)
|
||||
272
docs/superpowers/specs/2026-05-29-upload-task-panel-design.md
Normal file
272
docs/superpowers/specs/2026-05-29-upload-task-panel-design.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 上传任务列表组件 — 设计规格
|
||||
|
||||
> **日期**: 2026-05-29
|
||||
> **目标**: 在 ReadModule 中嵌入上传任务列表组件,实现文件上传后自动跟踪向量化+出题状态,支持轮询刷新、分页、筛选
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据源分析
|
||||
|
||||
### 1.1 后端 file 表关键字段(来自 `file.sql`)
|
||||
|
||||
| 字段 | 类型 | 用途 | 可能值 |
|
||||
|------|------|------|--------|
|
||||
| `process_step_status` | varchar(32) | **流程状态**(主状态) | `UPLOADED` / `VECTORIZING` / `VECTORIZED` / `VECTORIZE_FAILED` / `EXAM_GENERATING` / `EXAM_GENERATED` / `EXAM_GENERATE_FAILED` / `COMPLETED` |
|
||||
| `process_step_message` | varchar(512) | **流程步骤信息**(向量化详情) | `"向量化失败: 确保collection存在失败..."` / `"文件不存在于服务器"` / null |
|
||||
| `exam_status` | varchar(20) | **出题状态** | `UNGENERATED` / `GENERATING` / `GENERATED` / `FAILED` |
|
||||
| `process_message` | text | **处理消息**(出题详情) | `"生成成功,共4道题"` / `"生成失败: 所有题目保存失败..."` / null |
|
||||
|
||||
### 1.2 状态映射规则
|
||||
|
||||
**向量化状态**(从 `process_step_status` + `process_step_message` 判断):
|
||||
|
||||
| process_step_status | 判定结果 | UI 展示 |
|
||||
|---------------------|----------|---------|
|
||||
| `UPLOADED` | 待向量化 | ⏳ 灰色 - 等待中 |
|
||||
| `VECTORIZING` | 向量化中 | 🔄 蓝色 - 处理中(动画) |
|
||||
| `VECTORIZED` | 向量化成功 | ✅ 绿色 - 成功 |
|
||||
| `VECTORIZE_FAILED` | 向量化失败 | ❌ 红色 - 失败(显示 process_step_message) |
|
||||
|
||||
**出题状态**(从 `exam_status` + `process_message` 判断):
|
||||
|
||||
| exam_status | 判定结果 | UI 展示 |
|
||||
|-------------|----------|---------|
|
||||
| `UNGENERATED` | 未出题 | ⏸ 灰色 - 未开始 |
|
||||
| `GENERATING` | 出题中 | 🔄 蓝色 - 处理中(动画) |
|
||||
| `GENERATED` | 出题成功 | ✅ 绿色 - 成功(显示题目数) |
|
||||
| `FAILED` | 出题失败 | ❌ 红色 - 失败(显示 process_message) |
|
||||
|
||||
### 1.3 触发条件
|
||||
|
||||
- 上传成功后:新上传的文件自动加入任务列表
|
||||
- 页面加载时:加载最近 N 条有处理状态的文件(非 UPLOADED/COMPLETED 的活跃任务)
|
||||
- 轮询间隔:5 秒一次,仅对"处理中"状态的任务轮询
|
||||
|
||||
---
|
||||
|
||||
## 2. 组件架构
|
||||
|
||||
```
|
||||
ReadModule.vue
|
||||
├── 文件列表 Tab(已有)
|
||||
├── 文件审批 Tab(已有)
|
||||
└── [新增] 上传任务面板 (UploadTaskPanel)
|
||||
├── 任务头部统计栏(总数 / 处理中 / 成功 / 失败)
|
||||
├── 筛选工具栏(状态筛选 + 搜索 + 手动刷新按钮)
|
||||
├── 任务列表(分页展示)
|
||||
│ └── UploadTaskItem × N
|
||||
│ ├── 文件基本信息(名称、大小、部门、时间)
|
||||
│ ├── 向量化状态条
|
||||
│ └── 出题状态条
|
||||
└── 分页器
|
||||
```
|
||||
|
||||
### 2.1 文件结构
|
||||
|
||||
| 文件 | 职责 | 预估行数 |
|
||||
|------|------|----------|
|
||||
| `src/components/exam/composables/useUploadTasks.js` | 任务数据管理、轮询逻辑、状态解析 | ~250 行 |
|
||||
| `src/components/exam/UploadTaskPanel.vue` | 任务列表面板容器 | ~180 行 |
|
||||
| `src/components/exam/UploadTaskItem.vue` | 单个任务卡片 | ~150 行 |
|
||||
| 修改 `src/components/ReadModule.vue` | 嵌入 UploadTaskPanel,上传后触发添加任务 | ~30 行改动 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 各组件详细设计
|
||||
|
||||
### 3.1 useUploadTasks.js — Composable
|
||||
|
||||
**职责**:所有任务数据的获取、缓存、状态解析、轮询控制。
|
||||
|
||||
**导出接口**:
|
||||
```javascript
|
||||
export function useUploadTasks() {
|
||||
// 状态
|
||||
const tasks = ref([]) // 任务列表原始数据
|
||||
const loading = ref(false)
|
||||
const polling = ref(false)
|
||||
|
||||
// 筛选
|
||||
const statusFilter = ref('all') // all | vectorizing | vectorized | vector_failed | exam_generating | generated | failed
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// 统计
|
||||
const stats = computed(() => ({ total, processing, success, failed }))
|
||||
const filteredTasks = computed(() => { /* 筛选+分页 */ })
|
||||
|
||||
// 核心方法
|
||||
function addTask(fileData) // 上传成功后调用,将文件加入任务列表
|
||||
function parseVectorStatus(task) // 解析向量化状态 → { phase, status, label, message, color }
|
||||
function parseExamStatus(task) // 解析出题状态 → { phase, status, label, message, color }
|
||||
function startPolling() // 启动定时轮询
|
||||
function stopPolling() // 停止轮询
|
||||
function refreshTasks() // 手动刷新
|
||||
function fetchTasks() // 从后端获取任务列表 API
|
||||
|
||||
return { tasks, loading, polling, statusFilter, currentPage, pageSize,
|
||||
stats, filteredTasks, addTask, parseVectorStatus, parseExamStatus,
|
||||
startPolling, stopPolling, refreshTasks, fetchTasks }
|
||||
}
|
||||
```
|
||||
|
||||
**状态解析函数核心逻辑**:
|
||||
```javascript
|
||||
function parseVectorStatus(task) {
|
||||
const pss = task.processStepStatus || task.process_step_status || ''
|
||||
const psm = task.processStepMessage || task.process_step_message || ''
|
||||
|
||||
if (['VECTORIZING'].includes(pss))
|
||||
return { phase: 'vectorize', status: 'processing', label: '向量化中', message: '正在处理...', color: 'info', animating: true }
|
||||
if (pss === 'VECTORIZED')
|
||||
return { phase: 'vectorize', status: 'success', label: '向量化完成', message: null, color: 'success' }
|
||||
if (pss === 'VECTORIZE_FAILED')
|
||||
return { phase: 'vectorize', status: 'error', label: '向量化失败', message: psm || '未知错误', color: 'error' }
|
||||
if (['UPLOADED'].includes(pss))
|
||||
return { phase: 'vectorize', status: 'pending', label: '等待向量化', message: null, color: 'pending' }
|
||||
if (['EXAM_GENERATING', 'EXAM_GENERATED', 'EXAM_GENERATE_FAILED', 'COMPLETED'].includes(pss))
|
||||
return { phase: 'vectorize', status: 'success', label: '已入库', message: null, color: 'success' }
|
||||
|
||||
return { phase: 'vectorize', status: 'unknown', label: '未知', message: null, color: 'pending' }
|
||||
}
|
||||
|
||||
function parseExamStatus(task) {
|
||||
const es = task.examStatus || task.exam_status || ''
|
||||
const pm = task.processMessage || task.process_message || ''
|
||||
|
||||
if (es === 'GENERATING')
|
||||
return { phase: 'exam', status: 'processing', label: '生成题目中...', message: null, color: 'info', animating: true }
|
||||
if (es === 'GENERATED') {
|
||||
const match = pm?.match(/共(\d+)道题/)
|
||||
return { phase: 'exam', status: 'success', label: match ? `生成${match[1]}道题` : '题目生成完成', message: pm, color: 'success' }
|
||||
}
|
||||
if (es === 'FAILED')
|
||||
return { phase: 'exam', status: 'error', label: '出题失败', message: pm || '未知错误', color: 'error' }
|
||||
if (es === 'UNGENERATED')
|
||||
return { phase: 'exam', status: 'pending', label: '未出题', message: null, color: 'pending' }
|
||||
|
||||
return { phase: 'exam', status: 'unknown', label: '-', message: null, color: 'pending' }
|
||||
}
|
||||
```
|
||||
|
||||
**轮询策略**:
|
||||
- 仅当存在 `status === 'processing' && animating === true` 的任务时启动轮询
|
||||
- 调用 `fileAPI.getFiles({ pageNum: 1, pageSize: 50 })` 获取最新数据
|
||||
- 对比 `process_step_status` 和 `exam_status` 变化,更新对应任务
|
||||
- 全部任务完成后自动停止轮询
|
||||
- 用户关闭面板或切换 Tab 时停止轮询
|
||||
|
||||
### 3.2 UploadTaskPanel.vue — 面板容器
|
||||
|
||||
**Props**: 无(内部使用 composable)
|
||||
|
||||
**布局**:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 📋 上传任务 [🔄 刷新] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 全部(12) 处理中(3) 成功(7) 失败(2) │ ← 统计标签栏
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [状态筛选 ▾] [🔍 搜索...] │ ← 工具栏
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 📄 longrule.docx 采购部门 │ │
|
||||
│ │ 14KB · 2026-05-29 12:01 │ │
|
||||
│ │ ✅ 向量化完成 │ │ ← 向量化状态条
|
||||
│ │ ✅ 生成4道题 │ │ ← 出题状态条
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ... │
|
||||
│ [< 1 2 >] 共12条 │ ← 分页
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**样式规范**:全部使用 design-tokens.css 变量,与 ReadModule 现有风格一致(白色扁平设计)。
|
||||
|
||||
### 3.3 UploadTaskItem.vue — 任务卡片
|
||||
|
||||
**Props**:
|
||||
```javascript
|
||||
{
|
||||
task: Object, // 原始任务数据(file 表记录)
|
||||
vectorStatus: Object, // parseVectorStatus() 返回值
|
||||
examStatus: Object // parseExamStatus() 返回值
|
||||
}
|
||||
```
|
||||
|
||||
**每个状态条的视觉设计**:
|
||||
|
||||
**状态条通用结构**:
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ [图标] 状态文字 详情/错误消息 │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 状态 | 图标 | 文字颜色 | 背景 | 特殊效果 |
|
||||
|------|------|----------|------|----------|
|
||||
| pending(等待) | ⏸ 时钟 | --text-disabled | --bg-container | 无 |
|
||||
| processing(处理中) | 🔄 spinner | --color-info | --bg-info | 脉冲动画 |
|
||||
| success(成功) | ✓ CheckCircleOutlined | --color-success | --bg-success | 无 |
|
||||
| error(失败) | ✗ CloseCircleOutlined | --color-error | --bg-error | 可展开查看错误消息 |
|
||||
| unknown(未知) | - 问号 | --text-disabled | --bg-container | 无 |
|
||||
|
||||
**错误消息展开**:点击 error 状态条可展开/收起完整的 `process_step_message` 或 `process_message` 文本。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与 ReadModule 集成方式
|
||||
|
||||
### 4.1 位置
|
||||
|
||||
在 ReadModule 的 Tab 栏下方、内容区上方,以可折叠面板形式呈现。默认折叠,上传后自动展开。
|
||||
|
||||
### 4.2 上传成功后联动
|
||||
|
||||
在 `handleUploadSubmit()` 成功回调中:
|
||||
```javascript
|
||||
// 上传成功后
|
||||
const result = await fileAPI.uploadFile(formData)
|
||||
if (result.data.code === 200) {
|
||||
const newFile = result.data.data
|
||||
uploadTasks.addTask(newFile) // 添加到任务列表
|
||||
uploadTasks.startPolling() // 启动轮询
|
||||
showUploadTaskPanel.value = true // 展开任务面板
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 生命周期
|
||||
|
||||
- `onMounted`: 如果有未完成的任务,自动加载并启动轮询
|
||||
- `onUnmounted`: 停止轮询清理定时器
|
||||
- 切换到其他 Tab: 暂停轮询(可选)
|
||||
|
||||
---
|
||||
|
||||
## 5. API 调用
|
||||
|
||||
复用已有的 `fileAPI.getFiles(params)` 接口,参数:
|
||||
```javascript
|
||||
{ pageNum: 1, pageSize: 100 } // 获取足够多的记录用于前端筛选
|
||||
```
|
||||
|
||||
无需新增后端接口。通过 `process_step_status` 和 `exam_status` 字段在前端做状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 6. 筛选与分页
|
||||
|
||||
### 6.1 状态筛选选项
|
||||
|
||||
| 筛选值 | 匹配条件 |
|
||||
|--------|----------|
|
||||
| `all` | 显示全部 |
|
||||
| `processing` | 向量化中 OR 出题中 |
|
||||
| `success` | 向量化成功 AND (未出题 OR 出题成功) |
|
||||
| `failed` | 向量化失败 OR 出题失败 |
|
||||
| `pending` | UPLOADED 或 UNGENERATED 且无进行中的步骤 |
|
||||
|
||||
### 6.2 分页
|
||||
|
||||
前端分页,与现有 `paginatedDocuments` 模式一致。
|
||||
@@ -0,0 +1,875 @@
|
||||
# AI对话链接跳转与引用高亮优化 - 技术设计文档
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建日期**: 2026-05-30
|
||||
**状态**: ✅ 已批准,待实施
|
||||
**方案选择**: 方案B - 统一搜索引擎重构
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景与问题定义
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
在AI智能问答模块(QAModule.vue)中,用户点击对话消息中的引用来源或[ref:xxx]标签后:
|
||||
|
||||
1. **PDF文件**:只能跳转到指定页码,**无法高亮显示关键词位置**
|
||||
2. **Word/Excel等文件**:使用`indexOf精确匹配`,关键词截断后容易匹配失败
|
||||
3. **时序问题**:文件DOM未完全渲染就执行搜索,导致定位失败
|
||||
4. **用户体验差**:无搜索结果反馈、无导航控件、高亮效果短暂
|
||||
|
||||
### 1.2 业务影响
|
||||
|
||||
- ❌ 用户无法快速定位到引用的具体内容位置
|
||||
- ❌ 需要手动翻页查找,效率低下
|
||||
- ❌ 多个匹配项时无法逐个浏览
|
||||
- ❌ 降低知识管理系统的易用性和专业性
|
||||
|
||||
### 1.3 目标用户
|
||||
|
||||
- 企业员工(制度文件查阅者)
|
||||
- HR/行政人员(制度发布者)
|
||||
- 管理员(系统维护者)
|
||||
|
||||
### 1.4 成功标准
|
||||
|
||||
✅ 点击任意格式的引用 → 自动跳转 + 持续高亮显示
|
||||
✅ 支持模糊匹配(容错率≥90%)
|
||||
✅ 提供上/下一处导航功能
|
||||
✅ 高亮效果美观且持久(手动关闭前不消失)
|
||||
✅ 响应时间 < 2秒
|
||||
|
||||
---
|
||||
|
||||
## 2. 解决方案架构
|
||||
|
||||
### 2.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ QAModule.vue │
|
||||
│ [引用点击] → navigateToFileReader(ref) │
|
||||
│ ↓ │
|
||||
│ 提取: { fileId, filename, page, context, rawData } │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
↓ router.push(query参数)
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ReaderPage.vue │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ UniversalSearchEngine (新增) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │ │
|
||||
│ │ │ Text │ │ Fuzzy │ │ Highlight │ │ │
|
||||
│ │ │Extractor│ │ Search │ │ Renderer │ │ │
|
||||
│ │ │ (提取) │ │ Engine │ │ (渲染) │ │ │
|
||||
│ │ └────┬────┘ └────┬────┘ └─────┬──────┘ │ │
|
||||
│ │ └──────────┼──────────┘ │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ SearchCoordinator │ │ │
|
||||
│ │ │ (流程协调器) │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ └───────────────┼───────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ SearchResult[] + UI Navigation Bar │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心技术选型
|
||||
|
||||
| 组件 | 技术方案 | 版本 | 选型理由 |
|
||||
|------|---------|------|---------|
|
||||
| **模糊搜索引擎** | Fuse.js | 7.0.0 | 轻量(10KB)、支持中文、零依赖 |
|
||||
| **文本提取** | DOM TreeWalker API | 原生 | 无需依赖、浏览器原生支持 |
|
||||
| **高亮渲染** | Range + Custom Elements | 原生 | 精确控制、性能优秀 |
|
||||
| **PDF处理** | PDF.js textLayer | 已集成 | 复用现有pdfjs-viewer |
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块详细设计
|
||||
|
||||
### 3.1 模块一:TextExtractor(文本提取器)
|
||||
|
||||
**职责**:从不同格式文件的DOM中提取结构化文本数据
|
||||
|
||||
**文件路径**:`src/utils/textExtractor.js`
|
||||
|
||||
**核心方法**:
|
||||
```typescript
|
||||
class TextExtractor {
|
||||
// 主入口:根据fileType分发到不同的提取策略
|
||||
async extract(fileType: string, container: HTMLElement): Promise<Document[]>
|
||||
|
||||
// PDF文本提取(从iframe的.textLayer提取)
|
||||
async extractPdfText(iframe: HTMLIFrameElement): Promise<PdfPage[]>
|
||||
|
||||
// DOCX/DOC文本提取(从.docx-wrapper提取分页)
|
||||
extractDocxText(container: HTMLElement): DocxPage[]
|
||||
|
||||
// Excel/CSV文本提取(从表格单元格提取)
|
||||
extractExcelText(container: HTMLElement): TableCell[]
|
||||
|
||||
// 通用DOM文本提取(TreeWalker遍历)
|
||||
extractDomText(container: HTMLElement): TextNode[]
|
||||
}
|
||||
```
|
||||
|
||||
**数据结构**:
|
||||
```typescript
|
||||
interface Document {
|
||||
text: string // 纯文本内容
|
||||
type: 'text' | 'page' | 'cell'
|
||||
location?: {
|
||||
page?: number // 页码(PDF/DOCX)
|
||||
cellIndex?: [number, number] // 单元格坐标 [row, col]
|
||||
}
|
||||
element: HTMLElement // 对应的DOM元素引用
|
||||
}
|
||||
```
|
||||
|
||||
**关键实现细节**:
|
||||
- PDF:遍历`.textLayer > span`元素,按`data-page`属性分组
|
||||
- DOCX:查找`.docx-wrapper > div`作为页面容器
|
||||
- Excel:遍历`table tr td`,记录行列索引
|
||||
- 通用:使用`TreeWalker(SHOW_TEXT)`过滤有意义的文本节点(长度≥2)
|
||||
|
||||
---
|
||||
|
||||
### 3.2 模块二:FuzzySearchEngine(模糊搜索引擎)
|
||||
|
||||
**职责**:基于Fuse.js实现智能模糊匹配和相关性排序
|
||||
|
||||
**文件路径**:`src/utils/fuzzySearchEngine.js`
|
||||
|
||||
**核心配置**:
|
||||
```javascript
|
||||
const fuseConfig = {
|
||||
threshold: 0.4, // 匹配阈值(0=精确, 1=宽松)
|
||||
distance: 100, // 模式匹配的最大距离
|
||||
includeScore: true, // 返回评分
|
||||
includeMatches: true, // 返回匹配位置信息
|
||||
minMatchCharLength: 2, // 最小匹配字符数
|
||||
tokenize: true, // 启用分词模式
|
||||
tokenSeparator: /[\s\p{P}]+/u, // 中英文分词正则
|
||||
keys: ['text'] // 搜索字段
|
||||
}
|
||||
```
|
||||
|
||||
**核心方法**:
|
||||
```typescript
|
||||
class FuzzySearchEngine {
|
||||
// 初始化索引(每次加载新文档时调用)
|
||||
initIndex(documents: Document[]): void
|
||||
|
||||
// 执行搜索
|
||||
search(keyword: string, options?: SearchOptions): SearchResult[]
|
||||
|
||||
// 关键词预处理(提升中文匹配率)
|
||||
preprocessKeyword(keyword: string): string
|
||||
|
||||
// 格式化原始结果为统一格式
|
||||
formatResult(rawResult: FuseResult, index: number): SearchResult
|
||||
}
|
||||
```
|
||||
|
||||
**输出数据结构**:
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
id: string // 唯一标识
|
||||
type: 'text' | 'pdf' | 'table'
|
||||
score: number // 相似度评分 (0-1, 越高越匹配)
|
||||
matchedText: string // 匹配到的文本片段
|
||||
context: string // 上下文(前后各50字符)
|
||||
location: {
|
||||
container?: HTMLElement
|
||||
startOffset?: number
|
||||
endOffset?: number
|
||||
pageNumber?: number // PDF专用
|
||||
rect?: { x, y, width, height } // PDF专用
|
||||
cellIndex?: [number, number] // 表格专用
|
||||
}
|
||||
originalData: Document // 原始文档数据(用于高亮渲染)
|
||||
}
|
||||
```
|
||||
|
||||
**中文优化策略**:
|
||||
1. 预处理阶段去除中文标点符号(""''【】《》())
|
||||
2. 使用Unicode属性转义`\p{P}`匹配所有标点
|
||||
3. 分词时按空格和标点切分
|
||||
4. 设置合理的threshold(0.4)平衡精准度和召回率
|
||||
|
||||
---
|
||||
|
||||
### 3.3 模块三:HighlightRenderer(高亮渲染器)
|
||||
|
||||
**职责**:在文档DOM中创建、管理和销毁高亮标记
|
||||
|
||||
**文件路径**:`src/utils/highlightRenderer.js`
|
||||
|
||||
**核心能力**:
|
||||
```typescript
|
||||
class HighlightRenderer {
|
||||
// 渲染所有搜索结果的高亮
|
||||
renderAll(results: SearchResult[]): number
|
||||
|
||||
// 创建单个高亮(根据type分发)
|
||||
private createHighlight(result: SearchResult, index: number): Highlight
|
||||
|
||||
// DOM类型高亮(text/docx/markdown/html等)
|
||||
private createDomHighlight(result, index): DomHighlight
|
||||
|
||||
// PDF类型高亮(在iframe中创建overlay层)
|
||||
private createPdfHighlight(result, index): PdfHighlight
|
||||
|
||||
// 表格类型高亮(Excel/CSV单元格)
|
||||
private createTableHighlight(result, index): TableHighlight
|
||||
|
||||
// 导航控制
|
||||
navigateTo(index: number): void
|
||||
next(): void
|
||||
prev(): void
|
||||
|
||||
// 清除所有高亮
|
||||
clearAll(): void
|
||||
}
|
||||
```
|
||||
|
||||
**高亮样式规范**:
|
||||
|
||||
#### DOM高亮(.search-highlight)
|
||||
```css
|
||||
.search-highlight {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffe69c 100%);
|
||||
border-bottom: 2px solid #ffc107;
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
box-shadow: 0 1px 3px rgba(255, 193, 7, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-highlight.active {
|
||||
background: linear-gradient(135deg, #ffd43b 0%, #fab005 100%);
|
||||
border-bottom: 3px solid #f59f00;
|
||||
animation: highlight-glow 2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
#### PDF高亮(.pdf-highlight-overlay)
|
||||
- 使用绝对定位的div覆盖在文本上方
|
||||
- 背景色:`rgba(255, 235, 59, 0.3)`
|
||||
- 边框:`2px solid #ffc107`
|
||||
- 包含角标显示序号
|
||||
|
||||
#### 表格高亮(.table-highlight)
|
||||
- 绿色系配色(区别于文本黄色)
|
||||
- 背景:`linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%)`
|
||||
- 边框:`2px solid #28a745`
|
||||
|
||||
**交互特性**:
|
||||
- ✅ 点击高亮标记可跳转到该位置
|
||||
- ✅ 当前激活项带脉冲发光动画
|
||||
- ✅ 序号标签(1, 2, 3...)便于识别
|
||||
- ✅ hover效果(轻微放大+阴影加深)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 模块四:SearchCoordinator(协调控制器)
|
||||
|
||||
**职责**:编排整个搜索→定位→高亮的完整流程,处理异常和重试
|
||||
|
||||
**文件路径**:`src/utils/searchCoordinator.js`
|
||||
|
||||
**核心流程**:
|
||||
```
|
||||
execute(params)
|
||||
↓
|
||||
Step 1: 文本提取 (withRetry, 最多重试3次)
|
||||
↓
|
||||
Step 2: 页码过滤(如果指定了targetPage)
|
||||
↓
|
||||
Step 3: 初始化Fuse.js索引 → 执行搜索
|
||||
↓
|
||||
Step 4: 渲染高亮 → 导航到第一个结果
|
||||
↓
|
||||
返回: { success, resultCount, stats }
|
||||
```
|
||||
|
||||
**重试机制**:
|
||||
```typescript
|
||||
async withRetry<T>(fn: () => T, maxRetries: number, delayMs: number): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (i < maxRetries - 1) {
|
||||
await sleep(delayMs) // 等待DOM渲染完成
|
||||
} else {
|
||||
throw error // 最后一次重试失败则抛出异常
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误处理**:
|
||||
- 文本提取失败 → 返回友好提示"无法提取文档内容"
|
||||
- 未找到匹配 → 返回"未找到XXX相关内容"
|
||||
- 高亮渲染部分失败 → 记录警告日志,继续渲染其他结果
|
||||
- 全局异常 → 显示错误提示,不阻断用户操作
|
||||
|
||||
**性能统计**:
|
||||
```typescript
|
||||
interface SearchStats {
|
||||
totalTime: number // 总耗时(ms)
|
||||
extractionTime: number // 文本提取耗时
|
||||
searchTime: number // 搜索耗时
|
||||
renderTime: number // 高亮渲染耗时
|
||||
resultCount: number // 结果数量
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 集成方案
|
||||
|
||||
### 4.1 QAModule.vue修改点
|
||||
|
||||
**文件路径**:`src/components/QAModule.vue`
|
||||
|
||||
**修改函数**:`navigateToFileReader()` (第1126行)
|
||||
|
||||
**改动内容**:
|
||||
```javascript
|
||||
// 优化关键词提取逻辑(第1159-1175行)
|
||||
const keyword = ''
|
||||
|
||||
// 优先级1: ref.context(引用上下文)
|
||||
if (ref.context) {
|
||||
keyword = ref.context.substring(0, 150) // 增加长度限制至150字符
|
||||
}
|
||||
// 优先级2: ref.rawData中的多个字段
|
||||
else if (ref.rawData) {
|
||||
const raw = ref.rawData
|
||||
keyword = raw.content || raw.context || raw.preview ||
|
||||
raw.excerpt || raw.query || raw.question || ''
|
||||
if (keyword) keyword = keyword.substring(0, 150)
|
||||
}
|
||||
// 优先级3: ref.location中的文本描述
|
||||
if (!keyword && ref.location && ref.location.length > 4) {
|
||||
const locText = ref.location.replace(/第\d+页|page\s*\d+/gi, '').trim()
|
||||
if (locText.length >= 4) keyword = locText.substring(0, 120)
|
||||
}
|
||||
|
||||
// 清洗关键词
|
||||
if (keyword) {
|
||||
keyword = cleanSearchKeyword(keyword)
|
||||
if (keyword.length < 4) keyword = '' // 最小长度要求降至4字符
|
||||
}
|
||||
```
|
||||
|
||||
**改动理由**:
|
||||
- 增加关键词长度限制(100→150),提高匹配成功率
|
||||
- 扩展rawData字段检查范围
|
||||
- 降低最小长度要求(4字符),适应短文本场景
|
||||
|
||||
---
|
||||
|
||||
### 4.2 ReaderPage.vue集成
|
||||
|
||||
**文件路径**:`src/views/ReaderPage.vue`
|
||||
|
||||
#### 4.2.1 新增imports
|
||||
|
||||
```javascript
|
||||
import searchCoordinator from '@/utils/searchCoordinator'
|
||||
|
||||
// 新增响应式变量
|
||||
const showNavigationControls = ref(false)
|
||||
const searchResultCount = ref(0)
|
||||
const currentHighlightIndex = ref(0)
|
||||
const searchKeywordPreview = ref('')
|
||||
```
|
||||
|
||||
#### 4.2.2 修改onMounted逻辑
|
||||
|
||||
```javascript
|
||||
onMounted(async () => {
|
||||
// ... 现有代码保持不变 ...
|
||||
|
||||
if (fileId) {
|
||||
await loadFile(fileId, fileTitle, fileExtension, page)
|
||||
|
||||
// ✨ 新增:自动执行搜索高亮
|
||||
if (keyword?.trim()) {
|
||||
const renderDelays = {
|
||||
pdf: 1000,
|
||||
docx: 1500,
|
||||
pptx: 1200,
|
||||
xlsx: 800,
|
||||
default: 500
|
||||
}
|
||||
|
||||
const delay = renderDelays[fileType.value] || renderDelays.default
|
||||
|
||||
setTimeout(async () => {
|
||||
const container = scrollContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
const result = await searchCoordinator.execute({
|
||||
keyword,
|
||||
fileType: fileType.value,
|
||||
container,
|
||||
targetPage: page
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
showNavigationControls.value = true
|
||||
searchResultCount.value = result.resultCount
|
||||
searchKeywordPreview.value = keyword.substring(0, 20) + '...'
|
||||
|
||||
message.success({
|
||||
content: `找到 ${result.resultCount} 处匹配内容`,
|
||||
duration: 3
|
||||
})
|
||||
} else {
|
||||
message.warning({
|
||||
content: result.message || '未找到相关内容',
|
||||
duration: 3
|
||||
})
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 4.2.3 新增UI模板(搜索导航栏)
|
||||
|
||||
```html
|
||||
<!-- 在reader-container内部、底部添加 -->
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showNavigationControls" class="search-nav-bar">
|
||||
<div class="nav-info">
|
||||
<SearchOutlined class="nav-icon" />
|
||||
<span>找到 <strong>{{ searchResultCount }}</strong> 处匹配</span>
|
||||
<span class="keyword-preview">"{{ searchKeywordPreview }}"</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-actions">
|
||||
<button
|
||||
class="nav-btn"
|
||||
@click="prevHighlight"
|
||||
:disabled="currentHighlightIndex <= 0"
|
||||
>
|
||||
<UpOutlined /> 上一处
|
||||
</button>
|
||||
|
||||
<span class="nav-counter">
|
||||
{{ currentHighlightIndex + 1 }} / {{ searchResultCount }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="nav-btn"
|
||||
@click="nextHighlight"
|
||||
:disabled="currentHighlightIndex >= searchResultCount - 1"
|
||||
>
|
||||
下一处 <DownOutlined />
|
||||
</button>
|
||||
|
||||
<button class="nav-btn close-btn" @click="closeSearch">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
```
|
||||
|
||||
#### 4.2.4 新增事件监听
|
||||
|
||||
```javascript
|
||||
// 监听高亮导航事件(由HighlightRenderer触发)
|
||||
onMounted(() => {
|
||||
window.addEventListener('highlightNavigate', (e) => {
|
||||
const { currentIndex, total } = e.detail
|
||||
currentHighlightIndex.value = currentIndex
|
||||
searchResultCount.value = total
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('highlightNavigate')
|
||||
searchCoordinator.clearAllHighlights() // 清理高亮
|
||||
})
|
||||
```
|
||||
|
||||
#### 4.2.5 新增方法
|
||||
|
||||
```javascript
|
||||
// 导航控制方法
|
||||
const nextHighlight = () => {
|
||||
searchCoordinator.nextHighlight()
|
||||
}
|
||||
|
||||
const prevHighlight = () => {
|
||||
searchCoordinator.prevHighlight()
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
searchCoordinator.clearAllHighlights()
|
||||
showNavigationControls.value = false
|
||||
currentHighlightIndex.value = 0
|
||||
searchResultCount.value = 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CSS样式规范
|
||||
|
||||
### 5.1 高亮基础样式
|
||||
|
||||
已在"3.3 HighlightRenderer"章节详细定义,此处补充动画和导航栏样式。
|
||||
|
||||
### 5.2 动画效果
|
||||
|
||||
```css
|
||||
/* 脉冲发光动画(当前激活项) */
|
||||
@keyframes highlight-glow {
|
||||
0%, 100% { box-shadow: 0 3px 8px rgba(245, 159, 0, 0.6); }
|
||||
50% { box-shadow: 0 4px 16px rgba(245, 159, 0, 0.9), 0 0 20px rgba(245, 159, 0, 0.4); }
|
||||
}
|
||||
|
||||
/* 缩放脉冲(首次出现) */
|
||||
@keyframes highlight-pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 搜索导航栏
|
||||
|
||||
固定在阅读器底部中央,包含:
|
||||
- 左侧:搜索图标 + 匹配数量 + 关键词预览
|
||||
- 中间:当前位置计数器(如 "2 / 5")
|
||||
- 右侧:上一处 / 下一处 / 关闭按钮
|
||||
|
||||
详见设计文档第3.3节的CSS代码。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据流与时序
|
||||
|
||||
### 6.1 完整交互时序图
|
||||
|
||||
```
|
||||
用户点击引用来源组件
|
||||
↓
|
||||
[QAModule.vue] navigateToFileReader(ref)
|
||||
├─ 提取 filename, page, context/rawData
|
||||
├─ 清洗生成 keyword (≤150字符)
|
||||
└─ router.push({ query: { id, title, extension, page, keyword, from: 'qa' } })
|
||||
↓
|
||||
[Vue Router] 路由跳转到 /reader
|
||||
↓
|
||||
[ReaderPage.vue] onMounted()
|
||||
├─ 解析路由参数 (id, page, keyword...)
|
||||
├─ loadFile(id, title, extension, page)
|
||||
│ ├─ 调用API获取文件Blob
|
||||
│ ├─ 根据fileType渲染文档 (PDF/Word/Excel/...)
|
||||
│ └─ hasFileLoaded = true
|
||||
│
|
||||
└─ setTimeout(delay) ← 等待DOM渲染完成
|
||||
↓
|
||||
[searchCoordinator.execute()]
|
||||
│
|
||||
├─ Step 1: textExtractor.extract(fileType, container)
|
||||
│ ├─ withRetry(fn, 3, 200ms) ← 重试机制
|
||||
│ └─ 返回 documents[]
|
||||
│
|
||||
├─ Step 2: 过滤目标页码(可选)
|
||||
│
|
||||
├─ Step 3: fuzzySearchEngine
|
||||
│ ├─ initIndex(documents)
|
||||
│ ├─ preprocessKeyword(keyword)
|
||||
│ └─ search() → results[]
|
||||
│
|
||||
├─ Step 4: highlightRenderer.renderAll(results)
|
||||
│ ├─ 遍历results创建高亮mark/overlay
|
||||
│ ├─ navigateTo(0) ← 跳转到第一个结果
|
||||
│ └─ 返回 highlightCount
|
||||
│
|
||||
└─ 返回 { success, resultCount, stats }
|
||||
↓
|
||||
更新UI状态:
|
||||
├─ showNavigationControls = true
|
||||
├─ searchResultCount = N
|
||||
└─ 显示成功提示Toast
|
||||
```
|
||||
|
||||
### 6.2 时间估算
|
||||
|
||||
| 步骤 | 耗时 | 说明 |
|
||||
|------|------|------|
|
||||
| 文本提取 | 50-200ms | 取决于文档大小 |
|
||||
| Fuse.js索引构建 | 10-50ms | 取决于文本块数量 |
|
||||
| 搜索执行 | 5-20ms | Fuse.js高效算法 |
|
||||
| 高亮渲染 | 30-100ms | DOM操作 |
|
||||
| **总计** | **95-370ms** | **< 400ms,用户体验流畅** |
|
||||
|
||||
加上等待DOM渲染的delay(500-1500ms),总响应时间约**0.6-2秒**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 边界情况处理
|
||||
|
||||
### 7.1 异常场景
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| **关键词为空** | 不执行搜索,仅跳转到指定页码 |
|
||||
| **文档内容为空** | 提示"无法提取文档内容",仍完成页面跳转 |
|
||||
| **无匹配结果** | 提示"未找到XXX相关内容",保持在目标页面 |
|
||||
| **部分高亮失败** | 记录警告日志,成功渲染的其他高亮正常显示 |
|
||||
| **PDF iframe跨域受限** | 降级为仅页码跳转,提示"PDF高亮暂不可用" |
|
||||
| **大文档(>10MB)** | 文本提取限流,只提取前1000个文本块 |
|
||||
| **用户快速连续点击** | 防抖处理(300ms),取消上一次搜索 |
|
||||
| **网络请求失败** | 显示错误提示,保留已渲染的高亮 |
|
||||
|
||||
### 7.2 兼容性保障
|
||||
|
||||
- **浏览器兼容**:Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
|
||||
- **Vue版本**:Vue 3.2+ (Composition API)
|
||||
- **移动端适配**:导航栏响应式布局,触摸友好的按钮尺寸
|
||||
- **无障碍访问**:高亮元素添加aria-label,键盘导航支持(Tab/Enter)
|
||||
|
||||
---
|
||||
|
||||
## 8. 性能优化策略
|
||||
|
||||
### 8.1 文本提取优化
|
||||
|
||||
- **懒加载**:只在需要搜索时才提取文本(非页面加载时)
|
||||
- **分批处理**:大文档分段提取,避免阻塞主线程
|
||||
- **缓存机制**:相同文档不重复提取(基于fileId + hash缓存)
|
||||
|
||||
### 8.2 搜索引擎优化
|
||||
|
||||
- **增量更新**:文档内容变化时只更新受影响的索引条目
|
||||
- **结果限制**:默认返回Top 20结果,避免过多DOM操作
|
||||
- **Web Worker**:(可选)将Fuse.js搜索移至Worker线程(针对超大文档)
|
||||
|
||||
### 8.3 高亮渲染优化
|
||||
|
||||
- **虚拟滚动**:只渲染可视区域内的高亮(针对超多匹配项)
|
||||
- **批量DOM操作**:使用DocumentFragment减少reflow
|
||||
- **防抖清除**:快速切换时延迟清理旧高亮
|
||||
|
||||
### 8.4 内存管理
|
||||
|
||||
- **及时清理**:离开页面时调用`clearAllHighlights()`
|
||||
- **引用释放**:断开DOM元素与JavaScript对象的循环引用
|
||||
- **事件解绑**:onUnmounted时移除所有事件监听器
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试计划
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
| 测试模块 | 测试用例数 | 覆盖率目标 |
|
||||
|---------|-----------|-----------|
|
||||
| TextExtractor | 15 | ≥90% |
|
||||
| FuzzySearchEngine | 20 | ≥95% |
|
||||
| HighlightRenderer | 25 | ≥85% |
|
||||
| SearchCoordinator | 18 | ≥90% |
|
||||
|
||||
**关键测试场景**:
|
||||
- PDF文本提取(含多页、空页、特殊字符)
|
||||
- 中文模糊匹配(同义词、错别字、截断关键词)
|
||||
- DOM高亮创建/销毁(内存泄漏检测)
|
||||
- 重试机制验证(模拟DOM未就绪)
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- **E2E测试**:使用Cypress自动化测试完整流程
|
||||
1. 打开AI对话页面
|
||||
2. 发送问题获得带引用的回答
|
||||
3. 点击引用来源
|
||||
4. 验证:页面跳转 + 高亮显示 + 导航栏出现
|
||||
5. 点击"下一处",验证高亮切换
|
||||
|
||||
- **兼容性测试**:BrowserStack云测试平台
|
||||
- Chrome/Firefox/Safari/Edge 最新3个版本
|
||||
- Windows/macOS/Linux 桌面端
|
||||
- iOS/Android 移动端(可选)
|
||||
|
||||
### 9.3 性能测试
|
||||
|
||||
- **加载性能**:Lighthouse Performance Score ≥ 90
|
||||
- **搜索响应时间**:P99 < 2秒
|
||||
- **内存占用**:高亮渲染后内存增长 < 50MB
|
||||
- **CPU占用**:搜索过程中CPU峰值 < 60%
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施路线图
|
||||
|
||||
### Phase 1:核心引擎开发(Day 1-2)
|
||||
|
||||
**Day 1上午**:
|
||||
- [ ] 安装依赖:`npm install fuse.js@7.0.0`
|
||||
- [ ] 创建`src/utils/textExtractor.js`
|
||||
- [ ] 实现PDF/DOCX/Excel/DOM文本提取方法
|
||||
- [ ] 编写单元测试(TextExtractor)
|
||||
|
||||
**Day 1下午**:
|
||||
- [ ] 创建`src/utils/fuzzySearchEngine.js`
|
||||
- [ ] 配置Fuse.js中文优化参数
|
||||
- [ ] 实现`preprocessKeyword`和`search`方法
|
||||
- [ ] 编写单元测试(FuzzySearchEngine)
|
||||
|
||||
**Day 2上午**:
|
||||
- [ ] 创建`src/utils/highlightRenderer.js`
|
||||
- [ ] 实现DOM/PDF/表格三种高亮类型
|
||||
- [ ] 添加导航控制逻辑
|
||||
- [ ] 编写单元测试(HighlightRenderer)
|
||||
|
||||
**Day 2下午**:
|
||||
- [ ] 创建`src/utils/searchCoordinator.js`
|
||||
- [ ] 实现编排逻辑和重试机制
|
||||
- [ ] 集成测试(4个模块联调)
|
||||
|
||||
### Phase 2:UI集成与调试(Day 3)
|
||||
|
||||
**Day 3上午**:
|
||||
- [ ] 修改`QAModule.vue`的`navigateToFileReader`
|
||||
- [ ] 修改`ReaderPage.vue`的`onMounted`
|
||||
- [ ] 添加搜索导航栏UI模板
|
||||
- [ ] 编写CSS样式(高亮+导航栏+动画)
|
||||
|
||||
**Day 3下午**:
|
||||
- [ ] 本地开发环境调试
|
||||
- [ ] 测试PDF高亮功能(重点)
|
||||
- [ ] 测试Word/Excel/TXT等多种格式
|
||||
- [ ] 修复发现的bug
|
||||
|
||||
### Phase 3:打磨与优化(Day 4)
|
||||
|
||||
**Day 4上午**:
|
||||
- [ ] 错误处理完善(边界情况)
|
||||
- [ ] 性能优化(内存/CPU)
|
||||
- [ ] 用户反馈收集(内部测试)
|
||||
|
||||
**Day 4下午**:
|
||||
- [ ] 代码审查和重构
|
||||
- [ ] 文档编写(使用指南)
|
||||
- [ ] 准备发布
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险评估与应对
|
||||
|
||||
### 11.1 技术风险
|
||||
|
||||
| 风险项 | 概率 | 影响 | 应对措施 |
|
||||
|--------|------|------|---------|
|
||||
| **PDF iframe跨域限制** | 中 | 高 | 降级方案:仅页码跳转 + Toast提示 |
|
||||
| **Fuse.js中文分词不准** | 低 | 中 | 自定义tokenizer或引入jieba分词 |
|
||||
| **大文档性能问题** | 中 | 中 | 分批处理 + Web Worker + 虚拟滚动 |
|
||||
| **DOM高亮破坏文档结构** | 低 | 高 | 使用Range API + 异常捕获回滚 |
|
||||
|
||||
### 11.2 进度风险
|
||||
|
||||
- **风险**:Phase 2集成调试超出预期
|
||||
- **应对**:预留1天buffer time,优先保证核心功能可用
|
||||
|
||||
---
|
||||
|
||||
## 12. 成功验收标准
|
||||
|
||||
### 功能完整性 ✅
|
||||
|
||||
- [ ] 点击PDF引用 → 跳转页码 + overlay高亮显示
|
||||
- [ ] 点击Word引用 → DOM高亮 + 持续显示
|
||||
- [ ] 点击Excel引用 → 单元格高亮 + 角标序号
|
||||
- [ ] 模糊匹配成功率 ≥ 90%(测试集验证)
|
||||
- [ ] 导航栏正确显示匹配数量和当前位置
|
||||
- [ ] 上/下一处按钮工作正常
|
||||
|
||||
### 性能指标 ⚡
|
||||
|
||||
- [ ] 搜索+高亮总耗时 < 2秒(P99)
|
||||
- [ ] 内存增长 < 50MB(相对于无高亮状态)
|
||||
- [ ] CPU峰值 < 60%(搜索过程中)
|
||||
- [ ] Lighthouse Performance Score ≥ 90
|
||||
|
||||
### 用户体验 😊
|
||||
|
||||
- [ ] 高亮视觉效果醒目但不刺眼
|
||||
- [ ] 动画流畅(60fps)
|
||||
- [ ] 错误提示友好清晰
|
||||
- [ ] 移动端触摸操作顺畅
|
||||
- [ ] 键盘可访问(Tab/Enter导航)
|
||||
|
||||
### 代码质量 🔧
|
||||
|
||||
- [ ] 单元测试覆盖率 ≥ 85%
|
||||
- [ ] 无console警告(生产环境)
|
||||
- [ ] ESLint检查通过(0 error, 0 warning)
|
||||
- [ ] 代码注释完整(JSDoc标准)
|
||||
|
||||
---
|
||||
|
||||
## 13. 后续迭代方向(可选)
|
||||
|
||||
### Phase 4:增强功能(v2.0)
|
||||
|
||||
- [ ] **多关键词同时高亮**:支持AND/OR逻辑组合
|
||||
- [ ] **高亮导出**:将高亮标注导出为PDF注释
|
||||
- [ ] **历史记录**:保存用户的搜索历史和高亮偏好
|
||||
- [ ] **快捷键支持**:Ctrl+F唤起搜索框,F3跳转下一个
|
||||
- [ ] **AI语义搜索**:升级为向量相似度匹配(需后端支持)
|
||||
|
||||
### Phase 5:平台扩展(v3.0)
|
||||
|
||||
- [ ] **Web Worker迁移**:将搜索引擎移至Worker线程
|
||||
- [ ] **IndexedDB缓存**:离线缓存文本提取结果
|
||||
- [ ] **PWA支持**:离线模式下仍可使用基本搜索功能
|
||||
- [ ] **插件机制**:允许第三方开发者自定义高亮样式
|
||||
|
||||
---
|
||||
|
||||
## 附录A:关键技术参考
|
||||
|
||||
### A.1 Fuse.js官方文档
|
||||
https://fusejs.io/
|
||||
|
||||
### A.2 Range API(MDN)
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Range
|
||||
|
||||
### A.3 TreeWalker API(MDN)
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
|
||||
|
||||
### A.4 PDF.js textLayer
|
||||
https://github.com/nickmoss/pdfjs-viewer-textlayer
|
||||
|
||||
---
|
||||
|
||||
## 附录B:术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| **RAG** | Retrieval-Augmented Generation(检索增强生成) |
|
||||
| **SSE** | Server-Sent Events(服务器推送事件) |
|
||||
| **Fuse.js** | 轻量级模糊搜索库 |
|
||||
| **TreeWalker** | DOM树遍历API |
|
||||
| **Range** | DOM范围选择API |
|
||||
| **Overlay** | 覆盖层(用于PDF高亮) |
|
||||
|
||||
---
|
||||
|
||||
**文档维护者**:AI Code Assistant
|
||||
**最后更新**:2026-05-30
|
||||
**下次评审日期**:实施完成后3天内
|
||||
@@ -0,0 +1,717 @@
|
||||
# 文件上传与任务状态管理优化设计文档
|
||||
|
||||
**方案选择**:A - 渐进式增强(用户体验优先)
|
||||
**版本**:V1.0
|
||||
**日期**:2026-05-30
|
||||
**状态**:待审核
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计概述
|
||||
|
||||
### 1.1 项目背景
|
||||
|
||||
基于《文件管理接口文档》(V2.12.0)规范,对现有文件上传功能和文件列表下的任务向量化/出题状态组件进行系统性用户体验优化。
|
||||
|
||||
### 1.2 优化目标
|
||||
|
||||
- **主要目标**:提升用户交互体验,增强功能易用性和反馈机制
|
||||
- **次要目标**:改善性能表现,优化错误处理流程
|
||||
- **非目标**:不进行架构重构,不改变核心数据流,不引入新技术栈
|
||||
|
||||
### 1.3 设计原则
|
||||
|
||||
1. **KISS原则**:保持简单,避免过度工程化
|
||||
2. **增量改进**:每个优化点独立可回滚
|
||||
3. **向后兼容**:不破坏现有API和组件接口
|
||||
4. **用户驱动**:所有改进围绕实际使用场景
|
||||
5. **渐进增强**:在现有代码基础上添加功能,不重写
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前实现分析
|
||||
|
||||
### 2.1 现有架构
|
||||
|
||||
```
|
||||
前端组件结构:
|
||||
├── FileSelector.vue # 文件选择器(用于AI对话中选择文件)
|
||||
├── exam/
|
||||
│ ├── UploadTaskItem.vue # 任务状态展示卡片
|
||||
│ ├── composables/
|
||||
│ │ ├── useUploadTasks.js # 任务状态管理逻辑
|
||||
│ │ └── useTaskManager.js # 任务管理器
|
||||
│ ├── GeneratePanel.vue # 向量化/出题面板
|
||||
│ └── ExamModuleContainer.vue
|
||||
├── api/
|
||||
│ └── file.js # 文件API接口定义
|
||||
└── views/
|
||||
└── ReaderPage.vue # 文档阅读页面
|
||||
```
|
||||
|
||||
### 2.2 已识别问题清单
|
||||
|
||||
#### **P0 - 必须修复(影响核心功能)**
|
||||
|
||||
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|
||||
|--------|---------|----------|----------|
|
||||
| P0-01 | 无上传进度条显示 | 文件上传体验 | 🔴 高 |
|
||||
| P0-02 | 错误状态无重试机制 | 任务失败处理 | 🔴 高 |
|
||||
| P0-03 | 轮询策略固定(5秒间隔) | 性能浪费 | 🟡 中 |
|
||||
|
||||
#### **P1 - 应该改进(显著提升体验)**
|
||||
|
||||
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|
||||
|--------|---------|----------|----------|
|
||||
| P1-01 | 不支持拖拽上传 | 上传便捷性 | 🟡 中 |
|
||||
| P1-02 | 无前端文件验证 | 错误预防 | 🟡 中 |
|
||||
| P1-03 | 错误提示不够友好 | 用户理解 | 🟡 中 |
|
||||
| P1-04 | 不支持批量操作 | 效率提升 | 🟢 低 |
|
||||
| P1-05 | 移动端适配不完善 | 多设备支持 | 🟢 低 |
|
||||
|
||||
#### **P2 - 可以优化(锦上添花)**
|
||||
|
||||
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|
||||
|--------|---------|----------|----------|
|
||||
| P2-01 | 无状态变更通知提醒 | 用户感知 | 🟢 低 |
|
||||
| P2-02 | 缺少加载骨架屏 | 视觉体验 | 🟢 低 |
|
||||
| P2-03 | 任务列表无虚拟滚动 | 大列表性能 | 🟢 低 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计方案
|
||||
|
||||
### 3.1 模块一:文件上传增强(优先级:🔴 最高)
|
||||
|
||||
#### **3.1.1 功能需求**
|
||||
|
||||
##### **FR-01: 上传进度条**
|
||||
- **描述**:显示实时上传百分比和预计剩余时间
|
||||
- **触发条件**:文件大小 > 1MB 或上传时间 > 2秒时自动显示
|
||||
- **UI设计**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📄 制度管理办法.docx (2.3MB) │
|
||||
│ ████████████░░░░░░ 65% 剩余 3秒 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
##### **FR-02: 拖拽上传支持**
|
||||
- **描述**:支持将文件从桌面/资源管理器拖拽到上传区域
|
||||
- **技术方案**:HTML5 Drag and Drop API
|
||||
- **交互细节**:
|
||||
- 拖拽悬停时:边框高亮 + 提示文字"释放以上传"
|
||||
- 拖拽离开时:恢复原状
|
||||
- 支持多文件同时拖拽
|
||||
|
||||
##### **FR-03: 前端文件验证**
|
||||
- **验证规则**:
|
||||
- ✅ 支持的文件类型:`.docx, .pdf, .txt, .md, .xlsx, .pptx`
|
||||
- ✅ 最大文件大小:50MB(可配置)
|
||||
- ✅ 文件名长度:≤200字符
|
||||
- ✅ 特殊字符检查(禁止 `\ / : * ? " < > |`)
|
||||
- **错误提示**:
|
||||
- 类型不支持:"❌ 不支持的文件格式,请上传 .docx/.pdf/.txt 等文档"
|
||||
- 文件过大:"❌ 文件超过50MB限制,请压缩后重试"
|
||||
- 其他错误:"⚠️ 文件验证失败:{具体原因}"
|
||||
|
||||
##### **FR-04: 上传前预览**
|
||||
- **预览信息**:
|
||||
- 文件图标(根据扩展名显示不同图标)
|
||||
- 文件名(高亮显示)
|
||||
- 文件大小(格式化为KB/MB)
|
||||
- 文件类型标签(如"Word文档"、"PDF文件")
|
||||
|
||||
##### **FR-05: 友好的错误处理**
|
||||
- **错误分类**:
|
||||
- 🌐 **网络错误**:连接超时、服务器无响应
|
||||
- 📁 **文件错误**:格式不支持、文件损坏
|
||||
- 🔐 **权限错误**:未登录、无上传权限
|
||||
- ⚙️ **服务器错误**:500内部错误、存储空间不足
|
||||
- **错误展示**:
|
||||
- 图标 + 标题 + 详细说明 + 操作建议
|
||||
- 示例:
|
||||
```
|
||||
⚠️ 上传失败
|
||||
|
||||
原因:服务器响应超时(>60秒)
|
||||
|
||||
建议:
|
||||
• 检查网络连接是否正常
|
||||
• 尝试压缩文件后重新上传
|
||||
• 如持续失败,请联系管理员
|
||||
|
||||
[重新上传] [取消]
|
||||
```
|
||||
|
||||
#### **3.1.2 技术实现方案**
|
||||
|
||||
##### **修改文件清单**:
|
||||
|
||||
| 文件路径 | 改动类型 | 改动量 | 说明 |
|
||||
|---------|---------|--------|------|
|
||||
| `src/components/FileSelector.vue` | 修改 | ~150行 | 添加拖拽、验证、进度条 |
|
||||
| `src/api/file.js` | 修改 | ~20行 | 添加onUploadProgress回调 |
|
||||
| `src/utils/fileValidator.js` | 新增 | ~80行 | 文件验证工具函数 |
|
||||
| `src/components/ui/ProgressBar.vue` | 新增 | ~60行 | 可复用进度条组件 |
|
||||
| `src/components/ui/DropZone.vue` | 新增 | ~90行 | 拖拽上传区域组件 |
|
||||
|
||||
##### **核心代码示例**:
|
||||
|
||||
**Axios进度监听** (`src/api/file.js`):
|
||||
```javascript
|
||||
uploadFile: (formData, onProgress) => {
|
||||
return apiClient.post('/file/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress({
|
||||
loaded: progressEvent.loaded,
|
||||
total: progressEvent.total,
|
||||
percent: percentCompleted
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**拖拽上传组件** (`src/components/ui/DropZone.vue`):
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ 'is-dragging': isDragging }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<div v-if="!isDragging" class="drop-zone-content">
|
||||
<InboxOutlined class="drop-icon" />
|
||||
<p class="drop-text">拖拽文件到此处,或<span class="link">点击选择</span></p>
|
||||
<p class="drop-hint">支持 .docx .pdf .txt .xlsx .pptx,最大 50MB</p>
|
||||
</div>
|
||||
<div v-else class="drop-zone-active">
|
||||
<span class="active-text">📥 释放文件以上传</span>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="acceptedTypes"
|
||||
@change="onFileSelected"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { InboxOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
acceptedTypes: {
|
||||
type: String,
|
||||
default: '.docx,.pdf,.txt,.md,.xlsx,.pptx'
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 50 * 1024 * 1024 // 50MB
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['files-selected', 'error'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
function onDragEnter(e) {
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
isDragging.value = false
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
validateAndEmit(files)
|
||||
}
|
||||
|
||||
function onFileSelected(e) {
|
||||
const files = Array.from(e.target.files)
|
||||
validateAndEmit(files)
|
||||
// 重置input以允许重复选择相同文件
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function validateAndEmit(files) {
|
||||
const validFiles = []
|
||||
const errors = []
|
||||
|
||||
files.forEach(file => {
|
||||
// 验证文件类型
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase()
|
||||
if (!props.acceptedTypes.includes(ext)) {
|
||||
errors.push({ file: file.name, reason: '不支持的文件格式' })
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > props.maxSize) {
|
||||
errors.push({ file: file.name, reason: `文件过大 (${formatSize(file.size)},限制${formatSize(props.maxSize)})` })
|
||||
return
|
||||
}
|
||||
|
||||
validFiles.push(file)
|
||||
})
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
emit('files-selected', validFiles)
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
emit('error', errors)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 模块二:任务状态优化(优先级:🟡 高)
|
||||
|
||||
#### **3.2.1 功能需求**
|
||||
|
||||
##### **FR-06: 一键重试按钮**
|
||||
- **触发条件**:任务状态为 error 时显示
|
||||
- **位置**:UploadTaskItem 组件的错误标签旁
|
||||
- **行为**:
|
||||
1. 点击后立即调用重新处理API
|
||||
2. 按钮变为加载状态(转圈图标)
|
||||
3. 成功后自动刷新任务列表
|
||||
4. 失败后显示错误详情
|
||||
- **UI设计**:
|
||||
```
|
||||
❌ 向量化失败 [🔄 重试] [📋 详情]
|
||||
```
|
||||
|
||||
##### **FR-07: 批量删除功能**
|
||||
- **使用场景**:清理大量失败/已完成的历史任务
|
||||
- **交互流程**:
|
||||
1. 进入批量模式(勾选框出现)
|
||||
2. 选择多个任务(支持全选)
|
||||
3. 点击"删除选中项(N)"按钮
|
||||
4. 弹出确认对话框(显示即将删除的任务数)
|
||||
5. 执行删除并显示进度
|
||||
- **权限控制**:仅管理员可见此功能
|
||||
|
||||
##### **FR-08: Toast通知系统**
|
||||
- **触发事件**:
|
||||
- ✅ 上传成功:"✅ 文件「{filename}」上传成功,正在处理中..."
|
||||
- ✅ 向量化完成:"🎉 「{filename}」向量化完成,准备生成题目..."
|
||||
- ✅ 出题完成:"📝 「{filename}」已生成{count}道题目"
|
||||
- ❌ 处理失败:"❌ 「{filename}」处理失败:{原因}"
|
||||
- **配置选项**:
|
||||
- 显示时长:成功=3秒,失败=5秒
|
||||
- 位置:右下角
|
||||
- 可关闭:是
|
||||
- 堆叠方式:垂直堆叠(最多3条同时显示)
|
||||
|
||||
##### **FR-09: 智能轮询策略**
|
||||
- **当前问题**:固定5秒轮询,无论是否有活跃任务
|
||||
- **优化方案**:
|
||||
|
||||
| 任务状态 | 轮询间隔 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 有处理中任务 | 3秒 | 快速反馈 |
|
||||
| 全部完成/失败 | 10秒 | 降低频率 |
|
||||
| 页面不可见 | 30秒 | 节省资源 |
|
||||
| 无任何任务 | 停止轮询 | 完全停止 |
|
||||
|
||||
- **实现方式**:使用 `document.visibilitychange` 事件检测页面可见性
|
||||
|
||||
##### **FR-10: 加载骨架屏**
|
||||
- **应用场景**:
|
||||
1. 首次加载任务列表时
|
||||
2. 刷新任务状态时(>500ms)
|
||||
- **UI效果**:灰色脉冲动画块模拟真实内容布局
|
||||
- **组件复用**:创建通用 SkeletonCard 组件
|
||||
|
||||
#### **3.2.2 技术实现方案**
|
||||
|
||||
##### **修改文件清单**:
|
||||
|
||||
| 文件路径 | 改动类型 | 改动量 | 说明 |
|
||||
|---------|---------|--------|------|
|
||||
| `src/components/exam/UploadTaskItem.vue` | 修改 | ~40行 | 添加重试按钮 |
|
||||
| `src/components/exam/composables/useUploadTasks.js` | 修改 | ~80行 | 智能轮询+批量操作 |
|
||||
| `src/components/ui/SkeletonCard.vue` | 新增 | ~50行 | 骨架屏组件 |
|
||||
| `src/components/ui/ToastNotification.vue` | 新增 | ~90行 | 通知组件 |
|
||||
| `src/utils/notification.js` | 新增 | ~60行 | 通知工具函数 |
|
||||
|
||||
##### **核心代码示例**:
|
||||
|
||||
**智能轮询** (`useUploadTasks.js` 修改部分):
|
||||
```javascript
|
||||
// 新增智能轮询配置
|
||||
const POLL_CONFIG = {
|
||||
active: 3000, // 有活跃任务时:3秒
|
||||
idle: 10000, // 全部空闲时:10秒
|
||||
hidden: 30000, // 页面隐藏时:30秒
|
||||
maxInterval: 30000 // 最大间隔上限
|
||||
}
|
||||
|
||||
let currentInterval = POLL_CONFIG.idle
|
||||
let visibilityHandler = null
|
||||
|
||||
function updatePollingStrategy() {
|
||||
const hasActiveTasks = shouldPoll()
|
||||
const isHidden = document.hidden
|
||||
|
||||
let newInterval
|
||||
if (isHidden) {
|
||||
newInterval = POLL_CONFIG.hidden
|
||||
} else if (hasActiveTasks) {
|
||||
newInterval = POLL_CONFIG.active
|
||||
} else {
|
||||
newInterval = POLL_CONFIG.idle
|
||||
}
|
||||
|
||||
// 仅当间隔变化时才重启定时器
|
||||
if (newInterval !== currentInterval) {
|
||||
currentInterval = newInterval
|
||||
stopPolling()
|
||||
if (tasks.value.length > 0) {
|
||||
startPolling()
|
||||
}
|
||||
console.log(`[useUploadTasks] 轮询间隔调整为 ${currentInterval}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听页面可见性
|
||||
function setupVisibilityListener() {
|
||||
if (visibilityHandler) return
|
||||
|
||||
visibilityHandler = () => {
|
||||
updatePollingStrategy()
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupVisibilityListener()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**一键重试** (`UploadTaskItem.vue` 修改部分):
|
||||
```vue
|
||||
<template>
|
||||
<!-- 在错误标签旁添加重试按钮 -->
|
||||
<span v-if="vectorStatus.status === 'error'" class="uti-tag error">
|
||||
{{ vectorStatus.label }}
|
||||
<button class="retry-btn" @click="handleRetry" :disabled="retrying">
|
||||
<LoadingOutlined v-if="retrying" spin />
|
||||
<ReloadOutlined v-else />
|
||||
重试
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ReloadOutlined, LoadingOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
task: Object,
|
||||
vectorStatus: Object,
|
||||
examStatus: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['retry'])
|
||||
const retrying = ref(false)
|
||||
|
||||
async function handleRetry() {
|
||||
retrying.value = true
|
||||
try {
|
||||
await emit('retry', props.task.id)
|
||||
} finally {
|
||||
setTimeout(() => { retrying.value = false }, 1000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 模块三:移动端适配(优先级:🟢 中)
|
||||
|
||||
#### **3.3.1 适配策略**
|
||||
|
||||
##### **断点定义**:
|
||||
|
||||
| 断点名称 | 宽度范围 | 目标设备 |
|
||||
|---------|---------|---------|
|
||||
| `sm` | ≥640px | 大屏手机横屏/小平板 |
|
||||
| `md` | ≥768px | 平板竖屏 |
|
||||
| `lg` | ≥1024px | 桌面端 |
|
||||
|
||||
##### **关键改动**:
|
||||
|
||||
1. **UploadTaskItem 组件**:
|
||||
- 字体缩小(12px → 11px)
|
||||
- 状态标签换行显示
|
||||
- 错误信息默认展开(无需点击"详情")
|
||||
|
||||
2. **文件列表**:
|
||||
- 卡片布局改为单列
|
||||
- 操作按钮改为底部固定栏
|
||||
- 添加下拉刷新手势支持
|
||||
|
||||
3. **上传区域**:
|
||||
- 拖拽区域全屏宽度
|
||||
- 点击区域增大(最小44px触控目标)
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流与接口变更
|
||||
|
||||
### 4.1 API接口调整
|
||||
|
||||
#### **新增接口**(可选,如后端已支持则使用):
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
|------|------|------|
|
||||
| `/api/file/{id}/retry` | POST | 重试失败任务 |
|
||||
| `/api/file/batch-delete` | POST | 批量删除任务 |
|
||||
|
||||
#### **兼容性处理**:
|
||||
|
||||
如果后端暂不支持上述接口,前端降级方案:
|
||||
- **重试功能**:调用原有上传接口重新上传同一文件
|
||||
- **批量删除**:循环调用单个删除接口(带loading状态)
|
||||
|
||||
### 4.2 数据流图
|
||||
|
||||
```
|
||||
用户操作
|
||||
↓
|
||||
FileSelector.vue(拖拽/选择文件)
|
||||
↓
|
||||
fileValidator.js(前端验证)
|
||||
├─ 通过 → 显示预览 → 开始上传
|
||||
└─ 失败 → 显示友好错误提示
|
||||
↓
|
||||
api/file.js uploadFile(带进度回调)
|
||||
↓
|
||||
ProgressBar.vue(实时更新进度)
|
||||
↓
|
||||
上传完成
|
||||
↓
|
||||
useUploadTasks.js(添加任务到列表)
|
||||
↓
|
||||
智能轮询(3-30秒动态调整)
|
||||
↓
|
||||
UploadTaskItem.vue(展示状态)
|
||||
├─ 处理中 → 动画图标
|
||||
├─ 完成 → 成功图标 + Toast通知
|
||||
└─ 失败 → 错误图标 + 重试按钮
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能与兼容性考虑
|
||||
|
||||
### 5.1 性能优化措施
|
||||
|
||||
1. **按需加载**:新组件使用动态导入
|
||||
```javascript
|
||||
const DropZone = defineAsyncComponent(() => import('./components/ui/DropZone.vue'))
|
||||
```
|
||||
|
||||
2. **防抖/节流**:
|
||||
- 搜索输入:debounce 300ms
|
||||
- 窗口resize:throttle 150ms
|
||||
- 滚动事件:passive listener
|
||||
|
||||
3. **内存管理**:
|
||||
- 轮询定时器在组件卸载时清除
|
||||
- 大文件上传完成后释放引用
|
||||
- 图片/文件预览使用URL.revokeObjectURL()
|
||||
|
||||
### 5.2 浏览器兼容性
|
||||
|
||||
| 功能 | 最低版本 | 降级方案 |
|
||||
|------|---------|---------|
|
||||
| Drag & Drop API | IE10+, 所有现代浏览器 | 回退到点击上传 |
|
||||
| Progress Event | IE10+, 所有现代浏览器 | 不显示进度条 |
|
||||
| Visibility API | IE10+, Chrome 13+ | 固定10秒轮询 |
|
||||
| CSS Grid/Flexbox | IE11+ (partial), 现代浏览器全支持 | 使用float fallback |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试计划
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
| 测试场景 | 输入 | 预期输出 | 优先级 |
|
||||
|---------|------|---------|--------|
|
||||
| 文件类型验证 | `test.exe` | 返回错误"不支持的格式" | P0 |
|
||||
| 文件大小验证 | 60MB文件 | 返回错误"超过50MB限制" | P0 |
|
||||
| 进度计算 | loaded=50, total=100 | percent=50 | P0 |
|
||||
| 拖拽事件处理 | 有效文件 | 触发files-selected事件 | P1 |
|
||||
| 轮询间隔调整 | 页面hidden | 间隔变为30秒 | P1 |
|
||||
| 重试按钮点击 | error状态任务 | 调用retry事件 | P0 |
|
||||
|
||||
### 6.2 集成测试
|
||||
|
||||
1. **上传流程测试**:
|
||||
- 选择文件 → 验证 → 上传 → 进度显示 → 完成
|
||||
- 拖拽文件 → 验证 → 上传 → 进度显示 → 完成
|
||||
- 选择无效文件 → 显示错误 → 修正后重试
|
||||
|
||||
2. **任务状态测试**:
|
||||
- 上传后观察状态流转(UPLOADED→VECTORIZING→VECTORIZED→EXAM_GENERATING→COMPLETED)
|
||||
- 模拟失败场景 → 点击重试 → 验证重试逻辑
|
||||
- 批量选择 → 删除 → 确认删除成功
|
||||
|
||||
3. **边界情况测试**:
|
||||
- 同时上传10个文件
|
||||
- 网络中断后恢复
|
||||
- 页面刷新后状态保持
|
||||
- 移动端触摸操作
|
||||
|
||||
### 6.3 兼容性测试
|
||||
|
||||
- ✅ Chrome 90+
|
||||
- ✅ Firefox 88+
|
||||
- ✅ Safari 14+
|
||||
- ✅ Edge 90+
|
||||
- ⚠️ iOS Safari 14+(移动端)
|
||||
- ⚠️ Android Chrome 90+
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施路线图
|
||||
|
||||
### Phase 1:核心功能(第1天)- 6小时
|
||||
|
||||
**上午(3小时)**:
|
||||
- [ ] 创建 `src/utils/fileValidator.js` 文件验证工具
|
||||
- [ ] 创建 `src/components/ui/ProgressBar.vue` 进度条组件
|
||||
- [ ] 修改 `src/api/file.js` 添加进度回调支持
|
||||
|
||||
**下午(3小时)**:
|
||||
- [ ] 创建 `src/components/ui/DropZone.vue` 拖拽组件
|
||||
- [ ] 修改 `src/components/FileSelector.vue` 集成新功能
|
||||
- [ ] 测试上传流程(正常/异常场景)
|
||||
|
||||
### Phase 2:任务状态优化(第2天)- 6小时
|
||||
|
||||
**上午(3小时)**:
|
||||
- [ ] 修改 `UploadTaskItem.vue` 添加重试按钮
|
||||
- [ ] 修改 `useUploadTasks.js` 实现智能轮询
|
||||
- [ ] 创建 `src/components/ui/ToastNotification.vue`
|
||||
|
||||
**下午(3小时)**:
|
||||
- [ ] 实现批量删除功能
|
||||
- [ ] 创建骨架屏组件
|
||||
- [ ] 测试任务状态流转
|
||||
|
||||
### Phase 3:打磨与测试(第3天)- 6小时
|
||||
|
||||
**上午(3小时)**:
|
||||
- [ ] 移动端响应式适配
|
||||
- [ ] 跨浏览器测试
|
||||
- [ ] 性能 profiling
|
||||
|
||||
**下午(3小时)**:
|
||||
- [ ] 用户体验走查
|
||||
- [ ] Bug修复
|
||||
- [ ] 文档编写
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险评估与缓解
|
||||
|
||||
### 8.1 技术风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|-------|------|---------|
|
||||
| 后端不支持重试API | 中 | 中 | 前端降级为重新上传 |
|
||||
| 大文件上传内存溢出 | 低 | 高 | 使用分片上传(Phase 2考虑) |
|
||||
| 拖拽API兼容性问题 | 低 | 低 | Feature detection + 降级方案 |
|
||||
| 轮询性能开销 | 低 | 中 | 智能间隔 + 页面不可见时暂停 |
|
||||
|
||||
### 8.2 业务风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|-------|------|---------|
|
||||
| 用户不接受新交互 | 低 | 低 | A/B测试 + 快速回滚能力 |
|
||||
| 移动端体验不佳 | 中 | 中 | 充分的设备测试 |
|
||||
| 与现有工作流冲突 | 低 | 中 | 保持向后兼容 + 渐进启用 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 成功指标(KPIs)
|
||||
|
||||
### 9.1 定量指标
|
||||
|
||||
| 指标 | 当前值 | 目标值 | 测量方法 |
|
||||
|------|-------|--------|---------|
|
||||
| 上传操作成功率 | 未知 | ≥98% | 错误日志统计 |
|
||||
| 平均上传等待感知时间 | 未知 | 减少30% | 用户调研 |
|
||||
| 任务状态查询次数/会话 | 固定12次/分钟 | 动态3-30次 | 性能监控 |
|
||||
| 错误重试成功率 | 0% | ≥80% | 重试按钮点击率 |
|
||||
|
||||
### 9.2 定性指标
|
||||
|
||||
- ✅ 用户能够清晰了解上传进度
|
||||
- ✅ 任务失败时有明确的解决路径(重试按钮)
|
||||
- ✅ 移动端操作流畅无明显卡顿
|
||||
- ✅ 错误信息易于理解和行动
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 参考文档
|
||||
|
||||
- [文件管理接口文档 V2.12.0](./文件管理接口文档.md)
|
||||
- Vue 3 官方文档:https://vuejs.org/
|
||||
- Ant Design Vue 组件库:https://antdv.com/
|
||||
- MDN Drag & Drop API:https://developer.mozilla.org/en-US/docs/Web/API/Drag_and_Drop
|
||||
|
||||
### 10.2 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| **向量化(Vectorize)** | 将文档内容转换为向量表示,用于语义搜索 |
|
||||
| **出题(Exam Generation)** | 基于文档内容自动生成考试题目 |
|
||||
| **轮询(Polling)** | 客户端定期向服务器请求最新状态的机制 |
|
||||
| **骨架屏(Skeleton)** | 内容加载时的占位动画效果 |
|
||||
| **Toast通知** | 短暂出现的消息提示,通常位于屏幕角落 |
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
|
||||
*请审核本设计文档,确认后我们将进入实施阶段。*
|
||||
Reference in New Issue
Block a user