18 KiB
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 设计目标
- 组件拆分: 将单文件拆分为 7 个子组件 + 2 个 composable,每个 < 350 行
- 代码精简: 移除冗余逻辑、调试日志、硬编码颜色
- 视觉优化: 延续现有扁平设计风格,统一使用 design-tokens
- 交互改进: 优化组卷配置区控件、预览区展示、弹窗体验
- 响应式: 保持移动端/平板/桌面断点适配
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
- 状态提升: 所有业务状态集中在
usePaperManagementcomposable - v-model 模式: 弹窗类组件支持
v-model:visible双向绑定 - 接口最小化: 每个子组件只暴露必要的 props/emits
3. 各组件详细设计
3.1 PaperManagementPanel.vue — 主容器
职责: Tab 导航 + 子组件编排 + 状态初始化
Template 结构:
<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
{
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
{
'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
{
result: Object | null, // composeResult
composing: Boolean,
totalScore: Number
}
Emits
{ save: [] }
三态设计
-
空态 (
!result && !composing):- SVG 图标 + 引导文案 "选择文件(可选)、设置题型数量 → 点击「生成试卷」"
- 使用
--text-muted颜色
-
加载态 (
composing):- Spinner + "正在从题库中选题组卷..."
-
结果态 (
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
{
mode: 'drafts' | 'published',
papers: Array,
loading: Boolean
}
Emits
{
view: [paper],
publish: [paperId], // 仅 drafts 模式
delete: [paperId], // 仅 drafts 模式
revoke: [paperId] // 仅 published 模式
}
视觉改进
| 改进项 | 当前 | 新设计 |
|---|---|---|
| 行左侧 | 无标识 | 彩色竖条(草稿=--color-warning, 发布=--color-success) |
| 操作按钮 | 文字按钮 ("查看"/"发布"/"删除") | 图标按钮 + hover tooltip |
| Badge 样式 | 自定义 CSS | 使用 design-token 颜色 |
| 空态 | 内联 SVG | 统一空态组件风格 |
条件渲染逻辑
<!-- 草稿模式独有 -->
<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
{
modelValue: Boolean, // 弹窗显隐
rawData: Object | null // 原始 API 返回数据
}
内部处理
- 使用
useQuestionParser()composable 解析原始数据 - 解析逻辑包括: content 多格式兼容、选项提取、答案匹配、类型判断
展示区块
- 发布信息区 (如有): 目标部门/用户/时间/截止
- 概要行: 总题数 + 总分
- 题目列表 (每题):
- 头部: 序号(主色圆圈) + 类型标签 + 分数
- 题干文本
- 选择题: 选项列表 (正确选项绿色左边框 + ✓)
- 判断题: 正确/错误文本 填空/简答: 参考答案文本
- 解析区:
--color-warning背景 (与 ExamPanel.vue 统一) - 难度标签 (如有)
- Fallback 区: 原始数据兜底展示 (当自动解析失败时)
3.6 PublishDialog.vue — 发布弹窗
职责: 设置发布参数(部门/用户范围/时间)
Props
{
modelValue: Boolean,
paperId: String | Number
}
Emits
{
'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
{
modelValue: Boolean,
type: 'dept' | 'user',
mode: 'target' | 'exclude', // 仅 type='user' 时有效
sourceList: Array, // 可选数据源
selectedIds: Set, // 当前已选 ID 集合
loading: Boolean,
searchPlaceholder: String
}
Emits
{
'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 使用
导出函数:
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. 实施顺序建议
-
Phase 1 - 基础设施
- 创建
useQuestionParser.js(纯函数,无依赖) - 创建
usePaperManagement.js(从原组件提取状态和方法)
- 创建
-
Phase 2 - 核心组件
- 创建
ComposeConfigSidebar.vue - 创建
ComposePreviewPane.vue - 创建
ComposePanel.vue(组合上述两个) - 创建
PaperListTab.vue
- 创建
-
Phase 3 - 弹窗组件
- 创建
EntityPickerModal.vue(通用,无业务依赖) - 创建
PublishDialog.vue(依赖 EntityPickerModal) - 创建
PaperDetailModal.vue(依赖 useQuestionParser)
- 创建
-
Phase 4 - 主容器整合
- 重构
PaperManagementPanel.vue为薄容器 - 接入所有子组件
- 样式清理与 token 替换
- 重构
-
Phase 5 - 验证
- 功能回归测试(组卷/保存/发布/查看/删除/撤回)
- 响应式布局测试
- 构建验证 (npm run build)