563 lines
18 KiB
Markdown
563 lines
18 KiB
Markdown
# 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)
|