# 考察与训练功能页面组件设计规范重构实施计划
> **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+ 处硬编码值:
```html
```
- CSS 中有 30+ 处硬编码尺寸值:
```css
/* 错误示例 */
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: 创建容器组件基础结构**
```vue
```
- [ ] **Step 2: 验证容器组件基本功能**
运行: `npm run dev`
Expected: 页面正常渲染,标签栏可切换,无控制台错误
- [ ] **Step 3: 提交容器组件**
```bash
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: 提取出题相关的状态管理逻辑**
```javascript
// 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 组件**
```vue
智能出题配置
📝
暂无生成的题目
请先选择文件并配置题型数量,然后点击"开始生成题目"
{{ q.stem }}
{{ opt.key }}.
{{ opt.text }}
✓ 正确答案
正确 (T)
✓ 正确答案
错误 (F)
✓ 正确答案
```
- [ ] **Step 3: 测试 GeneratePanel 组件**
运行: `npm run dev`
Expected: AI出题面板完整显示,表单交互正常,生成功能可用
- [ ] **Step 4: 从原 ExamModule.vue 移除 generate 相关代码**
删除范围:
- Template: 第10-283行(`` 整个区块)
- Script: 所有 generate 相关的状态和方法
- Style: 所有 `.generate-panel` 相关样式(约800行)
- [ ] **Step 5: 提交 GeneratePanel**
```bash
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 的步骤模式:
1. 创建 composable 函数
2. 创建面板组件(使用通用组件 + CSS变量)
3. 测试功能完整性
4. 从原文件移除代码
5. 提交更改
---
### Task 7: 全面替换硬编码值为 CSS 变量
**目标:** 确保所有新组件中不存在硬编码的样式值
**Files:**
- Modify: `src/components/exam/*.vue` (所有新创建的面板组件)
- [ ] **Step 1: 创建样式审查脚本**
```javascript
// 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: 提交样式标准化**
```bash
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 使用情况**
搜索所有 `
` 和 `