# 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)
