45 KiB
考察与训练功能页面组件设计规范重构实施计划
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: 将考察与训练功能页面(ExamModule.vue)从分散的独立设计方式重构为符合项目统一设计规范的标准化组件架构,消除维护问题、提升扩展性、确保一致性。
Architecture: 采用组件拆分 + 设计令牌统一的双轨策略:首先将巨型单体组件(6400+行)拆分为5个功能子组件 + 1个容器组件,然后全面替换硬编码样式值为CSS变量,最后确保所有子组件严格使用通用组件库(ContentCard, DataTable等)而非重复定义样式。
Tech Stack: Vue 3 Composition API, CSS Variables (design-tokens), Ant Design Vue, 通用组件库
问题诊断报告
当前状态分析
📊 ExamModule.vue 规模统计
- 总代码量: ~6500+ 行(估算)
- CSS样式规则: 642 个选择器
- HTML注释区块: 30 个功能分区标记
- 条件渲染逻辑: 34 个 v-if/v-show
- 内联样式: 20+ 处硬编码 style 属性
- 功能面板数: 5 个主面板(generate/bank/paper/train/exam)
- 子标签页: train(3个) + exam(5个) = 8个子功能
🔍 识别的核心问题
问题1: 巨型单体组件违反单一职责原则
现象:
- 一个 .vue 文件包含 5 个完全独立的功能模块
- 每个模块有独立的模板、逻辑、样式
- 文件过大导致编辑器性能下降、代码审查困难
影响:
- ❌ 维护性差:修改一个功能可能影响其他功能
- ❌ 可读性低:开发者需要理解整个文件才能修改小功能
- ❌ 测试困难:无法对单个功能进行单元测试
- ❌ 协作冲突:多人同时修改不同功能时容易产生合并冲突
设计规范要求:
✅ 每个组件应具有单一的明确职责
✅ 组件代码量控制在 500 行以内
✅ 复杂模块应拆分为独立的子组件
问题2: 样式定义严重碎片化且重复
现象:
.exam-card,.card-header,.form-group,.form-label在 9个文件 中重复定义- ExamModule.vue 内部有 8 处重复定义
- DashboardModule.vue 有 5 处,其他模块也有类似情况
具体重复清单:
| 样式类 | ExamModule | DashboardModule | 其他模块 | 总计 |
|---|---|---|---|---|
.exam-card |
✓ | ✓ | 3个文件 | 5处 |
.card-header |
✓ | ✓ | 3个文件 | 5处 |
.form-group |
✓ | - | 4个文件 | 5处 |
.form-label |
✓ | - | 3个文件 | 4处 |
影响:
- ❌ 修改成本高:需要同时修改多个文件的相同样式
- ❌ 一致性风险:容易遗漏某个文件导致样式不统一
- ❌ 代码冗余:大量重复代码增加包体积
设计规范要求:
✅ 使用通用组件 ContentCard 替代自定义 .exam-card
✅ 统一样式定义到全局或组件库层面
✅ 消除跨文件重复样式
问题3: 大量硬编码样式值未使用CSS变量
现象:
-
内联 style 中有 20+ 处硬编码值:
<!-- 错误示例 --> <div style="margin-bottom: 20px; padding: 16px; border-radius: 8px;"> <h3 style="margin-bottom: 16px; font-size: 48px;"> -
CSS 中有 30+ 处硬编码尺寸值:
/* 错误示例 */ padding: 12px 16px; /* 应使用 var(--space-lg) var(--space-xl) */ font-size: 16px; /* 应使用 var(--font-xl) */ border-radius: 8px; /* 应使用 var(--radius-lg) */ margin-bottom: 24px; /* 应使用 var(--space-2xl) */ box-shadow: 0 1px 2px rgba(0,0,0,0.04); /* 应使用 var(--shadow-sm) */
影响:
- ❌ 主题切换困难:无法通过修改变量全局调整样式
- ❌ 一致性差:不同开发者可能使用不同的值
- ❌ 维护成本高:需要逐个查找和替换
设计规范要求:
✅ 所有间距使用 --space-* 变量体系
✅ 所有字体大小使用 --font-* 变量体系
✅ 所有圆角使用 --radius-* 变量体系
✅ 所有阴影使用 --shadow-* 变量体系
✅ 禁止在模板中使用内联 style(极少数例外)
问题4: 通用组件利用率不足
现状:
- 已有通用组件库:
common/ContentCard.vue,common/DataTable.vue - ExamModule 仅使用了 3/5 个通用组件:
- ✅ TabsBar.vue
- ✅ PageHeader.vue
- ✅ StatsRow.vue
- ❌ ContentCard.vue (未使用,而是自己定义 .exam-card)
- ❌ DataTable.vue (未使用,而是直接使用 a-table)
影响:
- ❌ 违反 DRY 原则:重新发明轮子
- ❌ 风格不一致:自定义样式可能与通用组件略有差异
- ❌ 维护负担:需要同时维护两套组件系统
设计规范要求:
✅ 优先使用通用组件库中的组件
✅ 只有当通用组件无法满足需求时才自定义
✅ 自定义组件必须遵循相同的设计规范
问题5: 功能耦合度高,缺乏清晰的接口边界
现象:
- 5 个功能面板共享同一个 data/computed/methods 对象
- 状态管理混乱:例如
activeTab,trainTab,examTab三层嵌套 - 组件间通信依赖 props/emits 不清晰
影响:
- ❌ 扩展困难:添加新功能需要理解所有现有功能的状态
- ❌ 调试困难:状态变化来源难以追踪
- ❌ 复用困难:无法单独提取某个功能到其他页面
设计规范要求:
✅ 每个子组件应有清晰的 props 和 emits 接口
✅ 状态管理应尽量局部化,避免全局污染
✅ 组件间通信应通过明确的 API 进行
维护性问题矩阵
| 问题类型 | 严重程度 | 发生频率 | 影响范围 | 修复难度 |
|---|---|---|---|---|
| 巨型单体组件 | 🔴 高 | 持续 | 全局 | 高 |
| 样式重复定义 | 🔴 高 | 每次样式修改 | 9个文件 | 中 |
| 硬编码样式值 | 🟠 中 | 新增UI时 | 全局 | 低 |
| 通用组件利用率低 | 🟠 中 | 持续 | ExamModule | 中 |
| 功能耦合度高 | 🟡 中 | 功能扩展时 | ExamModule | 高 |
重构方案设计
方案总览
采用渐进式重构策略,分三个阶段实施:
阶段1: 组件拆分(结构优化)
├── Task 1: 创建容器组件 ExamModuleContainer.vue
├── Task 2: 拆分 AI出题面板 → GeneratePanel.vue
├── Task 3: 拆分 题库管理面板 → QuestionBankPanel.vue
├── Task 4: 拆分 智能组卷面板 → PaperManagementPanel.vue
├── Task 5: 拆分 互动训练面板 → TrainingPanel.vue
└── Task 6: 拆分 试卷考核面板 → ExamPanel.vue
阶段2: 样式标准化(视觉统一)
├── Task 7: 全面替换硬编码值为 CSS 变量
├── Task 8: 使用 ContentCard 替换 .exam-card
├── Task 9: 使用 DataTable 替换原始 a-table
└── Task 10: 移除重复样式定义
阶段3: 接口规范化(架构优化)
├── Task 11: 定义清晰的 Props/Emits 接口
├── Task 12: 状态管理本地化
└── Task 13: 编写单元测试和文档
文件结构规划
重构前
src/components/
├── ExamModule.vue # 6500+ 行巨型组件 ❌
├── common/
│ ├── ContentCard.vue # 未充分利用 ❌
│ └── DataTable.vue # 未充分利用 ❌
└── ...
重构后
src/components/
├── exam/ # 考察与训练功能模块目录(新建)
│ ├── ExamModuleContainer.vue # 容器组件(~150行)✅
│ ├── GeneratePanel.vue # AI出题面板(~800行)✅
│ ├── QuestionBankPanel.vue # 题库管理面板(~600行)✅
│ ├── PaperManagementPanel.vue # 智能组卷面板(~800行)✅
│ ├── TrainingPanel.vue # 互动训练面板(~1000行)✅
│ ├── ExamPanel.vue # 试卷考核面板(~1200行)✅
│ └── composables/ # 组合式函数(新建)
│ ├── useGenerateState.js # 出题相关状态管理
│ ├── useQuestionBank.js # 题库相关状态管理
│ ├── usePaperManagement.js # 组卷相关状态管理
│ ├── useTraining.js # 训练相关状态管理
│ └── useExam.js # 考核相关状态管理
├── common/ # 通用组件(保持不变)
│ ├── ContentCard.vue # 广泛使用 ✅
│ └── DataTable.vue # 广泛使用 ✅
└── ExamModule.vue # 保留为向后兼容别名(可选删除)
实施任务详解
Task 1: 创建容器组件 ExamModuleContainer.vue
目标: 作为考察与训练功能的顶层容器,负责标签栏切换和子面板加载
Files:
-
Create:
src/components/exam/ExamModuleContainer.vue -
Reference:
src/components/common/TabsBar.vue -
Reference:
src/design-tokens.css -
Step 1: 创建容器组件基础结构
<template>
<div class="exam-module-container">
<!-- 功能标签栏 -->
<TabsBar
:tabs="moduleTabs"
v-model="activeModule"
/>
<!-- 动态面板加载 -->
<component
:is="currentPanelComponent"
v-bind="currentPanelProps"
@panel-event="handlePanelEvent"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import TabsBar from '../common/TabsBar.vue'
import GeneratePanel from './GeneratePanel.vue'
import QuestionBankPanel from './QuestionBankPanel.vue'
import PaperManagementPanel from './PaperManagementPanel.vue'
import TrainingPanel from './TrainingPanel.vue'
import ExamPanel from './ExamPanel.vue'
const activeModule = ref('generate')
const moduleTabs = [
{ key: 'generate', label: 'AI智能出题', icon: 'robot' },
{ key: 'bank', label: '题库管理', icon: 'clipboard' },
{ key: 'paper', label: '智能组卷', icon: 'target' },
{ key: 'train', label: '互动训练', icon: 'edit' },
{ key: 'exam', label: '试卷考核', icon: 'file-text' }
]
const panelComponents = {
generate: GeneratePanel,
bank: QuestionBankPanel,
paper: PaperManagementPanel,
train: TrainingPanel,
exam: ExamPanel
}
const currentPanelComponent = computed(() => panelComponents[activeModule.value])
const currentPanelProps = computed(() => ({
// 可根据需要传递共享的props
}))
const handlePanelEvent = (event) => {
console.log('Panel event:', event)
}
</script>
<style scoped>
.exam-module-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-page);
}
</style>
- Step 2: 验证容器组件基本功能
运行: npm run dev
Expected: 页面正常渲染,标签栏可切换,无控制台错误
- Step 3: 提交容器组件
git add src/components/exam/ExamModuleContainer.vue
git commit -m "feat(exam): create container component for exam module"
Task 2: 拆分 AI出题面板 GeneratePanel.vue
目标: 从 ExamModule.vue 提取 generate 面板的所有模板、逻辑、样式
Files:
-
Create:
src/components/exam/GeneratePanel.vue -
Create:
src/components/exam/composables/useGenerateState.js -
Modify:
src/components/ExamModule.vue(移除 generate 相关代码) -
Step 1: 提取出题相关的状态管理逻辑
// src/components/exam/composables/useGenerateState.js
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import { fileAPI } from '../../api/file.js'
import { questionAPI } from '../../api/question.js'
export function useGenerateState() {
const selectedDoc = ref(null)
const selectedDocData = ref(null)
const selectedCollectionName = ref('')
const difficulty = ref(2)
const generatedQuestions = ref([])
const isGenerating = ref(false)
const questionTypeOptions = [
{ value: 'single_choice', label: '单选题' },
{ value: 'multiple_choice', label: '多选题' },
{ value: 'true_false', label: '判断题' },
{ value: 'fill_blank', label: '填空题' },
{ value: 'subjective', label: '简答题' }
]
const questionTypeCounts = reactive({
single_choice: 0,
multiple_choice: 0,
true_false: 0,
fill_blank: 0,
subjective: 0
})
const totalQuestionCount = computed(() => {
return Object.values(questionTypeCounts).reduce((sum, count) => sum + count, 0)
})
const examTypeStats = computed(() => [
{ value: totalQuestionCount.value, label: '题目总数' },
{ value: generatedQuestions.value.length, label: '已生成' },
{ value: difficulty.value === 1 ? '简单' : difficulty.value === 2 ? '中等' : '困难', label: '难度等级' }
])
const onDocSelected = (doc) => {
selectedDocData.value = doc
}
const onCollectionChange = (name) => {
selectedCollectionName.value = name
}
const toggleQuestionType = (type) => {
if (questionTypeCounts[type] > 0) {
questionTypeCounts[type] = 0
} else {
questionTypeCounts[type] = 1
}
}
const handleGenerateQuestions = async () => {
if (!selectedDoc.value) {
message.warning('请先选择制度文件')
return
}
if (totalQuestionCount.value === 0) {
message.warning('请至少配置一道题目')
return
}
isGenerating.value = true
try {
const response = await questionAI.generate({
docId: selectedDoc.value,
types: questionTypeCounts,
difficulty: difficulty.value
})
if (response.success) {
generatedQuestions.value = response.data || []
message.success(`成功生成 ${generatedQuestions.value.length} 道题目`)
} else {
message.error(response.message || '生成失败')
}
} catch (error) {
console.error('生成题目失败:', error)
message.error('生成题目时发生错误')
} finally {
isGenerating.value = false
}
}
return {
selectedDoc,
selectedDocData,
selectedCollectionName,
difficulty,
generatedQuestions,
isGenerating,
questionTypeOptions,
questionTypeCounts,
totalQuestionCount,
examTypeStats,
onDocSelected,
onCollectionChange,
toggleQuestionType,
handleGenerateQuestions
}
}
- Step 2: 创建 GeneratePanel 组件
<template>
<div class="generate-panel">
<!-- 顶部标题栏 -->
<PageHeader
title="AI智能出题系统"
description="基于AI技术自动生成高质量试题"
/>
<!-- 统计卡片 -->
<StatsRow :stats="examTypeStats" />
<!-- 出题表单卡片 - 使用通用组件 -->
<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>
<div class="form-content">
<!-- 选择制度文件 -->
<div class="form-item">
<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">
<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 class="type-count-total">
总计: <strong>{{ totalQuestionCount }}</strong> 题
</div>
</div>
<!-- 操作按钮 -->
<div class="form-actions">
<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>
</ContentCard>
<!-- 待审核题目列表 -->
<ContentCard title="待审核题目列表">
<div v-if="isGenerating" class="loading-state">
<div class="loading-icon">⏳</div>
<p>正在生成题目,请稍候...</p>
</div>
<div v-else-if="generatedQuestions.length === 0" class="empty-state">
<div class="empty-icon">📝</div>
<p>暂无生成的题目</p>
<p class="empty-hint">请先选择文件并配置题型数量,然后点击"开始生成题目"</p>
</div>
<div v-else class="question-list">
<div v-for="(q, index) in generatedQuestions" :key="index" class="question-item">
<div class="question-header">
<span class="question-number">第{{ index + 1 }}题</span>
<span :class="['type-badge', q.type]">{{ getTypeLabel(q.type) }}</span>
<span v-if="q.status === 'pending'" class="status-badge pending">待审核</span>
</div>
<div class="question-content">{{ q.stem }}</div>
<!-- 选项显示 -->
<div v-if="(q.type === 'single_choice' || q.type === 'multiple_choice') && q.options && q.options.length > 0" class="question-options">
<div v-for="opt in q.options" :key="opt.key" class="option-item" :class="{ correct: opt.isCorrect }">
<span class="option-key">{{ opt.key }}.</span>
<span class="option-text">{{ opt.text }}</span>
<span v-if="opt.isCorrect" class="correct-badge">✓ 正确答案</span>
</div>
</div>
<!-- 判断题选项 -->
<div v-if="q.type === 'true_false'" class="question-options">
<div class="option-item" :class="{ correct: q.answer === 'T' || q.answer === 'true' }">
<span>正确 (T)</span>
<span v-if="q.answer === 'T' || q.answer === 'true'" class="correct-badge">✓ 正确答案</span>
</div>
<div class="option-item" :class="{ correct: q.answer === 'F' || q.answer === 'false' }">
<span>错误 (F)</span>
<span v-if="q.answer === 'F' || q.answer === 'false'" class="correct-badge">✓ 正确答案</span>
</div>
</div>
</div>
</div>
</ContentCard>
</div>
</template>
<script setup>
import PageHeader from '../common/PageHeader.vue'
import StatsRow from '../common/StatsRow.vue'
import ContentCard from '../common/ContentCard.vue'
import FileSelector from '../FileSelector.vue'
import { useGenerateState } from './composables/useGenerateState.js'
const {
selectedDoc,
selectedDocData,
selectedCollectionName,
difficulty,
generatedQuestions,
isGenerating,
questionTypeOptions,
questionTypeCounts,
totalQuestionCount,
examTypeStats,
onDocSelected,
onCollectionChange,
toggleQuestionType,
handleGenerateQuestions
} = useGenerateState()
const getTypeLabel = (type) => {
const typeMap = {
single_choice: '单选',
multiple_choice: '多选',
true_false: '判断',
fill_blank: '填空',
subjective: '简答'
}
return typeMap[type] || type
}
</script>
<style scoped>
/* 使用CSS变量替代所有硬编码值 */
.generate-panel {
padding: var(--space-lg);
}
.form-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.form-item {
margin-bottom: var(--space-md);
}
.form-label {
display: block;
font-size: var(--font-md);
font-weight: var(--weight-medium);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.required-mark {
color: var(--color-error);
}
.form-select {
width: 100%;
height: var(--input-height);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: var(--font-md);
color: var(--text-primary);
background: var(--bg-card);
transition: border-color var(--transition-fast);
}
.form-select:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: 0 0 0 3px rgba(33, 37, 41, 0.05);
}
.type-count-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-md);
}
.type-count-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--bg-container);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
font-size: var(--font-md);
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.checkbox-label.active {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.form-input-sm {
width: 60px;
height: var(--input-height);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
font-size: var(--font-md);
text-align: center;
}
.type-count-total {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-active);
border-radius: var(--radius-sm);
font-size: var(--font-md);
color: var(--text-secondary);
text-align: right;
}
.form-actions {
display: flex;
gap: var(--space-md);
padding-top: var(--space-lg);
border-top: 1px solid var(--border-light);
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-md);
font-weight: var(--weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
line-height: 1.5;
}
.btn-primary {
background: var(--color-primary);
color: var(--text-inverse);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--color-primary);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.collection-info,
.selected-file-info {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-container);
border-radius: var(--radius-sm);
font-size: var(--font-sm);
color: var(--text-secondary);
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-3xl);
color: var(--text-tertiary);
}
.loading-icon,
.empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
}
.empty-hint {
font-size: var(--font-sm);
color: var(--text-tertiary);
margin-top: var(--space-sm);
}
.question-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.question-item {
padding: var(--space-lg);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
transition: box-shadow var(--transition-fast);
}
.question-item:hover {
box-shadow: var(--shadow-md);
}
.question-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.question-number {
font-size: var(--font-md);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.type-badge {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
.type-badge.single_choice {
background: var(--bg-info);
color: var(--color-info);
border: 1px solid var(--border-medium);
}
.type-badge.multiple_choice {
background: var(--bg-success);
color: var(--color-success);
border: 1px solid var(--border-medium);
}
.type-badge.true_false {
background: var(--bg-warning);
color: var(--color-warning);
border: 1px solid var(--border-medium);
}
.status-badge {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--weight-medium);
}
.status-badge.pending {
background: var(--bg-warning);
color: var(--color-warning);
}
.question-content {
font-size: var(--font-lg);
line-height: 1.8;
color: var(--text-primary);
margin-bottom: var(--space-lg);
padding: var(--space-md) var(--space-lg);
background: var(--bg-card);
border-radius: var(--radius-md);
border-left: 4px solid var(--color-primary);
}
.question-options {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.option-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.option-item.correct {
background: var(--bg-success);
color: var(--color-success);
font-weight: var(--weight-medium);
}
.option-key {
font-weight: var(--weight-semibold);
color: var(--text-secondary);
}
.option-text {
flex: 1;
color: var(--text-primary);
}
.correct-badge {
margin-left: auto;
padding: var(--space-xs) var(--space-sm);
background: var(--color-success);
color: var(--text-inverse);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
}
.card-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--font-lg);
font-weight: var(--weight-semibold);
color: var(--text-primary);
margin: 0;
}
</style>
- Step 3: 测试 GeneratePanel 组件
运行: npm run dev
Expected: AI出题面板完整显示,表单交互正常,生成功能可用
- Step 4: 从原 ExamModule.vue 移除 generate 相关代码
删除范围:
-
Template: 第10-283行(
<!-- ========== AI出题面板 ========== -->整个区块) -
Script: 所有 generate 相关的状态和方法
-
Style: 所有
.generate-panel相关样式(约800行) -
Step 5: 提交 GeneratePanel
git add src/components/exam/GeneratePanel.vue src/components/exam/composables/useGenerateState.js
git commit -m "feat(exam): extract generate panel as independent component"
Task 3-6: 拆分其他四个面板(简化描述)
遵循相同的模式:
-
Task 3: QuestionBankPanel.vue (~600行)
- 提取题库管理相关逻辑到
useQuestionBank.js - 包含:题目列表、搜索筛选、分页、CRUD操作
- 提取题库管理相关逻辑到
-
Task 4: PaperManagementPanel.vue (~800行)
- 提取组卷相关逻辑到
usePaperManagement.js - 包含:草稿箱、已发布试卷列表、组卷操作
- 提取组卷相关逻辑到
-
Task 5: TrainingPanel.vue (~1000行)
- 提取训练相关逻辑到
useTraining.js - 包含:闯关模式、每日一练、错题本三个子标签页
- 提取训练相关逻辑到
-
Task 6: ExamPanel.vue (~1200行)
- 提取考核相关逻辑到
useExam.js - 包含:待考试、答题中、成绩单、错题本、自测组卷五个子标签页
- 提取考核相关逻辑到
每个任务都应遵循 Task 2 的步骤模式:
- 创建 composable 函数
- 创建面板组件(使用通用组件 + CSS变量)
- 测试功能完整性
- 从原文件移除代码
- 提交更改
Task 7: 全面替换硬编码值为 CSS 变量
目标: 确保所有新组件中不存在硬编码的样式值
Files:
-
Modify:
src/components/exam/*.vue(所有新创建的面板组件) -
Step 1: 创建样式审查脚本
// scripts/check-hardcoded-styles.js
const fs = require('fs')
const path = require('path')
const cssVarPatterns = {
spacing: /(\d+)px/g,
fontSize: /font-size:\s*(\d+)px/g,
borderRadius: /border-radius:\s*(\d+)px/g,
colors: /#[0-9a-fA-F]{3,8}/g,
shadows: /box-shadow:\s*[^;]+rgba\([^)]+\)/g
}
function checkFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8')
const issues = []
Object.entries(cssVarPatterns).forEach(([category, pattern]) => {
let match
while ((match = pattern.exec(content)) !== null) {
issues.push({
category,
line: content.substring(0, match.index).split('\n').length,
matched: match[0]
})
}
})
return issues
}
const files = [
'src/components/exam/GeneratePanel.vue',
'src/components/exam/QuestionBankPanel.vue',
'src/components/exam/PaperManagementPanel.vue',
'src/components/exam/TrainingPanel.vue',
'src/components/exam/ExamPanel.vue'
]
files.forEach(file => {
const issues = checkFile(file)
if (issues.length > 0) {
console.log(`\n📁 ${file}`)
console.log(` 发现 ${issues.length} 处硬编码样式:`)
issues.slice(0, 10).forEach(issue => {
console.log(` - Line ${issue.line}: ${issue.category} -> ${issue.matched}`)
})
if (issues.length > 10) {
console.log(` ... 还有 ${issues.length - 10} 处`)
}
} else {
console.log(`✅ ${file}: 无硬编码样式`)
}
})
- Step 2: 运行样式审查
Run: node scripts/check-hardcoded-styles.js
Expected: 输出所有需要修复的硬编码样式位置
- Step 3: 批量替换硬编码值
替换规则表:
| 硬编码值 | CSS变量 | 类别 |
|---|---|---|
padding: 4px |
padding: var(--space-xs) |
间距 |
padding: 8px |
padding: var(--space-sm) |
间距 |
padding: 12px |
padding: var(--space-md) |
间距 |
padding: 16px |
padding: var(--space-lg) |
间距 |
padding: 20px |
padding: var(--space-xl) |
间距 |
padding: 24px |
padding: var(--space-2xl) |
间距 |
margin-bottom: 16px |
margin-bottom: var(--space-lg) |
间距 |
font-size: 11px |
font-size: var(--font-xs) |
字体 |
font-size: 12px |
font-size: var(--font-sm) |
字体 |
font-size: 13px |
font-size: var(--font-md) |
字体 |
font-size: 14px |
font-size: var(--font-lg) |
字体 |
font-size: 16px |
font-size: var(--font-xl) |
字体 |
border-radius: 4px |
border-radius: var(--radius-sm) |
圆角 |
border-radius: 6px |
border-radius: var(--radius-md) |
圆角 |
border-radius: 8px |
border-radius: var(--radius-lg) |
圆角 |
box-shadow: 0 1px 2px rgba(0,0,0,0.04) |
box-shadow: var(--shadow-sm) |
阴影 |
box-shadow: 0 2px 8px rgba(0,0,0,0.06) |
box-shadow: var(--shadow-md) |
阴影 |
#212529 |
var(--text-primary) |
颜色 |
#495057 |
var(--text-secondary) |
颜色 |
#6C757D |
var(--text-tertiary) |
颜色 |
#E9ECEF |
var(--border-light) |
颜色 |
#DEE2E6 |
var(--border-default) |
颜色 |
- Step 4: 再次运行审查确认零硬编码
Run: node scripts/check-hardcoded-styles.js
Expected: 所有文件输出 "✅ 无硬编码样式"
- Step 5: 提交样式标准化
git add src/components/exam/*.vue
git commit -m "style(exam): replace all hardcoded values with CSS variables"
Task 8: 使用 ContentCard 替换 .exam-card
目标: 消除自定义卡片样式,统一使用通用组件
Files:
-
Modify:
src/components/exam/*.vue(所有面板组件) -
Step 1: 审计当前 .exam-card 使用情况
搜索所有 <div class="exam-card"> 和 <div class="card-header"> 的使用点
- Step 2: 逐一替换为 ContentCard 组件
替换前:
<div class="exam-card">
<div class="card-header">
<h2>标题</h2>
</div>
<div class="card-body">
内容
</div>
</div>
替换后:
<ContentCard title="标题">
<template #header>
<h2>标题</h2>
</template>
内容
</ContentCard>
- Step 3: 删除所有 .exam-card 和 .card-header 样式定义
从各组件的 <style scoped> 中移除这些不再使用的样式规则
- Step 4: 验证视觉效果一致性
对比截图确认替换前后视觉效果完全一致
- Step 5: 提交 ContentCard 迁移
git add src/components/exam/*.vue
git commit -m "refactor(exam): replace custom .exam-card with ContentCard component"
Task 9: 使用 DataTable 替换原始 a-table
目标: 统一表格组件的使用,提供一致的表格体验
Files:
-
Modify:
src/components/exam/QuestionBankPanel.vue -
Modify:
src/components/exam/PaperManagementPanel.vue -
Modify:
src/components/exam/ExamPanel.vue -
Step 1: 识别所有原始 a-table 使用点
搜索 <a-table 在各面板组件中的使用情况
- Step 2: 评估 DataTable 组件是否满足需求
检查 DataTable.vue 支持的功能:
-
✅ 基础列定义
-
✅ 分页
-
✅ 排序
-
✅ 筛选
-
✅ 自定义列插槽
-
✅ 空状态
-
✅ 加载状态
-
Step 3: 替换为 DataTable 组件
替换前:
<a-table
:dataSource="questions"
:columns="columns"
:loading="loading"
:pagination="pagination"
rowKey="id"
/>
替换后:
<DataTable
:dataSource="questions"
:columns="columns"
:loading="loading"
:pagination="pagination"
rowKey="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 自定义列内容 -->
</template>
</DataTable>
- Step 4: 测试表格功能完整性
验证:
-
数据展示正确
-
分页工作正常
-
排序功能正常
-
自定义列渲染正常
-
响应式布局正常
-
Step 5: 提交 DataTable 迁移
git add src/components/exam/*.vue
git commit -m "refactor(exam): replace raw a-table with DataTable component"
Task 10: 移除重复样式定义
目标: 消除所有跨文件重复的样式定义
Files:
-
Delete: 各组件中的重复样式(如果存在)
-
Step 1: 生成样式重复度报告
分析以下样式类在各文件中的出现次数:
-
.form-group/.form-item -
.form-label -
.form-input/.form-select -
.btn/.btn-primary/.btn-secondary -
.question-item/.question-content -
.option-item/.option-text -
Step 2: 将通用样式提取到全局样式文件
如果发现多个组件使用相同样式,考虑:
- 选项A: 移入
src/style.css作为全局样式 - 选项B: 创建新的通用组件封装这些样式
- 选项C: 保持 scoped 但确保使用相同的 CSS 变量值
推荐选项 C(最安全,不影响其他模块)
- Step 3: 验证无视觉回归
运行: npm run build
Expected: 构建成功,无警告
- Step 4: 提交样式清理
git add src/style.css src/components/exam/*.vue
git commit -m "cleanup(exam): remove duplicate style definitions"
Task 11: 定义清晰的 Props/Emits 接口
目标: 为每个面板组件建立明确的公共API
Files:
-
Modify:
src/components/exam/*.vue(所有面板组件) -
Step 1: 为每个组件添加 Props 定义示例
<script setup>
// GeneratePanel.vue
const props = defineProps({
initialDocId: {
type: String,
default: ''
},
readOnly: {
type: Boolean,
default: false
},
showActions: {
type: Boolean,
default: true
}
})
const emit = defineEmits([
'question-generated',
'question-approved',
'question-rejected',
'doc-selected'
])
</script>
- Step 2: 记录组件API文档
在每个组件文件顶部添加注释块:
<!--
@component GeneratePanel
@description AI智能出题面板
@props {string} initialDocId - 初始文档ID
@props {boolean} readOnly - 是否只读模式
@props {boolean} showActions - 是否显示操作按钮
@emits question-generated - 题目生成完成事件
@emits question-approved - 题目审核通过事件
@emits question-rejected - 题目审核拒绝事件
@emits doc-selected - 文档选中事件
-->
- Step 3: 更新容器组件传递正确的 props
在 ExamModuleContainer.vue 中根据需要向子组件传递 props
- Step 4: 提交接口规范化
git add src/components/exam/*.vue
git commit -m "docs(exam): define clear component interfaces with props and emits"
Task 12: 状态管理本地化
目标: 确保每个面板组件的状态尽可能自包含,减少跨组件耦合
Files:
-
Modify:
src/components/exam/composables/*.js -
Step 1: 审计当前状态共享情况
识别哪些状态是真正的全局状态 vs 可以本地化的状态
- Step 2: 将本地状态移入 composable
确保每个 composable 只返回该面板需要的最小状态集
- Step 3: 必要时引入轻量级状态管理
如果确实需要跨面板共享状态(如用户信息、权限),使用 provide/inject 或简单的 store
- Step 4: 提交状态管理优化
git add src/components/exam/composables/*.js
git commit -m "refactor(exam): localize state management to reduce coupling"
Task 13: 编写单元测试和文档
目标: 确保重构后的代码质量和可维护性
Files:
-
Create:
src/components/exam/__tests__/*.spec.js -
Create:
docs/components/exam.md -
Step 1: 为核心 composable 编写单元测试
// src/components/exam/__tests__/useGenerateState.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useGenerateState } from '../composables/useGenerateState.js'
describe('useGenerateState', () => {
it('should initialize with default values', () => {
const state = useGenerateState()
expect(state.selectedDoc.value).toBeNull()
expect(state.difficulty.value).toBe(2)
expect(state.generatedQuestions.value).toEqual([])
})
it('should calculate total question count correctly', () => {
const state = useGenerateState()
state.questionTypeCounts.single_choice = 5
state.questionTypeCounts.multiple_choice = 3
expect(state.totalQuestionCount.value).toBe(8)
})
})
- Step 2: 为关键组件编写快照测试
验证组件渲染输出符合预期
- Step 3: 编写组件使用文档
# 考察与训练功能模块
## 组件结构
ExamModuleContainer (容器) ├── GeneratePanel (AI出题) ├── QuestionBankPanel (题库管理) ├── PaperManagementPanel (智能组卷) ├── TrainingPanel (互动训练) └── ExamPanel (试卷考核)
## 使用方法
```vue
<template>
<ExamModuleContainer />
</template>
<script setup>
import ExamModuleContainer from '@/components/exam/ExamModuleContainer.vue'
</script>
自定义配置
单独使用某个面板
<GeneratePanel
:initial-doc-id="doc123"
:read-only="false"
@question-generated="onGenerated"
/>
样式定制
所有组件使用 CSS 变量,可通过覆盖变量来自定义主题:
:root {
--color-primary: #your-brand-color;
--radius-md: 8px;
}
- [ ] **Step 4: 提交测试和文档**
```bash
git add src/components/exam/__tests__ docs/components/exam.md
git commit -m "test(exam): add unit tests and documentation"
验收标准检查清单
功能完整性 ✅
- 所有原有功能在重构后正常工作
- 无功能回归(对比重构前后行为一致)
- 表单提交、数据加载、事件处理均正常
代码质量 ✅
- 每个组件文件不超过 500 行(除了 ExamPanel 可能稍大)
- 无 ESLint 警告或错误
- 无 TypeScript 类型错误(如果使用 TS)
- 所有 Props/Emits 都有明确的类型定义
设计规范符合度 ✅
- 零硬编码样式值(全部使用 CSS 变量)
- 通用组件利用率达到 90% 以上
- 无跨文件重复样式定义
- 符合白色体系色彩规范
- 符合组件尺寸标准(紧凑化)
性能指标 ✅
- 首屏加载时间无明显增加
- 组件切换响应时间 < 100ms
- 内存占用无异常增长
- 包体积增加控制在合理范围内
可维护性 ✅
- 新开发者可在 30 分钟内理解组件结构
- 修改某个功能不会影响其他功能
- 单元测试覆盖率 > 60%
- 有完整的组件使用文档
回滚策略
如果在重构过程中遇到问题:
- Git 分支保护: 所有重构工作在新分支
refactor/exam-module进行 - 渐进式合并: 每个 Task 完成后即可合并到主分支
- 功能开关: 在容器组件中保留切换新旧实现的开关
- 备份保留: 原 ExamModule.vue 文件保留至少 2 个版本周期
- 监控告警: 上线后密切监控错误日志和用户反馈
时间估算
| 任务 | 预估工时 | 优先级 | 依赖关系 |
|---|---|---|---|
| Task 1: 容器组件 | 0.5天 | P0 | 无 |
| Task 2: GeneratePanel | 1天 | P0 | Task 1 |
| Task 3: QuestionBankPanel | 0.5天 | P0 | Task 1 |
| Task 4: PaperManagementPanel | 1天 | P1 | Task 1 |
| Task 5: TrainingPanel | 1天 | P1 | Task 1 |
| Task 6: ExamPanel | 1.5天 | P1 | Task 1 |
| Task 7: CSS变量替换 | 1天 | P0 | Task 2-6 |
| Task 8: ContentCard迁移 | 0.5天 | P1 | Task 7 |
| Task 9: DataTable迁移 | 0.5天 | P2 | Task 7 |
| Task 10: 样式清理 | 0.5天 | P2 | Task 8-9 |
| Task 11: 接口规范化 | 0.5天 | P2 | Task 2-6 |
| Task 12: 状态管理优化 | 1天 | P2 | Task 11 |
| Task 13: 测试和文档 | 1天 | P3 | Task 12 |
| 总计 | 10.5天 |
风险与缓解措施
| 风险 | 可能性 | 影响 | 缓解措施 |
|---|---|---|---|
| 功能回归 | 中 | 高 | 每步完成后进行全面测试 |
| 性能下降 | 低 | 中 | 监控性能指标,必要时优化 |
| 团队学习成本 | 中 | 低 | 提供详细文档和培训 |
| 合并冲突 | 中 | 中 | 小步提交,频繁同步 |
| 时间超期 | 中 | 中 | 优先完成P0任务,P2/P3可后续迭代 |
附录
A. 参考资料
B. 工具链推荐
- 代码质量: ESLint + Prettier
- 测试框架: Vitest + Vue Test Utils
- 样式审查: Stylelint + 自定义规则
- 性能监测: Chrome DevTools + Lighthouse
- 文档生成: Storybook (可选)
C. 后续优化方向
重构完成后,可考虑进一步优化:
- 虚拟滚动: 对于大数据量的题目列表
- 懒加载: 按需加载面板组件,减少首屏时间
- 国际化: 支持 i18n 多语言
- 主题系统: 支持深色模式等多主题
- 无障碍: 增强 WCAG 2.1 AA 合规性
计划版本: v1.0
创建日期: 2026-05-14
预计完成日期: 2026-05-28
负责人: AI Assistant
审核人: 待定