前端项目初始化提交

This commit is contained in:
2026-06-03 13:16:30 +08:00
commit 0910ba9cbe
163 changed files with 110032 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
# 考试管理API对接设计文档
## 一、概述
### 1.1 目标
对「考察与训练」模块中的「智能组卷」和「互动训练」两个子功能进行后端API对接替换现有的模拟数据实现与后端考试管理接口的真实数据交互。
### 1.2 范围
- **智能组卷**: 使用 `POST /exam/paper/generate` 从题库自动组卷
- **互动训练-闯关模式**: 预设固定关卡,每关调用组卷接口生成题目
- **互动训练-错题本**: 使用 `POST /exam/answers/query` 查询答题记录
- **互动训练-每日一练**: 保持现有逻辑(已对接 `POST /exam/grade`
### 1.3 约束
- 组卷结果仅前端暂存,不持久化到后端
- 闯关模式为预设固定关卡
- 仅修改前端,不修改后端代码
---
## 二、涉及接口
### 2.1 生成试卷 `POST /api/exam/paper/generate`
**请求参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| single_choice_count | Integer | 单选题数量 |
| multiple_choice_count | Integer | 多选题数量 |
| true_false_count | Integer | 判断题数量 |
| fill_blank_count | Integer | 填空题数量 |
| subjective_count | Integer | 简答题数量 |
| include_personal | Boolean | 是否包含个人题目 |
| difficulty | Integer | 难度等级(1-5) |
| file_ids | List<Long> | 关联文件ID列表 |
| collection | String | 向量库名称 |
| collection_name | String | 向量库名称(备选) |
**响应结构**:
```json
{
"code": 200,
"message": "试卷生成成功",
"data": {
"success": true,
"paper_id": "paper_xxx",
"paper_title": "试卷标题",
"total_score": 100,
"question_count": 10,
"generated_at": "2026-05-10T10:00:00",
"permission_scope": "department",
"warnings": {},
"questions": [
{
"question_id": "q-xxx",
"question_type": "single_choice",
"question_type_name": "单选题",
"difficulty": 2,
"score": 10,
"content": { "stem": "...", "data": { "options": [...] }, "answer": "A" }
}
]
}
}
```
### 2.2 批改答案 `POST /api/exam/grade`
已对接,无需修改。
### 2.3 查询答题记录 `POST /api/exam/answers/query`
**请求参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| paper_id | String | 试卷ID与session_id二选一 |
| session_id | String | 会话ID与paper_id二选一 |
**响应结构**: 包含分组答题记录,其中 `is_correct` 为0时表示错误题目。
---
## 三、智能组卷改造
### 3.1 当前状态
`handleGeneratePaper()` 错误地调用了 `examAPI.generateQuestions()`AI出题接口而不是组卷专用接口。
### 3.2 改造内容
#### 3.2.1 新增 API 方法
在 [exam.js](file:///c:/Users/33520/Desktop/制度文件管理学习AI智能体 vue版本/src/api/exam.js) 中新增:
- `generatePaper(params)` → 调 `POST /api/exam/paper/generate`
#### 3.2.2 参数映射
| paperConfig | API字段 | 转换逻辑 |
|---|---|---|
| singleCount | single_choice_count | 直接映射 |
| multipleCount | multiple_choice_count | 直接映射 |
| judgmentCount | true_false_count | 直接映射 |
| essayCount | subjective_count | 直接映射 |
| difficulty (百分比) | difficulty | 加权计算: easy%×1 + medium%×3 + hard%×5 / 100 |
| doc | collection/collection_name/file_ids | 从FileSelector对象提取 |
#### 3.2.3 响应处理
API返回的 `data.questions` 包含完整的题目列表,直接作为试卷的题目内容保存在前端的 `papers` 数组中。
---
## 四、互动训练改造
### 4.1 闯关模式
#### 4.1.1 预设关卡设计
| 关卡 | 名称 | 题目数 | 难度 | 题型组成 |
|---|---|---|---|---|
| 1 | 入门挑战 | 5 | 1 | 3单选+2判断 |
| 2 | 基础巩固 | 8 | 2 | 4单选+2多选+2判断 |
| 3 | 进阶提升 | 10 | 3 | 4单选+3多选+3判断 |
| 4 | 高级挑战 | 10 | 4 | 3单选+3多选+2判断+2简答 |
| 5 | 大师试炼 | 12 | 5 | 4单选+4多选+2判断+2简答 |
每关调用 `POST /exam/paper/generate` 生成对应配置的题目。
#### 4.1.2 闯关流程
1. 用户点击关卡 → 调 `generatePaper()` 生成题目
2. 用户在弹窗中逐题作答
3. 点击提交 → 调 `examAPI.gradeAnswers()` 批改
4. 显示分数和结果 → 更新关卡状态
### 4.2 错题本
#### 4.2.1 数据来源
用户每次在闯关模式/每日一练中提交答案后:
1. 调用 `POST /exam/answers/query` 查询答题记录
2. 筛选 `is_correct === 0` 的题目作为错题
3. 按时间倒序排列展示
#### 4.2.2 本地缓存
每次批改后,将错题信息缓存到前端的 `wrongQuestions` 数组中,避免频繁调用查询接口。
---
## 五、文件修改清单
| 文件 | 修改内容 |
|------|---------|
| `src/api/exam.js` | 新增 `generatePaper()``queryUserAnswers()` 方法 |
| `src/components/ExamModule.vue` | 改造智能组卷和互动训练的数据流和API调用逻辑 |
## 六、错误处理
- API 调用失败时在控制台输出详细错误信息,并在界面上给出用户友好的提示
- 闯关模式中题目加载失败时显示重试按钮
- 错题本查询失败时降级显示空列表,不影响其他功能使用

View File

@@ -0,0 +1,234 @@
# 试卷考核功能设计文档
> 日期: 2026-05-10
> 状态: 已批准
> 模块: 考察与训练 - 试卷考核子功能
## 一、功能概述
在现有「考察与训练」模块中新增 **试卷考核** 主标签页,为用户提供完整的考试流程:接收考试 → 答题 → 批改 → 查看成绩 → 错题练习。同时保留用户自行生成测试卷的能力。
## 二、模块架构
### 2.1 改造后的标签页结构
```
考察与训练 (exam)
├── 🤖 AI出题 (generate) — exam:ai — 管理员/有权限
├── 📚 题库管理 (bank) — exam:bank — 管理员/有权限
├── 📋 智能组卷 (paper) — exam:paper — 管理员专用(可编辑+发布)
├── 📝 试卷考核 (exam) — exam:exam — 全员(答题+错题+自测)
└── 🎯 互动训练 (train) — exam:train — 全员(闯关+每日一练)
```
### 2.2 试卷考核子标签页
```
试卷考核 [exam]
├── 📋 待考试 (pending) — 管理员发布的待完成试卷列表
├── ✏️ 答题中 (taking) — 全屏答题界面(当前正在答的试卷)
├── 📊 成绩单 (results) — 已完成的试卷批改结果
├── ❌ 错题本 (wrong) — 调用 wrong-questions/list API
└── 🔧 自测组卷 (selftest) — 用户自行生成试卷(只生成不编辑,直接做题)
```
## 三、权限模型
### 3.1 新增权限代码
`permission.js``permissionCodeToModule` 中新增:
```javascript
'exam:exam': 'exam', // 试卷考核 - 查看、答题、成绩、错题
'exam:exam:selftest': 'exam', // 自测组卷 - 用户自行生成试卷
```
### 3.2 数据库新增权限记录
需在 `permission` 表中插入:
| permissionCode | permissionName | parentId(=exam节点ID) |
|---------------|---------------|---------------------|
| `exam:exam` | 试卷考核 | [exam父节点ID] |
### 3.3 角色分配建议
| 角色 | 可见标签页 |
|-----|----------|
| 超级管理员/管理员 | 全部5个含智能组卷编辑发布 |
| 普通员工 | AI出题 + 试卷考核 + 互动训练 |
### 3.4 前端权限控制变量
```javascript
const canShowExam = computed(() =>
hasChildPermission('exam:exam') || hasChildPermission('exam')
)
```
## 四、核心功能详细设计
### 4.1 待考试列表 (pending)
**功能**: 展示管理员通过「智能组卷」发布后、用户尚未完成的试卷。
**数据来源**: `GET /api/exam/my/papers` (§15.1)
**展示字段**:
- 试卷标题 (paper_title)
- 出卷人/来源
- 题目数量 (question_count)
- 总分 (total_score)
- 发布时间 (generated_at)
- 状态标签: `待考试`
**交互**: 点击卡片 → 进入全屏答题页面
**空状态**: "暂无待考试试卷,请等待管理员发布"
### 4.2 答题页面 (taking)
**功能**: 全屏沉浸式答题界面支持5种题型的答案输入。
**触发**: 从待考试列表点击进入 / 从自测组卷生成后自动进入
**界面布局**:
```
┌─────────────────────────────────────────────┐
│ ⬅ 返回 📝 《试卷名称》 倒计时 ⏱️ │
├─────────────────────────────────────────────┤
│ │
│ 第 1/13 题 [单选题] ★ 5分 │
│ ─────────────────────────────────────── │
│ 根据公司考勤制度迟到15分钟以内的处罚是
│ │
│ ○ A. 口头警告 │
│ ○ B. 扣款50元 │
│ ● C. 扣款100元 │
│ ○ D. 视为旷工 │
│ │
├─────────────────────────────────────────────┤
│ < 上一题 下一题 > 提交试卷 │
└─────────────────────────────────────────────┘
```
**题型渲染规则**:
| question_type | 控件 | 答案格式 |
|--------------|------|---------|
| single_choice | Radio 单选 | 字符串 "A" |
| multiple_choice | Checkbox 多选 | 数组 ["A","C"] |
| true_false | Radio 是/否 | 字符串 "true"/"false" |
| fill_blank | Input 输入框 | 字符串 |
| subjective | Textarea 文本域 | 字符串 |
**状态管理**:
```javascript
const currentTakingPaper = ref(null) // 当前正在答的试卷
const currentQuestionIndex = ref(0) // 当前题目索引
const takingAnswers = ref({}) // { questionId: answer }
const isSubmitting = ref(false) // 提交中状态
```
**提交逻辑**:
1. 收集所有答案 → 构建 `answers` 数组
2. 调用 `POST /api/exam/grade` (§14.3)
3. 收到批改结果 → 自动跳转到成绩单视图
4. 错题自动标记 → 可在错题本中查看
### 4.3 成绩单 (results)
**功能**: 展示已完成的试卷批改结果。
**数据来源**: 提交批改时的响应 + `POST /api/exam/answers/query` (§14.4)
**展示内容**:
- 总分 / 得分 / 得分率
- 每道题的对错状态、得分、反馈
- 用时统计
- 操作按钮: 「查看解析」「重做错题」
### 4.4 错题本 (wrong)
**功能**: 展示用户的错题记录,支持查看解析和重做。
**数据来源**: `POST /api/wrong-questions/list` (§18.1)
**操作**:
- 查看解析: 显示标准答案 + AI反馈 + 选项高亮
- 重做: 调用 `POST /api/wrong-questions/redo` (§18.3) → 进入答题模式
- 收藏: 调用 `POST /api/wrong-questions/collection/toggle` (§18.2)
**与互动训练错题本的关系**:
- 互动训练的错题本是前端本地缓存(闯关/每日一练的错题)
- 试卷考核的错题本调用后端API是持久化的真实错题数据
- 两者独立存在,互不影响
### 4.5 自测组卷 (selftest)
**功能**: 用户自行配置参数生成测试卷,生成后直接进入答题。
**API**: `POST /api/exam/paper/generate` (§14.1) — 用户版
**与智能组卷的区别**:
| 维度 | 智能组卷(管理员) | 自测组卷(用户) |
|-----|----------------|--------------|
| API | 同一个接口 | 同一个接口 |
| 生成后行为 | 进入编辑预览 | **直接进入答题** |
| 可编辑题目 | ✅ 编辑题干/选项/答案 | ❌ |
| 可发布给他人 | ✅ 发布按钮 | ❌ 仅自己使用 |
| 保存到试卷列表 | ✅ 存入后端 | ❌ 前端暂存 |
| 权限要求 | `exam:paper` | `exam:exam:selftest` |
**交互流程**:
1. 配置参数(题型数量、难度)→ 可选关联制度文件
2. 点击「开始测试」→ 调用 generatePaper API
3. 生成成功 → 自动切换到答题页面(taking)
4. 答题 → 提交批改 → 显示成绩
## 五、智能组卷改造(管理员增强)
### 5.1 新增能力
在现有的智能组卷功能上增加:
1. **编辑题目**: 在预览弹窗中点击题目旁的「编辑」按钮
- 可修改: 题干(stem)、选项(options)、答案(answer)、分值(score)
- 使用已有的 `showEditQuestionModal` 逻辑
2. **发布试卷**: 在预览弹窗底部增加「发布试卷」按钮
- 调用后端发布接口(如需要新增接口则后续补充)
- 发布成功后有 `exam:exam` 权限的用户可在「试卷考核-待考试」中看到
3. **试卷列表**: 增加本地已生成试卷的管理面板
- 显示所有已生成的试卷(草稿/已发布)
- 支持预览、编辑草稿、发布、删除
### 5.2 权限隔离
```javascript
// 只有拥有 exam:paper 权限的用户才能看到编辑和发布按钮
const canEditAndPublish = computed(() => hasChildPermission('exam:paper'))
```
## 六、API 接口清单
| 功能 | 方法 | 路径 | 文档章节 |
|-----|------|------|---------|
| 生成试卷(共用) | POST | `/api/exam/paper/generate` | §14.1 |
| 批改答案 | POST | `/api/exam/grade` | §14.3 |
| 查询答题记录 | POST | `/api/exam/answers/query` | §14.4 |
| 我的试卷列表 | GET | `/api/exam/my/papers` | §15.1 |
| 试卷详情 | GET | `/api/exam/paper/{paperId}` | §15.2 |
| 错题列表 | POST | `/api/wrong-questions/list` | §18.1 |
| 收藏错题 | POST | `/api/wrong-questions/collection/toggle` | §18.2 |
| 重做错题 | POST | `/api/wrong-questions/redo` | §18.3 |
## 七、文件变更清单
| 文件 | 变更类型 | 说明 |
|-----|---------|------|
| `src/utils/permission.js` | 修改 | 新增 `exam:exam` 权限映射 |
| `src/api/exam.js` | 已完成 | 所有API方法已在之前添加 |
| `src/components/ExamModule.vue` | 大幅修改 | 新增试卷考核标签页及全部子功能 |
| 数据库 permission 表 | 新增记录 | INSERT exam:exam 权限 |

View File

@@ -0,0 +1,943 @@
# 侧边栏导航系统设计方案
**项目名称**: 制度文件管理学习AI智能体 - 前端优化
**设计日期**: 2026-05-14
**版本**: v1.0
**状态**: 已批准
---
## 1. 设计背景与目标
### 1.1 当前问题
现有系统采用**顶部水平导航栏**布局,存在以下问题:
- **空间利用率低**: 7 个功能模块占用顶部空间,在宽屏显示器上浪费水平空间
- **可扩展性差**: 新增功能模块会导致顶部导航拥挤
- **视觉层级不清晰**: 系统标题、导航、用户信息混在同一行,缺乏层次感
- **移动端体验差**: 水平导航在小屏幕上需要滚动或换行
### 1.2 设计目标
将现有的顶部 header 导航重构为**左侧固定侧边栏**系统,实现:
1. **提升空间利用率**: 垂直导航释放顶部和水平空间
2. **增强可扩展性**: 支持更多功能模块而不影响布局
3. **改善视觉层级**: 清晰分离品牌区、导航区、用户区
4. **优化响应式体验**: 桌面/平板/移动端均有最佳表现
5. **保持一致性**: 遵循已有的白色体系设计规范
---
## 2. 设计决策记录
### 2.1 关键选择
| 决策项 | 选择 | 理由 |
|-------|------|------|
| **侧边栏位置** | 左侧 | 符合主流商业系统习惯VS Code、Notion、Slack |
| **标题与用户信息位置** | 侧边栏内 | 整体感强,减少页面元素碎片化 |
| **默认宽度规格** | 展开时 200px / 收起时 56px | 紧凑型设计,最大化主内容区空间 |
| **实现方案** | 方案 A经典固定侧边栏 | 实现简单、性能优、符合企业级应用标准 |
### 2.2 未采用的替代方案
- **方案 B可拖拽调整宽度**: 实现复杂度高,当前需求不需要此功能
- **方案 C全响应式混合导航**: 过度工程化,增加维护成本
- **右侧边栏**: 不符合用户阅读习惯(从左到右)
---
## 3. 整体布局架构
### 3.1 布局结构图
```
桌面端≥768px:
┌─────────────────────────────────────────────────────┐
│ │
│ ┌──────────┬──────────────────────────────────┐ │
│ │ │ │ │
│ │ 侧边栏 │ 主内容区域 │ │
│ │ (200px) │ (剩余所有空间) │ │
│ │ │ │ │
│ │ [品牌区] │ - ReadModule │ │
│ │ [导航项] │ - ManageModule │ │
│ │ [用户区] │ - QAModule │ │
│ │ │ - ExamModule │ │
│ └──────────┴──────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
收起状态56px:
┌────────┬───────────────────────────────────────────┐
│ │ │
│ 56px │ 主内容区域 │
│ │ (宽度 = 视口宽度 - 56px) │
│ 图标 │ │
│ 仅显示 │ │
└────────┴───────────────────────────────────────────┘
移动端(<768px:
┌──────────────────────────┐
│ ☰ 制度文件管理学习AI智能体 │ ← 顶部栏固定48px高
├──────────────────────────┤
│ │
│ │
│ 主内容区域 │ ← 全屏显示
│ │
│ │
└──────────────────────────┘
点击 ☰ 后Overlay模式:
┌────┬─────────────────────┐
│ │ ✕ │
│ 侧 │ │
│ 边 │ 主内容区(暗化) │
│ 栏 │ │
│ │ │
└────┴─────────────────────┘
```
### 3.2 技术实现要点
- **布局方式**: Flexbox外层容器 `display: flex`
- **侧边栏定位**: 固定定位或正常流(`flex-shrink: 0`
- **主内容区**: `flex: 1; overflow: auto`
- **高度控制**: 侧边栏 `height: 100vh`
---
## 4. 侧边栏内部组件设计
### 4.1 组件架构
```
AppSidebar.vue (主容器)
├── SidebarHeader.vue (品牌区)
│ ├── Logo/图标
│ ├── 系统名称
│ └── 折叠按钮
├── SidebarNav.vue (导航菜单)
│ └── SidebarNavItem.vue × N (单个导航项)
│ ├── 图标
│ ├── 文字标签
│ └── Tooltip (收起状态)
├── <div class="sidebar-spacer"> (弹性空间)
└── SidebarUser.vue (用户信息区)
├── 用户头像
├── 用户名 + 角色
└── 操作按钮 (设置/退出)
```
### 4.2 品牌区设计 (SidebarHeader)
#### 展开状态 (200px):
```
┌──────────────────┐
│ 🤖 制度文件管理 │
│ 学习AI智能体 │
│ [←] │
└──────────────────┘
```
#### 收起状态 (56px):
```
┌────┐
│ 🤖 │
│ [→] │
└────┘
```
**组件接口:**
```vue
<SidebarHeader
:collapsed="Boolean"
@toggle="Function"
/>
```
**样式规范:**
```css
.sidebar-header {
height: var(--sidebar-header-height, 64px);
padding: 12px 16px;
display: flex;
flex-direction: column;
justify-content: center;
border-bottom: 1px solid var(--border-sidebar);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
}
.brand-text {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
}
.collapse-btn {
align-self: flex-end;
margin-top: 8px;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.collapse-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
```
---
### 4.3 导航菜单设计 (SidebarNav)
#### 导航项配置数据结构:
```javascript
const navItems = [
{ key: 'read', label: '文件查看', icon: '📄', permission: 'read' },
{ key: 'manage', label: '文件管理', icon: '📁', permission: 'manage' },
{ key: 'qa', label: '知识问答', icon: '💬', permission: 'qa' },
{ key: 'exam', label: '考察训练', icon: '🔍', permission: 'exam' },
{ key: 'mind', label: '纲要学习', icon: '📋', permission: 'mind' },
{ key: 'dashboard', label: '综合看板', icon: '📊', permission: 'dashboard' },
{ key: 'permission', label: '权限管理', icon: '👥', permission: 'permission' }
]
```
#### 视觉状态对比:
| 状态 | 展开时 (200px) | 收起时 (56px) |
|-----|---------------|--------------|
| **默认** | `📄 文件查看` (灰色文字) | `📄` (灰色图标) |
| **悬停** | 浅灰背景 + 深色文字 | 浅灰背景 + Tooltip 显示 "文件查看" |
| **激活** | 蓝色浅背景 + 蓝色粗体文字 + 左侧蓝色指示条 | 蓝色图标 + 左侧蓝色指示条 |
**组件接口:**
```vue
<SidebarNav
:items="Array"
:active-key="String"
:collapsed="Boolean"
@select="Function(key)"
/>
```
**样式规范:**
```css
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
height: var(--sidebar-nav-item-height, 40px);
padding: 0 16px;
margin: 2px 8px;
border-radius: 6px;
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
color: var(--text-secondary);
background: transparent;
}
.nav-item:hover {
background: var(--bg-sidebar-hover, #f5f7fa);
color: var(--text-primary);
}
.nav-item.active {
background: var(--bg-sidebar-active, #e6f4ff);
color: var(--color-primary, #3b82f6);
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--color-primary);
border-radius: 2px;
}
.nav-icon {
font-size: 20px;
line-height: 1;
flex-shrink: 0;
}
.nav-label {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
```
**Tooltip 实现(收起状态):**
```css
.nav-tooltip {
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%) translateX(-4px);
padding: 6px 12px;
background: rgba(26, 26, 46, 0.92);
color: #ffffff;
font-size: 13px;
border-radius: 6px;
white-space: nowrap;
z-index: 1000;
pointer-events: none;
opacity: 0;
transition: all 200ms ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.nav-tooltip::before {
content: '';
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: rgba(26, 26, 46, 0.92);
}
.nav-item:hover .nav-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
```
---
### 4.4 用户信息区设计 (SidebarUser)
#### 展开状态:
```
┌──────────────────┐
│ ┌─┐ │
│ │张│ 系统管理员 │
│ └─┘ 角色:管理员 │
│ ⚙️ 设置 🚪退出 │
└──────────────────┘
```
#### 收起状态:
```
┌────┐
│ 👤 │ ← hover: tooltip "系统管理员"
│ ⚙️ │
│ 🚪 │
└────┘
```
**组件接口:**
```vue
<SidebarUser
:collapsed="Boolean"
:user-info="Object"
:role-label="String"
@logout="Function"
@settings="Function"
/>
```
**样式规范:**
```css
.sidebar-user {
padding: 16px;
border-top: 1px solid var(--border-sidebar);
background: var(--bg-elevated);
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.user-details {
overflow: hidden;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 12px;
color: var(--text-secondary);
}
.user-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.action-btn {
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
```
---
## 5. 交互细节与动画效果
### 5.1 展开/收起切换机制
#### 触发方式:
1. **主要方式**: 点击侧边栏顶部的折叠按钮(箭头图标)
2. **辅助方式**(可选): 键盘快捷键 `Ctrl + B`
#### 状态管理逻辑:
```javascript
// composables/useSidebar.js
import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useSidebar() {
const collapsed = ref(false)
const isMobile = ref(false)
const mobileOpen = ref(false)
// 从 localStorage 恢复状态
onMounted(() => {
const savedState = localStorage.getItem('sidebar-collapsed')
if (savedState !== null) {
collapsed.value = savedState === 'true'
}
// 初始化移动端检测
checkMobile()
window.addEventListener('resize', debounce(checkMobile, 150))
})
// 监听变化并持久化
watch(collapsed, (val) => {
localStorage.setItem('sidebar-collapsed', String(val))
}, { immediate: false })
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
if (isMobile.value && mobileOpen.value) {
mobileOpen.value = false
}
}
const toggleCollapse = () => {
collapsed.value = !collapsed.value
}
const openMobile = () => {
mobileOpen.value = true
}
const closeMobile = () => {
mobileOpen.value = false
}
return {
collapsed,
isMobile,
mobileOpen,
toggleCollapse,
openMobile,
closeMobile
}
}
```
#### 动画时间线:
```
时间轴(展开 → 收起):
0ms ──── 点击折叠按钮 ────→
0-280ms CSS transition: width 200px → 56px
100ms Vue transition: 文字 opacity 1 → 0 (提前消失)
280ms 动画完成,进入完全收起状态
时间轴(收起 → 展开):
0ms ──── 点击展开按钮 ────→
0-280ms CSS transition: width 56px → 200px
180ms Vue transition: 文字 opacity 0 → 1 (延迟出现)
280ms 动画完成,进入完全展开状态
```
#### CSS 过渡定义:
```css
.sidebar {
width: var(--sidebar-width, 200px);
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-easing);
will-change: width;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed-width, 56px);
}
/* 文字淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 200ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 主内容区适配 */
.main-content {
margin-left: var(--sidebar-width, 200px);
transition: margin-left var(--sidebar-transition-duration) var(--sidebar-transition-easing);
will-change: margin-left;
}
.main-content.sidebar-collapsed {
margin-left: var(--sidebar-collapsed-width, 56px);
}
```
---
### 5.2 移动端适配策略
#### 断点定义:
| 设备类型 | 屏幕宽度 | 行为 |
|---------|---------|------|
| **桌面端** | ≥ 1024px | 完整侧边栏200px支持手动折叠 |
| **平板端** | 768px - 1023px | 默认收起56px悬停自动展开 |
| **移动端** | < 768px | 隐藏侧边栏汉堡菜单按钮呼出overlay |
#### 移动端交互流程:
1. **初始状态**: 侧边栏隐藏,左上角显示 ☰ 按钮
2. **打开侧边栏**: 点击 ☰ → 侧边栏从左侧滑入 + 半透明遮罩层
3. **使用导航**: 点击导航项 → 切换模块 + 自动关闭侧边栏
4. **关闭侧边栏**:
- 点击遮罩层
- 点击 ✕ 关闭按钮
- 在侧边栏内向右滑动(可选手势)
#### 移动端样式实现:
```css
@media (max-width: 767px) {
.mobile-menu-btn {
position: fixed;
top: 12px;
left: 12px;
z-index: 1002;
width: 40px;
height: 40px;
border: none;
background: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 1001;
transform: translateX(-100%);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-lg);
}
.sidebar.mobile-open {
transform: translateX(0);
}
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease;
}
.mobile-overlay.active {
opacity: 1;
pointer-events: auto;
}
.main-content {
margin-left: 0 !important;
}
}
```
---
## 6. 性能优化策略
### 6.1 渲染性能优化
| 优化技术 | 应用场景 | 预期收益 |
|---------|---------|---------|
| **GPU 加速动画** | 侧边栏宽度过渡、主内容区 margin 过渡 | 避免 layout thrashing |
| **will-change 提示** | `.sidebar`, `.main-content` 元素 | 提前创建合成层 |
| **事件防抖** | resize 事件监听器 | 减少不必要的重计算 |
| **被动监听** | touch 事件、scroll 事件 | 提升滚动流畅度 |
| **按需加载** | MobileMenuButton 组件 | 减少首屏加载体积 |
### 6.2 内存优化
- **避免内存泄漏**: 在 `onUnmounted` 中移除事件监听器
- **合理使用 ref/computed**: 避免不必要的响应式依赖
- **虚拟列表**(未来扩展): 如果导航项超过 20 个,使用虚拟滚动
### 6.3 可访问性 (Accessibility)
- **键盘导航**: 支持 Tab 键切换导航项Enter 键激活
- **ARIA 标签**: 为侧边栏添加 `role="navigation"``aria-label`
- **焦点管理**: 打开/关闭侧边栏时正确管理焦点陷阱
- **屏幕阅读器**: 为图标添加 `aria-label` 或隐藏的文字标签
- **颜色对比度**: 确保所有文本满足 WCAG 2.1 AA 标准4.5:1
---
## 7. 文件结构与组件清单
### 7.1 新增文件
```
src/
├── components/
│ └── layout/
│ ├── AppSidebar.vue # 侧边栏主组件(~200行
│ ├── SidebarHeader.vue # 品牌区组件(~80行
│ ├── SidebarNav.vue # 导航菜单容器(~60行
│ ├── SidebarNavItem.vue # 单个导航项(~100行
│ ├── SidebarUser.vue # 用户信息区(~120行
│ └── MobileMenuButton.vue # 移动端汉堡按钮(~40行
├── composables/
│ └── useSidebar.js # 侧边栏状态管理(~80行
└── utils/
└── debounce.js # 防抖工具函数(~15行
```
### 7.2 修改文件
```
src/
├── views/
│ └── Home.vue # 重构:移除 header引入侧边栏布局
├── design-tokens.css # 扩展:添加侧边栏相关 CSS 变量
└── style.css # 更新:可能需要微调全局样式
```
### 7.3 组件职责划分
| 组件 | 职责 | Props | Events |
|-----|------|-------|--------|
| **AppSidebar** | 侧边栏主容器,协调子组件 | `collapsed`, `navItems`, `activeKey`, `userInfo`, `isMobile`, `mobileOpen` | `update:collapsed`, `navigate`, `logout`, `settings` |
| **SidebarHeader** | 显示品牌标识和折叠按钮 | `collapsed` | `toggle` |
| **SidebarNav** | 渲染导航项列表 | `items[]`, `activeKey`, `collapsed` | `select(key)` |
| **SidebarNavItem** | 单个导航项的展示和交互 | `item{key,label,icon}`, `active`, `collapsed` | `click` |
| **SidebarUser** | 显示用户信息和操作按钮 | `collapsed`, `userInfo{}`, `roleLabel` | `logout`, `settings` |
| **MobileMenuButton** | 移动端的菜单触发按钮 | 无 | `click` |
---
## 8. 样式变量扩展
在现有的 `src/design-tokens.css` 中添加以下变量:
```css
/* ========== 侧边栏系统 ========== */
:root {
/* 尺寸规范 */
--sidebar-width: 200px;
--sidebar-collapsed-width: 56px;
--sidebar-nav-item-height: 40px;
--sidebar-header-height: 64px;
--sidebar-user-height: auto;
/* 颜色规范 */
--bg-sidebar: #ffffff;
--bg-sidebar-hover: #f5f7fa;
--bg-sidebar-active: #e6f4ff;
--border-sidebar: #e5e7eb;
/* 动画规范 */
--sidebar-transition-duration: 280ms;
--sidebar-transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
/* 响应式断点 */
--breakpoint-mobile: 768px;
--breakpoint-tablet: 1024px;
/* 圆角规范 */
--radius-nav-item: 6px;
--radius-tooltip: 6px;
/* 阴影规范 */
--shadow-sidebar: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-mobile-overlay: 0 4px 16px rgba(0, 0, 0, 0.12);
}
```
---
## 9. 测试策略
### 9.1 单元测试
- **useSidebar composable**:
- 测试初始状态从 localStorage 正确恢复
- 测试 `toggleCollapse` 函数切换状态
- 测试移动端检测逻辑
- 测试状态变更后正确保存到 localStorage
- **各组件渲染测试**:
- 测试展开/收起状态下正确的 DOM 结构
- 测试 props 正确传递给子组件
- 测试 events 正确触发
### 9.2 集成测试
- **Home.vue 集成**:
- 测试侧边栏与主内容区的协同工作
- 测试导航切换功能是否正常
- 测试用户登出流程
### 9.3 视觉回归测试
- 使用 Playwright 或 Cypress 进行截图对比
- 测试不同断点下的布局表现
- 验证动画流畅性
### 9.4 手动测试清单
- [ ] 桌面端:侧边栏正常展开/收起
- [ ] 桌面端:点击导航项正确切换模块
- [ ] 桌面端:刷新页面后保持上次的状态
- [ ] 平板端:默认收起,悬停显示 tooltip
- [ ] 移动端:汉堡菜单按钮可见且可点击
- [ ] 移动端:侧边栏以 overlay 模式滑出
- [ ] 移动端:点击遮罩层可关闭侧边栏
- [ ] 键盘导航Tab 键可在导航项间切换
- [ ] 无障碍:屏幕阅读器可正确朗读导航项
---
## 10. 迁移计划与风险控制
### 10.1 分阶段实施
**Phase 1: 基础设施搭建(预计 2 小时)**
- 创建组件目录结构
- 实现 `useSidebar` composable
- 扩展 `design-tokens.css`
**Phase 2: 组件开发(预计 3 小时)**
- 实现 AppSidebar 及其子组件
- 实现展开/收起动画
- 实现 Tooltip 功能
**Phase 3: 集成与适配(预计 2 小时)**
- 修改 Home.vue移除旧 header
- 集成新的侧边栏布局
- 实现移动端响应式
**Phase 4: 测试与优化(预计 1 小时)**
- 手动测试所有场景
- 性能优化
- 修复边界情况 bug
**总预计工时**: ~8 小时
### 10.2 风险识别与缓解
| 风险 | 可能性 | 影响 | 缓解措施 |
|-----|-------|------|---------|
| 与现有样式冲突 | 中 | 高 | 使用 scoped styles + BEM 命名 |
| 移动端兼容性问题 | 低 | 中 | 充分测试主流设备 |
| 性能问题(大量 DOM 操作) | 低 | 中 | 使用 Vue 的 transition 组件 |
| 用户习惯改变导致困惑 | 中 | 低 | 提供引导提示(首次使用) |
| localStorage 不可用 | 极低 | 低 | try-catch 包裹,降级处理 |
### 10.3 回滚方案
如果新版本出现严重问题,可以通过 Git 快速回滚到上一个稳定版本:
```bash
git revert <commit-hash>
# 或
git reset --hard <previous-stable-commit>
```
建议在合并前打 tag 以便快速回滚:
```bash
git tag -a v1.0-before-sidebar-refactor -m "Pre sidebar refactor"
```
---
## 11. 成功标准
### 11.1 功能完整性
- [x] 所有 7 个功能模块均可通过侧边栏访问
- [x] 展开/收起功能正常工作
- [x] 状态记忆功能正常localStorage
- [x] Tooltip 在收起状态下正确显示
- [x] 移动端 overlay 模式正常工作
### 11.2 性能指标
- [x] 展开/收起动画帧率 ≥ 60fps
- [x] 首屏加载时间增加 < 200ms
- [x] 内存占用增加 < 5MB
- [x] 无明显的布局抖动CLS < 0.1
### 11.3 用户体验指标
- [x] 用户可在 3 秒内理解如何使用侧边栏
- [x] 导航切换操作步骤 ≤ 2 步
- [x] 视觉风格与整体白色体系一致
- [x] 不同屏幕尺寸下均表现良好
---
## 12. 未来迭代方向(超出本次范围)
### 12.1 短期增强(可选)
1. **拖拽调整宽度**(方案 B 特性)
2. **多级嵌套菜单**(如果功能模块有子分类)
3. **搜索框集成**(在侧边栏顶部添加全局搜索)
4. **通知徽章**(在导航项右上角显示未读数量)
### 12.2 中期优化
1. **主题定制**(深色模式、自定义配色)
2. **键盘快捷键完整支持**
3. **手势导航增强**(边缘滑动、长按预览)
4. **国际化i18n支持**
### 12.3 长期规划
1. **插件系统**(允许第三方扩展侧边栏功能)
2. **AI 辅助导航**(根据使用频率智能排序)
3. **跨应用同步**(多标签页间同步侧边栏状态)
---
## 附录 A: 参考资料
- [Ant Design Pro 侧边栏布局](https://pro.ant.design/layout/)
- [Vue 3 Composition API 文档](https://vuejs.org/guide/extras/composition-api-faq.html)
- [WCAG 2.1 可访问性指南](https://www.w3.org/WAI/WCAG21/quickref/)
- [Material Design Navigation Drawer](https://material.io/components/navigation-drawer)
---
## 附录 B: 术语表
| 术语 | 定义 |
|-----|------|
| **Sidebar** | 侧边栏,垂直排列的导航面板 |
| **Collapsed** | 收起状态,仅显示图标 |
| **Expanded** | 展开状态,显示图标+文字 |
| **Overlay** | 遮罩层,半透明背景覆盖主内容区 |
| **Tooltip** | 工具提示,鼠标悬停时显示的文字说明 |
| **Responsive** | 响应式,适应不同屏幕尺寸 |
| **localStorage** | 浏览器本地存储,用于持久化用户偏好 |
---
**文档维护者**: AI Assistant
**最后更新**: 2026-05-14
**下次评审日期**: 实施完成后

View File

@@ -0,0 +1,745 @@
# 题目管理系统设计方案
**日期**: 2026-05-16
**基于**: 前端优化方案与设计规范.md
**模式**: 集成式设计(生成→待审核列表→操作)
---
## 一、系统架构
### 1.1 数据流图
```
[用户操作]
[GeneratePanel.vue]
├─ 配置区域 (文件选择/题型/难度)
├─ 生成按钮 → POST /api/exam/generate
[待审核列表区域]
├─ 初始化: GET /api/question/pending (自动加载)
├─ 搜索/筛选
├─ 题目卡片展示
│ ├─ 审批: PUT /api/question/review/{id}
│ ├─ 编辑: PUT /api/question/{id}
│ └─ 发布: PUT /api/question/review/{id} (status=published)
└─ 批量操作
```
### 1.2 状态管理
```javascript
// useQuestionManagement.js (新增composable)
const state = reactive({
// 生成相关
isGenerating: false,
generatedCount: 0,
// 列表相关
pendingQuestions: [], // 待审核题目列表
loading: false,
total: 0,
currentPage: 1,
pageSize: 10,
// 筛选条件
searchKeyword: '',
statusFilter: 'all', // all/pending/approved/rejected/published
typeFilter: 'all', // all/single_choice/multiple_choice/...
// 编辑状态
editingQuestion: null, // 当前编辑的题目
showEditModal: false,
// 批量操作
selectedIds: [], // 选中的题目ID
})
```
### 1.3 接口调用规划
| 功能 | 方法 | 接口 | 触发时机 |
|------|------|------|---------|
| **生成题目** | `POST` | `/api/exam/generate` | 点击"开始生成"按钮 |
| **获取待审核列表** | `GET` | `/api/question/pending` | 页面加载/生成完成/刷新 |
| **搜索题目** | `GET` | `/api/question/pending?keyword=xxx` | 输入搜索关键词 |
| **筛选题目** | `GET` | `/api/question/pending?status=xxx&type=xxx` | 选择筛选条件 |
| **审批通过** | `PUT` | `/api/question/review/{id}?status=approved` | 点击"通过"按钮 |
| **驳回题目** | `PUT` | `/api/question/review/{id}?status=rejected&comment=xxx` | 点击"驳回"+填写原因 |
| **编辑题目** | `PUT` | `/api/question/{id}` | 点击"编辑"→修改→保存 |
| **发布题目** | `PUT` | `/api/question/review/{id}?status=published` | 点击"发布"按钮 |
| **批量审批** | `PUT` | `/api/question/batch-review` | 选择多条→批量操作 |
| **删除题目** | `DELETE` | `/api/question/{id}` | 点击"删除" |
---
## 二、UI组件设计遵循设计规范
### 2.1 组件结构树
```
GeneratePanel.vue (主容器)
├── PageHeader.vue (标题栏)
├── StatsRow.vue (统计信息)
├── ContentCard: "智能出题配置"
│ ├── FileSelector (文件选择器)
│ ├── DifficultySelect (难度选择)
│ ├── TypeConfigGrid (题型数量配置)
│ └── GenerateButton (生成按钮)
├── ContentCard: "待审核题目管理"
│ ├── Toolbar (工具栏)
│ │ ├── SearchInput (搜索框)
│ │ ├── StatusFilter (状态筛选)
│ │ ├── TypeFilter (题型筛选)
│ │ └── BatchActions (批量操作按钮组)
│ ├── QuestionList (题目列表)
│ │ └── QuestionCard (题目卡片) *N
│ │ ├── QuestionHeader (题号+类型+状态)
│ │ ├── QuestionContent (题干内容)
│ │ ├── QuestionOptions (选项展示)
│ │ └── ActionButtons (操作按钮组)
│ └── Pagination (分页器)
└── EditModal.vue (编辑弹窗) [可选]
```
### 2.2 题目卡片设计QuestionCard
#### 视觉规范
```css
/* 白色体系 + 设计规范 */
.question-card {
background: #FFFFFF;
border: 1px solid #E9ECEF; /* --border-light */
border-radius: 8px; /* --radius-lg */
box-shadow: 0 1px 2px rgba(0,0,0,0.04); /* --shadow-sm */
padding: 16px; /* --space-lg */
margin-bottom: 12px; /* --space-md */
transition: all 0.15s ease; /* --transition-fast */
}
.question-card:hover {
border-color: #DEE2E6; /* --border-default */
box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* --shadow-md */
}
```
#### 卡片内容布局
```
┌─────────────────────────────────────────────────────┐
│ [☑] 第1题 [单选题] [待审核] 中等难度 │ ← Header
├─────────────────────────────────────────────────────┤
│ 在按价位段自选投放工作指引中,用于评估样本零售客户 │ ← Content
│ 库存水平与销售速度匹配程度的核心公式是? │
│ │
│ A. 客户实际订货数量/订货客户订单提报需求数量*100% │ ← Options
│ B. 样本零售客户期末库存/月销量 │
│ C. 实际零售价格/零售指导价格*100% │
│ D. 特定聚类内市场状态结果按销量加权平均 │
│ │
│ [✓ 通过] [✗ 驳回] [✎ 编辑] [📤 发布] [🗑️ 删除] │ ← Actions
└─────────────────────────────────────────────────────┘
```
#### 状态徽章设计
```css
/* 状态标识 - 仅使用功能色 */
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px; /* --radius-sm */
font-size: 12px; /* --font-sm */
font-weight: 500; /* --weight-medium */
}
.status-pending {
background: #FFF9DB; /* --bg-warning */
color: #E67700; /* --color-warning */
border: 1px solid #FFE066;
}
.status-approved {
background: #EBFBEE; /* --bg-success */
color: #2B8A3E; /* --color-success */
border: 1px solid #B2F2BB;
}
.status-rejected {
background: #FFF5F5; /* --bg-error */
color: #C92A2A; /* --color-error */
border: 1px solid #FFC9C9;
}
.status-published {
background: #E7F5FF; /* --bg-info */
color: #1C7ED6; /* --color-info */
border: 1px solid #A5D8FF;
}
```
### 2.3 操作按钮设计
```css
/* 按钮组 - 遵循设计规范 5.3 */
.action-buttons {
display: flex;
gap: 6px; /* --space-xs + 2px */
margin-top: 12px; /* --space-md */
padding-top: 12px;
border-top: 1px solid #E9ECEF; /* --border-light */
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px; /* 紧凑尺寸 */
border-radius: 6px; /* --radius-md */
font-size: 12px; /* --font-sm */
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.btn-approve {
background: #EBFBEE; /* 浅绿底 */
color: #2B8A3E; /* 绿色文字 */
border-color: #B2F2BB;
}
.btn-approve:hover {
background: #D3F9D8;
}
.btn-reject {
background: #FFF5F5; /* 浅红底 */
color: #C92A2A; /* 红色文字 */
border-color: #FFC9C9;
}
.btn-reject:hover {
background: #FFE3E3;
}
.btn-edit {
background: #FFFFFF;
color: #495057; /* --text-secondary */
border-color: #DEE2E6; /* --border-default */
}
.btn-edit:hover {
background: #F8F9FA; /* --bg-container */
border-color: #ADB5BD;
}
.btn-publish {
background: #E7F5FF; /* 浅蓝底 */
color: #1C7ED6; /* 蓝色文字 */
border-color: #A5D8FF;
}
.btn-publish:hover {
background: #D0EBFF;
}
.btn-delete {
background: #FFFFFF;
color: #C92A2A; /* 红色文字 */
border-color: #FFC9C9;
}
.btn-delete:hover {
background: #FFF5F5;
}
```
### 2.4 工具栏设计Toolbar
```
┌─────────────────────────────────────────────────────┐
│ 🔍 搜索题目... [状态: 全部 ▼] [题型: 全部 ▼] │
│ [✓ 批量通过] [✗ 批量驳回] [刷新] │
└─────────────────────────────────────────────────────┘
```
```css
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #F8F9FA; /* --bg-container */
border: 1px solid #E9ECEF;
border-radius: 8px; /* --radius-lg */
margin-bottom: 16px; /* --space-lg */
}
.search-input {
flex: 1;
max-width: 320px;
height: 36px; /* 表单规范 */
padding: 6px 12px;
border: 1px solid #DEE2E6;
border-radius: 6px;
font-size: 13px; /* --font-md */
}
.filter-select {
height: 36px;
padding: 6px 12px;
border: 1px solid #DEE2E6;
border-radius: 6px;
font-size: 13px;
margin-left: 8px;
}
```
---
## 三、交互流程设计
### 3.1 生成题目流程
```
1. 用户配置参数(文件/题型/难度)
2. 点击"开始生成"按钮
3. 显示loading状态 + 进度提示
4. 调用 POST /api/exam/generate
5. 轮询检查生成状态(已有逻辑)
6. 生成完成后:
- 显示成功提示:"成功生成 X 道题目"
- 自动调用 GET /api/question/pending 刷新列表
- 滚动到待审核列表区域
7. 展示待审核题目列表
```
### 3.2 审批流程
```
单个审批:
1. 用户点击"通过"/"驳回"按钮
2. 弹出确认对话框(驳回时需要填写原因)
3. 调用 PUT /api/question/review/{id}?status=approved/rejected
4. 更新本地状态(乐观更新)
5. 刷新列表数据
批量审批:
1. 用户勾选多个题目复选框
2. 点击"批量通过"/"批量驳回"
3. 弹出确认对话框
4. 循环调用单个接口 或 调用批量接口
5. 刷新列表
```
### 3.3 编辑流程
```
方式1内联编辑推荐简单字段
1. 点击"编辑"按钮
2. 题目卡片变为编辑模式
3. 直接修改题干/选项
4. 点击"保存"/"取消"
方式2弹窗编辑推荐复杂编辑
1. 点击"编辑"按钮
2. 打开 EditModal 弹窗
3. 完整的表单编辑界面
4. 点击"保存"/"取消"
```
---
## 四、响应式与滚动策略
### 4.1 布局原则(解决之前的滚动问题)
```css
/* 主容器 - 允许自然流动,不限制高度 */
.generate-panel {
display: flex;
flex-direction: column;
min-height: 0; /* 允许收缩 */
/* 移除 height: 100% 和 overflow: hidden */
}
/* 配置面板 - 固定不滚动 */
.config-panel {
flex-shrink: 0; /* 不压缩 */
margin-bottom: 16px;
}
/* 结果面板 - 内容可撑开 */
.result-panel {
flex: 1;
display: flex;
flex-direction: column;
/* 不设置 max-height 或 overflow */
}
/* 题目列表 - 自然流动 */
.question-list {
display: flex;
flex-direction: column;
gap: 12px;
/* 不设置 overflow-y: auto */
}
```
**效果**
- ✅ 配置区域固定在顶部
- ✅ 题目列表随内容自然增长
- ✅ 当内容超出视口时,整个页面可滚动
- ✅ 无局部滚动条
### 4.2 分页策略
当题目数量较多时(>20道使用分页
```javascript
const pagination = reactive({
current: 1,
pageSize: 10, // 每页10道
total: 0,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50'],
})
// 分页变化时重新请求
const handlePageChange = async (page, size) => {
pagination.current = page
pagination.pageSize = size
await fetchPendingQuestions()
}
```
---
## 五、错误处理与边界情况
### 5.1 错误处理
| 场景 | 处理方式 |
|------|---------|
| 生成失败 | 显示错误消息,保留配置不变 |
| 网络超时 | 提供重试按钮 |
| 审批接口失败 | 回滚到之前状态,显示错误 |
| 并发冲突 | 提示"数据已被其他人修改",刷新列表 |
| 权限不足 | 显示"无权限操作",隐藏操作按钮 |
### 5.2 空状态处理
```html
<!-- 无待审核题目 -->
<div class="empty-state">
<div class="empty-icon">📋</div>
<p class="empty-title">暂无需审核的题目</p>
<p class="empty-hint">
您可以:<br/>
• 使用上方配置生成新题目<br/>
• 所有题目已审核完毕 ✨
</p>
</div>
```
### 5.3 加载状态
```html
<!-- 加载中 -->
<div class="loading-state">
<a-spin size="large" />
<p>正在加载待审核题目...</p>
</div>
<!-- 生成中 -->
<div class="generating-state">
<a-spin tip="AI正在生成题目请耐心等待..." />
<progress :percent="generateProgress" />
</div>
```
---
## 六、实施计划
### Phase 1: 核心功能优先级P0
**目标**:实现基本的生成+展示+审批流程
1. **修改 useGenerateState.js**
- ✅ 修复接口调用:生成后调用 `/question/pending`
- ✅ 添加 `question_count` 参数
- ✅ 保留现有的轮询逻辑
2. **创建 useQuestionManagement.js**
- 实现 `fetchPendingQuestions()`
- 实现 `approveQuestion(id)`
- 实现 `rejectQuestion(id, comment)`
- 实现搜索/筛选逻辑
3. **重构 GeneratePanel.vue**
- 添加工具栏(搜索/筛选)
- 重构题目列表为卡片式
- 添加操作按钮组
- 实现分页功能
### Phase 2: 编辑功能优先级P1
4. **创建 EditModal.vue**
- 题干编辑器
- 选项编辑器(动态增删)
- 答案设置
- 表单验证
5. **集成编辑功能**
- 调用 `PUT /question/{id}`
- 保存后刷新列表
### Phase 3: 批量操作优先级P2
6. **实现批量选择**
- 复选框
- 全选/反选
- 批量审批接口
### Phase 4: 体验优化优先级P3
7. **性能优化**
- 虚拟滚动(大量题目时)
- 防抖搜索
- 缓存策略
8. **视觉优化**
- 动画过渡
- 拖拽排序
- 快捷键支持
---
## 七、技术要点
### 7.1 关键代码示例
#### 获取待审核题目
```javascript
// useQuestionManagement.js
const fetchPendingQuestions = async () => {
loading.value = true
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
}
if (searchKeyword.value) {
params.keyword = searchKeyword.value
}
if (statusFilter.value !== 'all') {
params.status = statusFilter.value
}
if (typeFilter.value !== 'all') {
params.type = typeFilter.value
}
const res = await questionAPI.getPendingQuestions(params)
pendingQuestions.value = transformQuestionData(res.data?.records || [])
pagination.total = res.data?.total || 0
console.log(`✅ 获取待审核题目: ${pendingQuestions.value.length}`)
} catch (error) {
console.error('❌ 获取待审核题目失败:', error)
message.error('加载题目列表失败')
} finally {
loading.value = false
}
}
```
#### 审批操作
```javascript
const approveQuestion = async (questionId) => {
try {
await questionAPI.reviewQuestion(questionId, 'approved')
message.success('审批通过 ✓')
// 乐观更新立即更新UI
const index = pendingQuestions.value.findIndex(q => q.id === questionId)
if (index !== -1) {
pendingQuestions.value[index].status = 'approved'
}
// 可选:延迟刷新确保数据一致性
setTimeout(() => fetchPendingQuestions(), 500)
} catch (error) {
console.error('❌ 审批失败:', error)
message.error('审批操作失败,请重试')
// 回滚:重新获取数据
await fetchPendingQuestions()
}
}
const rejectQuestion = async (questionId, comment) => {
try {
await questionAPI.reviewQuestion(questionId, 'rejected', comment)
message.success('已驳回')
// 更新本地状态
const index = pendingQuestions.value.findIndex(q => q.id === questionId)
if (index !== -1) {
pendingQuestions.value[index].status = 'rejected'
}
setTimeout(() => fetchPendingQuestions(), 500)
} catch (error) {
message.error('驳回操作失败')
await fetchPendingQuestions()
}
}
```
### 7.2 数据转换函数(复用)
```javascript
// 与 useGenerateState.js 保持一致
const transformQuestionData = (rawQuestions) => {
return rawQuestions.map((q, qIdx) => {
let contentObj = {}
try {
if (typeof q.content === 'string' && q.content.startsWith('{')) {
contentObj = JSON.parse(q.content || '{}')
} else if (typeof q.content === 'object') {
contentObj = q.content
}
} catch (e) {
console.warn(`题目${qIdx} content解析失败:`, e.message)
}
return {
id: q.id,
questionId: q.questionId,
type: String(q.questionType || q.type || '').toLowerCase(),
typeLabel: formatQuestionType(q.questionType),
difficulty: q.difficulty || 2,
score: Number(q.score) || 5,
stem: contentObj.stem || q.stem || (typeof q.content === 'string' ? q.content : ''),
options: formatOptions(contentObj.options || q.options || []),
answer: q.answer || contentObj.answer || '',
status: q.status || 'pending',
reviewerComment: q.reviewerComment || '',
createdAt: q.createdAt,
updatedAt: q.updatedAt,
}
})
}
```
---
## 八、测试场景
### 8.1 功能测试
- [ ] 生成5道题目验证列表显示正确
- [ ] 搜索题目,验证过滤结果准确
- [ ] 按状态筛选(待审核/已通过/已驳回/已发布)
- [ ] 单个审批通过/驳回
- [ ] 批量审批多道题目
- [ ] 编辑题目并保存
- [ ] 发布已通过的题目
- [ ] 删除题目(带确认)
- [ ] 分页切换第1页/第2页/每页20条
### 8.2 边界测试
- [ ] 生成了0道题目空状态显示
- [ ] 生成了100道题目性能测试
- [ ] 同时点击多次生成(防重复提交)
- [ ] 网络断开时的错误处理
- [ ] 权限不足时的UI反馈
### 8.3 UI/UX测试
- [ ] 页面滚动流畅(无局部滚动条)
- [ ] 按钮hover效果正常
- [ ] 加载动画显示正确
- [ ] 响应式布局适配不同屏幕
- [ ] 空状态提示友好
---
## 九、成功标准
### 9.1 功能完整性
✅ 用户可以完整执行以下流程:
1. 选择文件 → 配置参数 → 生成题目
2. 查看待审核题目列表
3. 对每道题目进行审批/编辑/发布
4. 搜索、筛选、分页浏览
### 9.2 用户体验
✅ 符合设计规范:
- 白色体系配色
- 紧凑但清晰的布局
- 一致的组件样式
- 流畅的交互动画
- 友好的错误提示
### 9.3 技术质量
✅ 代码质量:
- 无语法错误
- 接口调用正确
- 状态管理清晰
- 错误处理完善
- 性能可接受
---
## 十、后续扩展方向
1. **题目版本管理**:记录每次编辑的历史版本
2. **批量导入导出**支持Excel格式导入/导出题目
3. **AI辅助审核**AI自动预审人工复核
4. **统计分析**:题目通过率、平均审核时间等
5. **权限细化**:角色权限(管理员/审核员/出题人)
6. **移动端适配**:响应式布局优化手机端体验
---
**文档版本**: v1.0
**最后更新**: 2026-05-16
**状态**: 待用户确认

View File

@@ -0,0 +1,562 @@
# 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)

View File

@@ -0,0 +1,272 @@
# 上传任务列表组件 — 设计规格
> **日期**: 2026-05-29
> **目标**: 在 ReadModule 中嵌入上传任务列表组件,实现文件上传后自动跟踪向量化+出题状态,支持轮询刷新、分页、筛选
---
## 1. 数据源分析
### 1.1 后端 file 表关键字段(来自 `file.sql`
| 字段 | 类型 | 用途 | 可能值 |
|------|------|------|--------|
| `process_step_status` | varchar(32) | **流程状态**(主状态) | `UPLOADED` / `VECTORIZING` / `VECTORIZED` / `VECTORIZE_FAILED` / `EXAM_GENERATING` / `EXAM_GENERATED` / `EXAM_GENERATE_FAILED` / `COMPLETED` |
| `process_step_message` | varchar(512) | **流程步骤信息**(向量化详情) | `"向量化失败: 确保collection存在失败..."` / `"文件不存在于服务器"` / null |
| `exam_status` | varchar(20) | **出题状态** | `UNGENERATED` / `GENERATING` / `GENERATED` / `FAILED` |
| `process_message` | text | **处理消息**(出题详情) | `"生成成功共4道题"` / `"生成失败: 所有题目保存失败..."` / null |
### 1.2 状态映射规则
**向量化状态**(从 `process_step_status` + `process_step_message` 判断):
| process_step_status | 判定结果 | UI 展示 |
|---------------------|----------|---------|
| `UPLOADED` | 待向量化 | ⏳ 灰色 - 等待中 |
| `VECTORIZING` | 向量化中 | 🔄 蓝色 - 处理中(动画) |
| `VECTORIZED` | 向量化成功 | ✅ 绿色 - 成功 |
| `VECTORIZE_FAILED` | 向量化失败 | ❌ 红色 - 失败(显示 process_step_message |
**出题状态**(从 `exam_status` + `process_message` 判断):
| exam_status | 判定结果 | UI 展示 |
|-------------|----------|---------|
| `UNGENERATED` | 未出题 | ⏸ 灰色 - 未开始 |
| `GENERATING` | 出题中 | 🔄 蓝色 - 处理中(动画) |
| `GENERATED` | 出题成功 | ✅ 绿色 - 成功(显示题目数) |
| `FAILED` | 出题失败 | ❌ 红色 - 失败(显示 process_message |
### 1.3 触发条件
- 上传成功后:新上传的文件自动加入任务列表
- 页面加载时:加载最近 N 条有处理状态的文件(非 UPLOADED/COMPLETED 的活跃任务)
- 轮询间隔5 秒一次,仅对"处理中"状态的任务轮询
---
## 2. 组件架构
```
ReadModule.vue
├── 文件列表 Tab已有
├── 文件审批 Tab已有
└── [新增] 上传任务面板 (UploadTaskPanel)
├── 任务头部统计栏(总数 / 处理中 / 成功 / 失败)
├── 筛选工具栏(状态筛选 + 搜索 + 手动刷新按钮)
├── 任务列表(分页展示)
│ └── UploadTaskItem × N
│ ├── 文件基本信息(名称、大小、部门、时间)
│ ├── 向量化状态条
│ └── 出题状态条
└── 分页器
```
### 2.1 文件结构
| 文件 | 职责 | 预估行数 |
|------|------|----------|
| `src/components/exam/composables/useUploadTasks.js` | 任务数据管理、轮询逻辑、状态解析 | ~250 行 |
| `src/components/exam/UploadTaskPanel.vue` | 任务列表面板容器 | ~180 行 |
| `src/components/exam/UploadTaskItem.vue` | 单个任务卡片 | ~150 行 |
| 修改 `src/components/ReadModule.vue` | 嵌入 UploadTaskPanel上传后触发添加任务 | ~30 行改动 |
---
## 3. 各组件详细设计
### 3.1 useUploadTasks.js — Composable
**职责**:所有任务数据的获取、缓存、状态解析、轮询控制。
**导出接口**
```javascript
export function useUploadTasks() {
// 状态
const tasks = ref([]) // 任务列表原始数据
const loading = ref(false)
const polling = ref(false)
// 筛选
const statusFilter = ref('all') // all | vectorizing | vectorized | vector_failed | exam_generating | generated | failed
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
// 统计
const stats = computed(() => ({ total, processing, success, failed }))
const filteredTasks = computed(() => { /* 筛选+分页 */ })
// 核心方法
function addTask(fileData) // 上传成功后调用,将文件加入任务列表
function parseVectorStatus(task) // 解析向量化状态 → { phase, status, label, message, color }
function parseExamStatus(task) // 解析出题状态 → { phase, status, label, message, color }
function startPolling() // 启动定时轮询
function stopPolling() // 停止轮询
function refreshTasks() // 手动刷新
function fetchTasks() // 从后端获取任务列表 API
return { tasks, loading, polling, statusFilter, currentPage, pageSize,
stats, filteredTasks, addTask, parseVectorStatus, parseExamStatus,
startPolling, stopPolling, refreshTasks, fetchTasks }
}
```
**状态解析函数核心逻辑**
```javascript
function parseVectorStatus(task) {
const pss = task.processStepStatus || task.process_step_status || ''
const psm = task.processStepMessage || task.process_step_message || ''
if (['VECTORIZING'].includes(pss))
return { phase: 'vectorize', status: 'processing', label: '向量化中', message: '正在处理...', color: 'info', animating: true }
if (pss === 'VECTORIZED')
return { phase: 'vectorize', status: 'success', label: '向量化完成', message: null, color: 'success' }
if (pss === 'VECTORIZE_FAILED')
return { phase: 'vectorize', status: 'error', label: '向量化失败', message: psm || '未知错误', color: 'error' }
if (['UPLOADED'].includes(pss))
return { phase: 'vectorize', status: 'pending', label: '等待向量化', message: null, color: 'pending' }
if (['EXAM_GENERATING', 'EXAM_GENERATED', 'EXAM_GENERATE_FAILED', 'COMPLETED'].includes(pss))
return { phase: 'vectorize', status: 'success', label: '已入库', message: null, color: 'success' }
return { phase: 'vectorize', status: 'unknown', label: '未知', message: null, color: 'pending' }
}
function parseExamStatus(task) {
const es = task.examStatus || task.exam_status || ''
const pm = task.processMessage || task.process_message || ''
if (es === 'GENERATING')
return { phase: 'exam', status: 'processing', label: '生成题目中...', message: null, color: 'info', animating: true }
if (es === 'GENERATED') {
const match = pm?.match(/共(\d+)道题/)
return { phase: 'exam', status: 'success', label: match ? `生成${match[1]}道题` : '题目生成完成', message: pm, color: 'success' }
}
if (es === 'FAILED')
return { phase: 'exam', status: 'error', label: '出题失败', message: pm || '未知错误', color: 'error' }
if (es === 'UNGENERATED')
return { phase: 'exam', status: 'pending', label: '未出题', message: null, color: 'pending' }
return { phase: 'exam', status: 'unknown', label: '-', message: null, color: 'pending' }
}
```
**轮询策略**
- 仅当存在 `status === 'processing' && animating === true` 的任务时启动轮询
- 调用 `fileAPI.getFiles({ pageNum: 1, pageSize: 50 })` 获取最新数据
- 对比 `process_step_status``exam_status` 变化,更新对应任务
- 全部任务完成后自动停止轮询
- 用户关闭面板或切换 Tab 时停止轮询
### 3.2 UploadTaskPanel.vue — 面板容器
**Props**: 无(内部使用 composable
**布局**
```
┌─────────────────────────────────────────────┐
│ 📋 上传任务 [🔄 刷新] │
├─────────────────────────────────────────────┤
│ 全部(12) 处理中(3) 成功(7) 失败(2) │ ← 统计标签栏
├─────────────────────────────────────────────┤
│ [状态筛选 ▾] [🔍 搜索...] │ ← 工具栏
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ 📄 longrule.docx 采购部门 │ │
│ │ 14KB · 2026-05-29 12:01 │ │
│ │ ✅ 向量化完成 │ │ ← 向量化状态条
│ │ ✅ 生成4道题 │ │ ← 出题状态条
│ └─────────────────────────────────────┘ │
│ ... │
│ [< 1 2 >] 共12条 │ ← 分页
└─────────────────────────────────────────────┘
```
**样式规范**:全部使用 design-tokens.css 变量,与 ReadModule 现有风格一致(白色扁平设计)。
### 3.3 UploadTaskItem.vue — 任务卡片
**Props**:
```javascript
{
task: Object, // 原始任务数据file 表记录)
vectorStatus: Object, // parseVectorStatus() 返回值
examStatus: Object // parseExamStatus() 返回值
}
```
**每个状态条的视觉设计**
**状态条通用结构**
```
┌────────────────────────────────────────────┐
│ [图标] 状态文字 详情/错误消息 │
└────────────────────────────────────────────┘
```
| 状态 | 图标 | 文字颜色 | 背景 | 特殊效果 |
|------|------|----------|------|----------|
| pending等待 | ⏸ 时钟 | --text-disabled | --bg-container | 无 |
| processing处理中 | 🔄 spinner | --color-info | --bg-info | 脉冲动画 |
| success成功 | ✓ CheckCircleOutlined | --color-success | --bg-success | 无 |
| error失败 | ✗ CloseCircleOutlined | --color-error | --bg-error | 可展开查看错误消息 |
| unknown未知 | - 问号 | --text-disabled | --bg-container | 无 |
**错误消息展开**:点击 error 状态条可展开/收起完整的 `process_step_message``process_message` 文本。
---
## 4. 与 ReadModule 集成方式
### 4.1 位置
在 ReadModule 的 Tab 栏下方、内容区上方,以可折叠面板形式呈现。默认折叠,上传后自动展开。
### 4.2 上传成功后联动
`handleUploadSubmit()` 成功回调中:
```javascript
// 上传成功后
const result = await fileAPI.uploadFile(formData)
if (result.data.code === 200) {
const newFile = result.data.data
uploadTasks.addTask(newFile) // 添加到任务列表
uploadTasks.startPolling() // 启动轮询
showUploadTaskPanel.value = true // 展开任务面板
}
```
### 4.3 生命周期
- `onMounted`: 如果有未完成的任务,自动加载并启动轮询
- `onUnmounted`: 停止轮询清理定时器
- 切换到其他 Tab: 暂停轮询(可选)
---
## 5. API 调用
复用已有的 `fileAPI.getFiles(params)` 接口,参数:
```javascript
{ pageNum: 1, pageSize: 100 } // 获取足够多的记录用于前端筛选
```
无需新增后端接口。通过 `process_step_status``exam_status` 字段在前端做状态判断。
---
## 6. 筛选与分页
### 6.1 状态筛选选项
| 筛选值 | 匹配条件 |
|--------|----------|
| `all` | 显示全部 |
| `processing` | 向量化中 OR 出题中 |
| `success` | 向量化成功 AND (未出题 OR 出题成功) |
| `failed` | 向量化失败 OR 出题失败 |
| `pending` | UPLOADED 或 UNGENERATED 且无进行中的步骤 |
### 6.2 分页
前端分页,与现有 `paginatedDocuments` 模式一致。

View File

@@ -0,0 +1,875 @@
# AI对话链接跳转与引用高亮优化 - 技术设计文档
**文档版本**: v1.0
**创建日期**: 2026-05-30
**状态**: ✅ 已批准,待实施
**方案选择**: 方案B - 统一搜索引擎重构
---
## 1. 项目背景与问题定义
### 1.1 当前问题
在AI智能问答模块QAModule.vue用户点击对话消息中的引用来源或[ref:xxx]标签后:
1. **PDF文件**:只能跳转到指定页码,**无法高亮显示关键词位置**
2. **Word/Excel等文件**:使用`indexOf精确匹配`,关键词截断后容易匹配失败
3. **时序问题**文件DOM未完全渲染就执行搜索导致定位失败
4. **用户体验差**:无搜索结果反馈、无导航控件、高亮效果短暂
### 1.2 业务影响
- ❌ 用户无法快速定位到引用的具体内容位置
- ❌ 需要手动翻页查找,效率低下
- ❌ 多个匹配项时无法逐个浏览
- ❌ 降低知识管理系统的易用性和专业性
### 1.3 目标用户
- 企业员工(制度文件查阅者)
- HR/行政人员(制度发布者)
- 管理员(系统维护者)
### 1.4 成功标准
✅ 点击任意格式的引用 → 自动跳转 + 持续高亮显示
✅ 支持模糊匹配容错率≥90%
✅ 提供上/下一处导航功能
✅ 高亮效果美观且持久(手动关闭前不消失)
✅ 响应时间 < 2秒
---
## 2. 解决方案架构
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────┐
│ QAModule.vue │
│ [引用点击] → navigateToFileReader(ref) │
│ ↓ │
│ 提取: { fileId, filename, page, context, rawData } │
└──────────────────────┬──────────────────────────────┘
↓ router.push(query参数)
┌─────────────────────────────────────────────────────┐
│ ReaderPage.vue │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ UniversalSearchEngine (新增) │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │ │
│ │ │ Text │ │ Fuzzy │ │ Highlight │ │ │
│ │ │Extractor│ │ Search │ │ Renderer │ │ │
│ │ │ (提取) │ │ Engine │ │ (渲染) │ │ │
│ │ └────┬────┘ └────┬────┘ └─────┬──────┘ │ │
│ │ └──────────┼──────────┘ │ │
│ │ ↓ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ SearchCoordinator │ │ │
│ │ │ (流程协调器) │ │ │
│ │ └────────┬─────────┘ │ │
│ └───────────────┼───────────────────────┘ │
│ ↓ │
│ SearchResult[] + UI Navigation Bar │
└─────────────────────────────────────────────────────┘
```
### 2.2 核心技术选型
| 组件 | 技术方案 | 版本 | 选型理由 |
|------|---------|------|---------|
| **模糊搜索引擎** | Fuse.js | 7.0.0 | 轻量(10KB)、支持中文、零依赖 |
| **文本提取** | DOM TreeWalker API | 原生 | 无需依赖、浏览器原生支持 |
| **高亮渲染** | Range + Custom Elements | 原生 | 精确控制、性能优秀 |
| **PDF处理** | PDF.js textLayer | 已集成 | 复用现有pdfjs-viewer |
---
## 3. 模块详细设计
### 3.1 模块一TextExtractor文本提取器
**职责**从不同格式文件的DOM中提取结构化文本数据
**文件路径**`src/utils/textExtractor.js`
**核心方法**
```typescript
class TextExtractor {
// 主入口根据fileType分发到不同的提取策略
async extract(fileType: string, container: HTMLElement): Promise<Document[]>
// PDF文本提取从iframe的.textLayer提取
async extractPdfText(iframe: HTMLIFrameElement): Promise<PdfPage[]>
// DOCX/DOC文本提取从.docx-wrapper提取分页
extractDocxText(container: HTMLElement): DocxPage[]
// Excel/CSV文本提取从表格单元格提取
extractExcelText(container: HTMLElement): TableCell[]
// 通用DOM文本提取TreeWalker遍历
extractDomText(container: HTMLElement): TextNode[]
}
```
**数据结构**
```typescript
interface Document {
text: string // 纯文本内容
type: 'text' | 'page' | 'cell'
location?: {
page?: number // 页码PDF/DOCX
cellIndex?: [number, number] // 单元格坐标 [row, col]
}
element: HTMLElement // 对应的DOM元素引用
}
```
**关键实现细节**
- PDF遍历`.textLayer > span`元素,按`data-page`属性分组
- DOCX查找`.docx-wrapper > div`作为页面容器
- Excel遍历`table tr td`,记录行列索引
- 通用:使用`TreeWalker(SHOW_TEXT)`过滤有意义的文本节点长度≥2
---
### 3.2 模块二FuzzySearchEngine模糊搜索引擎
**职责**基于Fuse.js实现智能模糊匹配和相关性排序
**文件路径**`src/utils/fuzzySearchEngine.js`
**核心配置**
```javascript
const fuseConfig = {
threshold: 0.4, // 匹配阈值0=精确, 1=宽松)
distance: 100, // 模式匹配的最大距离
includeScore: true, // 返回评分
includeMatches: true, // 返回匹配位置信息
minMatchCharLength: 2, // 最小匹配字符数
tokenize: true, // 启用分词模式
tokenSeparator: /[\s\p{P}]+/u, // 中英文分词正则
keys: ['text'] // 搜索字段
}
```
**核心方法**
```typescript
class FuzzySearchEngine {
// 初始化索引(每次加载新文档时调用)
initIndex(documents: Document[]): void
// 执行搜索
search(keyword: string, options?: SearchOptions): SearchResult[]
// 关键词预处理(提升中文匹配率)
preprocessKeyword(keyword: string): string
// 格式化原始结果为统一格式
formatResult(rawResult: FuseResult, index: number): SearchResult
}
```
**输出数据结构**
```typescript
interface SearchResult {
id: string // 唯一标识
type: 'text' | 'pdf' | 'table'
score: number // 相似度评分 (0-1, 越高越匹配)
matchedText: string // 匹配到的文本片段
context: string // 上下文前后各50字符
location: {
container?: HTMLElement
startOffset?: number
endOffset?: number
pageNumber?: number // PDF专用
rect?: { x, y, width, height } // PDF专用
cellIndex?: [number, number] // 表格专用
}
originalData: Document // 原始文档数据(用于高亮渲染)
}
```
**中文优化策略**
1. 预处理阶段去除中文标点符号(""''【】《》())
2. 使用Unicode属性转义`\p{P}`匹配所有标点
3. 分词时按空格和标点切分
4. 设置合理的threshold0.4)平衡精准度和召回率
---
### 3.3 模块三HighlightRenderer高亮渲染器
**职责**在文档DOM中创建、管理和销毁高亮标记
**文件路径**`src/utils/highlightRenderer.js`
**核心能力**
```typescript
class HighlightRenderer {
// 渲染所有搜索结果的高亮
renderAll(results: SearchResult[]): number
// 创建单个高亮根据type分发
private createHighlight(result: SearchResult, index: number): Highlight
// DOM类型高亮text/docx/markdown/html等
private createDomHighlight(result, index): DomHighlight
// PDF类型高亮在iframe中创建overlay层
private createPdfHighlight(result, index): PdfHighlight
// 表格类型高亮Excel/CSV单元格
private createTableHighlight(result, index): TableHighlight
// 导航控制
navigateTo(index: number): void
next(): void
prev(): void
// 清除所有高亮
clearAll(): void
}
```
**高亮样式规范**
#### DOM高亮.search-highlight
```css
.search-highlight {
background: linear-gradient(135deg, #fff3cd 0%, #ffe69c 100%);
border-bottom: 2px solid #ffc107;
border-radius: 2px;
padding: 1px 2px;
box-shadow: 0 1px 3px rgba(255, 193, 7, 0.3);
cursor: pointer;
}
.search-highlight.active {
background: linear-gradient(135deg, #ffd43b 0%, #fab005 100%);
border-bottom: 3px solid #f59f00;
animation: highlight-glow 2s ease-in-out infinite;
}
```
#### PDF高亮.pdf-highlight-overlay
- 使用绝对定位的div覆盖在文本上方
- 背景色:`rgba(255, 235, 59, 0.3)`
- 边框:`2px solid #ffc107`
- 包含角标显示序号
#### 表格高亮(.table-highlight
- 绿色系配色(区别于文本黄色)
- 背景:`linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%)`
- 边框:`2px solid #28a745`
**交互特性**
- ✅ 点击高亮标记可跳转到该位置
- ✅ 当前激活项带脉冲发光动画
- ✅ 序号标签1, 2, 3...)便于识别
- ✅ hover效果轻微放大+阴影加深)
---
### 3.4 模块四SearchCoordinator协调控制器
**职责**:编排整个搜索→定位→高亮的完整流程,处理异常和重试
**文件路径**`src/utils/searchCoordinator.js`
**核心流程**
```
execute(params)
Step 1: 文本提取 (withRetry, 最多重试3次)
Step 2: 页码过滤如果指定了targetPage
Step 3: 初始化Fuse.js索引 → 执行搜索
Step 4: 渲染高亮 → 导航到第一个结果
返回: { success, resultCount, stats }
```
**重试机制**
```typescript
async withRetry<T>(fn: () => T, maxRetries: number, delayMs: number): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
if (i < maxRetries - 1) {
await sleep(delayMs) // 等待DOM渲染完成
} else {
throw error // 最后一次重试失败则抛出异常
}
}
}
}
```
**错误处理**
- 文本提取失败 → 返回友好提示"无法提取文档内容"
- 未找到匹配 → 返回"未找到XXX相关内容"
- 高亮渲染部分失败 → 记录警告日志,继续渲染其他结果
- 全局异常 → 显示错误提示,不阻断用户操作
**性能统计**
```typescript
interface SearchStats {
totalTime: number // 总耗时(ms)
extractionTime: number // 文本提取耗时
searchTime: number // 搜索耗时
renderTime: number // 高亮渲染耗时
resultCount: number // 结果数量
}
```
---
## 4. 集成方案
### 4.1 QAModule.vue修改点
**文件路径**`src/components/QAModule.vue`
**修改函数**`navigateToFileReader()` (第1126行)
**改动内容**
```javascript
// 优化关键词提取逻辑第1159-1175行
const keyword = ''
// 优先级1: ref.context引用上下文
if (ref.context) {
keyword = ref.context.substring(0, 150) // 增加长度限制至150字符
}
// 优先级2: ref.rawData中的多个字段
else if (ref.rawData) {
const raw = ref.rawData
keyword = raw.content || raw.context || raw.preview ||
raw.excerpt || raw.query || raw.question || ''
if (keyword) keyword = keyword.substring(0, 150)
}
// 优先级3: ref.location中的文本描述
if (!keyword && ref.location && ref.location.length > 4) {
const locText = ref.location.replace(/第\d+页|page\s*\d+/gi, '').trim()
if (locText.length >= 4) keyword = locText.substring(0, 120)
}
// 清洗关键词
if (keyword) {
keyword = cleanSearchKeyword(keyword)
if (keyword.length < 4) keyword = '' // 最小长度要求降至4字符
}
```
**改动理由**
- 增加关键词长度限制100→150提高匹配成功率
- 扩展rawData字段检查范围
- 降低最小长度要求4字符适应短文本场景
---
### 4.2 ReaderPage.vue集成
**文件路径**`src/views/ReaderPage.vue`
#### 4.2.1 新增imports
```javascript
import searchCoordinator from '@/utils/searchCoordinator'
// 新增响应式变量
const showNavigationControls = ref(false)
const searchResultCount = ref(0)
const currentHighlightIndex = ref(0)
const searchKeywordPreview = ref('')
```
#### 4.2.2 修改onMounted逻辑
```javascript
onMounted(async () => {
// ... 现有代码保持不变 ...
if (fileId) {
await loadFile(fileId, fileTitle, fileExtension, page)
// ✨ 新增:自动执行搜索高亮
if (keyword?.trim()) {
const renderDelays = {
pdf: 1000,
docx: 1500,
pptx: 1200,
xlsx: 800,
default: 500
}
const delay = renderDelays[fileType.value] || renderDelays.default
setTimeout(async () => {
const container = scrollContainerRef.value
if (!container) return
const result = await searchCoordinator.execute({
keyword,
fileType: fileType.value,
container,
targetPage: page
})
if (result.success) {
showNavigationControls.value = true
searchResultCount.value = result.resultCount
searchKeywordPreview.value = keyword.substring(0, 20) + '...'
message.success({
content: `找到 ${result.resultCount} 处匹配内容`,
duration: 3
})
} else {
message.warning({
content: result.message || '未找到相关内容',
duration: 3
})
}
}, delay)
}
}
})
```
#### 4.2.3 新增UI模板搜索导航栏
```html
<!-- 在reader-container内部、底部添加 -->
<Transition name="slide-up">
<div v-if="showNavigationControls" class="search-nav-bar">
<div class="nav-info">
<SearchOutlined class="nav-icon" />
<span>找到 <strong>{{ searchResultCount }}</strong> 处匹配</span>
<span class="keyword-preview">"{{ searchKeywordPreview }}"</span>
</div>
<div class="nav-actions">
<button
class="nav-btn"
@click="prevHighlight"
:disabled="currentHighlightIndex <= 0"
>
<UpOutlined /> 上一处
</button>
<span class="nav-counter">
{{ currentHighlightIndex + 1 }} / {{ searchResultCount }}
</span>
<button
class="nav-btn"
@click="nextHighlight"
:disabled="currentHighlightIndex >= searchResultCount - 1"
>
下一处 <DownOutlined />
</button>
<button class="nav-btn close-btn" @click="closeSearch">
<CloseOutlined />
</button>
</div>
</div>
</Transition>
```
#### 4.2.4 新增事件监听
```javascript
// 监听高亮导航事件由HighlightRenderer触发
onMounted(() => {
window.addEventListener('highlightNavigate', (e) => {
const { currentIndex, total } = e.detail
currentHighlightIndex.value = currentIndex
searchResultCount.value = total
})
})
onUnmounted(() => {
window.removeEventListener('highlightNavigate')
searchCoordinator.clearAllHighlights() // 清理高亮
})
```
#### 4.2.5 新增方法
```javascript
// 导航控制方法
const nextHighlight = () => {
searchCoordinator.nextHighlight()
}
const prevHighlight = () => {
searchCoordinator.prevHighlight()
}
const closeSearch = () => {
searchCoordinator.clearAllHighlights()
showNavigationControls.value = false
currentHighlightIndex.value = 0
searchResultCount.value = 0
}
```
---
## 5. CSS样式规范
### 5.1 高亮基础样式
已在"3.3 HighlightRenderer"章节详细定义,此处补充动画和导航栏样式。
### 5.2 动画效果
```css
/* 脉冲发光动画(当前激活项) */
@keyframes highlight-glow {
0%, 100% { box-shadow: 0 3px 8px rgba(245, 159, 0, 0.6); }
50% { box-shadow: 0 4px 16px rgba(245, 159, 0, 0.9), 0 0 20px rgba(245, 159, 0, 0.4); }
}
/* 缩放脉冲(首次出现) */
@keyframes highlight-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
```
### 5.3 搜索导航栏
固定在阅读器底部中央,包含:
- 左侧:搜索图标 + 匹配数量 + 关键词预览
- 中间:当前位置计数器(如 "2 / 5"
- 右侧:上一处 / 下一处 / 关闭按钮
详见设计文档第3.3节的CSS代码。
---
## 6. 数据流与时序
### 6.1 完整交互时序图
```
用户点击引用来源组件
[QAModule.vue] navigateToFileReader(ref)
├─ 提取 filename, page, context/rawData
├─ 清洗生成 keyword (≤150字符)
└─ router.push({ query: { id, title, extension, page, keyword, from: 'qa' } })
[Vue Router] 路由跳转到 /reader
[ReaderPage.vue] onMounted()
├─ 解析路由参数 (id, page, keyword...)
├─ loadFile(id, title, extension, page)
│ ├─ 调用API获取文件Blob
│ ├─ 根据fileType渲染文档 (PDF/Word/Excel/...)
│ └─ hasFileLoaded = true
└─ setTimeout(delay) ← 等待DOM渲染完成
[searchCoordinator.execute()]
├─ Step 1: textExtractor.extract(fileType, container)
│ ├─ withRetry(fn, 3, 200ms) ← 重试机制
│ └─ 返回 documents[]
├─ Step 2: 过滤目标页码(可选)
├─ Step 3: fuzzySearchEngine
│ ├─ initIndex(documents)
│ ├─ preprocessKeyword(keyword)
│ └─ search() → results[]
├─ Step 4: highlightRenderer.renderAll(results)
│ ├─ 遍历results创建高亮mark/overlay
│ ├─ navigateTo(0) ← 跳转到第一个结果
│ └─ 返回 highlightCount
└─ 返回 { success, resultCount, stats }
更新UI状态:
├─ showNavigationControls = true
├─ searchResultCount = N
└─ 显示成功提示Toast
```
### 6.2 时间估算
| 步骤 | 耗时 | 说明 |
|------|------|------|
| 文本提取 | 50-200ms | 取决于文档大小 |
| Fuse.js索引构建 | 10-50ms | 取决于文本块数量 |
| 搜索执行 | 5-20ms | Fuse.js高效算法 |
| 高亮渲染 | 30-100ms | DOM操作 |
| **总计** | **95-370ms** | **< 400ms用户体验流畅** |
加上等待DOM渲染的delay500-1500ms总响应时间约**0.6-2秒**。
---
## 7. 边界情况处理
### 7.1 异常场景
| 场景 | 处理策略 |
|------|---------|
| **关键词为空** | 不执行搜索,仅跳转到指定页码 |
| **文档内容为空** | 提示"无法提取文档内容",仍完成页面跳转 |
| **无匹配结果** | 提示"未找到XXX相关内容",保持在目标页面 |
| **部分高亮失败** | 记录警告日志,成功渲染的其他高亮正常显示 |
| **PDF iframe跨域受限** | 降级为仅页码跳转,提示"PDF高亮暂不可用" |
| **大文档(>10MB** | 文本提取限流只提取前1000个文本块 |
| **用户快速连续点击** | 防抖处理300ms取消上一次搜索 |
| **网络请求失败** | 显示错误提示,保留已渲染的高亮 |
### 7.2 兼容性保障
- **浏览器兼容**Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
- **Vue版本**Vue 3.2+ (Composition API)
- **移动端适配**:导航栏响应式布局,触摸友好的按钮尺寸
- **无障碍访问**高亮元素添加aria-label键盘导航支持Tab/Enter
---
## 8. 性能优化策略
### 8.1 文本提取优化
- **懒加载**:只在需要搜索时才提取文本(非页面加载时)
- **分批处理**:大文档分段提取,避免阻塞主线程
- **缓存机制**相同文档不重复提取基于fileId + hash缓存
### 8.2 搜索引擎优化
- **增量更新**:文档内容变化时只更新受影响的索引条目
- **结果限制**默认返回Top 20结果避免过多DOM操作
- **Web Worker**可选将Fuse.js搜索移至Worker线程针对超大文档
### 8.3 高亮渲染优化
- **虚拟滚动**:只渲染可视区域内的高亮(针对超多匹配项)
- **批量DOM操作**使用DocumentFragment减少reflow
- **防抖清除**:快速切换时延迟清理旧高亮
### 8.4 内存管理
- **及时清理**:离开页面时调用`clearAllHighlights()`
- **引用释放**断开DOM元素与JavaScript对象的循环引用
- **事件解绑**onUnmounted时移除所有事件监听器
---
## 9. 测试计划
### 9.1 单元测试
| 测试模块 | 测试用例数 | 覆盖率目标 |
|---------|-----------|-----------|
| TextExtractor | 15 | ≥90% |
| FuzzySearchEngine | 20 | ≥95% |
| HighlightRenderer | 25 | ≥85% |
| SearchCoordinator | 18 | ≥90% |
**关键测试场景**
- PDF文本提取含多页、空页、特殊字符
- 中文模糊匹配(同义词、错别字、截断关键词)
- DOM高亮创建/销毁(内存泄漏检测)
- 重试机制验证模拟DOM未就绪
### 9.2 集成测试
- **E2E测试**使用Cypress自动化测试完整流程
1. 打开AI对话页面
2. 发送问题获得带引用的回答
3. 点击引用来源
4. 验证:页面跳转 + 高亮显示 + 导航栏出现
5. 点击"下一处",验证高亮切换
- **兼容性测试**BrowserStack云测试平台
- Chrome/Firefox/Safari/Edge 最新3个版本
- Windows/macOS/Linux 桌面端
- iOS/Android 移动端(可选)
### 9.3 性能测试
- **加载性能**Lighthouse Performance Score ≥ 90
- **搜索响应时间**P99 < 2秒
- **内存占用**:高亮渲染后内存增长 < 50MB
- **CPU占用**搜索过程中CPU峰值 < 60%
---
## 10. 实施路线图
### Phase 1核心引擎开发Day 1-2
**Day 1上午**
- [ ] 安装依赖:`npm install fuse.js@7.0.0`
- [ ] 创建`src/utils/textExtractor.js`
- [ ] 实现PDF/DOCX/Excel/DOM文本提取方法
- [ ] 编写单元测试TextExtractor
**Day 1下午**
- [ ] 创建`src/utils/fuzzySearchEngine.js`
- [ ] 配置Fuse.js中文优化参数
- [ ] 实现`preprocessKeyword``search`方法
- [ ] 编写单元测试FuzzySearchEngine
**Day 2上午**
- [ ] 创建`src/utils/highlightRenderer.js`
- [ ] 实现DOM/PDF/表格三种高亮类型
- [ ] 添加导航控制逻辑
- [ ] 编写单元测试HighlightRenderer
**Day 2下午**
- [ ] 创建`src/utils/searchCoordinator.js`
- [ ] 实现编排逻辑和重试机制
- [ ] 集成测试4个模块联调
### Phase 2UI集成与调试Day 3
**Day 3上午**
- [ ] 修改`QAModule.vue``navigateToFileReader`
- [ ] 修改`ReaderPage.vue``onMounted`
- [ ] 添加搜索导航栏UI模板
- [ ] 编写CSS样式高亮+导航栏+动画)
**Day 3下午**
- [ ] 本地开发环境调试
- [ ] 测试PDF高亮功能重点
- [ ] 测试Word/Excel/TXT等多种格式
- [ ] 修复发现的bug
### Phase 3打磨与优化Day 4
**Day 4上午**
- [ ] 错误处理完善(边界情况)
- [ ] 性能优化(内存/CPU
- [ ] 用户反馈收集(内部测试)
**Day 4下午**
- [ ] 代码审查和重构
- [ ] 文档编写(使用指南)
- [ ] 准备发布
---
## 11. 风险评估与应对
### 11.1 技术风险
| 风险项 | 概率 | 影响 | 应对措施 |
|--------|------|------|---------|
| **PDF iframe跨域限制** | 中 | 高 | 降级方案:仅页码跳转 + Toast提示 |
| **Fuse.js中文分词不准** | 低 | 中 | 自定义tokenizer或引入jieba分词 |
| **大文档性能问题** | 中 | 中 | 分批处理 + Web Worker + 虚拟滚动 |
| **DOM高亮破坏文档结构** | 低 | 高 | 使用Range API + 异常捕获回滚 |
### 11.2 进度风险
- **风险**Phase 2集成调试超出预期
- **应对**预留1天buffer time优先保证核心功能可用
---
## 12. 成功验收标准
### 功能完整性 ✅
- [ ] 点击PDF引用 → 跳转页码 + overlay高亮显示
- [ ] 点击Word引用 → DOM高亮 + 持续显示
- [ ] 点击Excel引用 → 单元格高亮 + 角标序号
- [ ] 模糊匹配成功率 ≥ 90%(测试集验证)
- [ ] 导航栏正确显示匹配数量和当前位置
- [ ] 上/下一处按钮工作正常
### 性能指标 ⚡
- [ ] 搜索+高亮总耗时 < 2秒P99
- [ ] 内存增长 < 50MB相对于无高亮状态
- [ ] CPU峰值 < 60%(搜索过程中)
- [ ] Lighthouse Performance Score ≥ 90
### 用户体验 😊
- [ ] 高亮视觉效果醒目但不刺眼
- [ ] 动画流畅60fps
- [ ] 错误提示友好清晰
- [ ] 移动端触摸操作顺畅
- [ ] 键盘可访问Tab/Enter导航
### 代码质量 🔧
- [ ] 单元测试覆盖率 ≥ 85%
- [ ] 无console警告生产环境
- [ ] ESLint检查通过0 error, 0 warning
- [ ] 代码注释完整JSDoc标准
---
## 13. 后续迭代方向(可选)
### Phase 4增强功能v2.0
- [ ] **多关键词同时高亮**支持AND/OR逻辑组合
- [ ] **高亮导出**将高亮标注导出为PDF注释
- [ ] **历史记录**:保存用户的搜索历史和高亮偏好
- [ ] **快捷键支持**Ctrl+F唤起搜索框F3跳转下一个
- [ ] **AI语义搜索**:升级为向量相似度匹配(需后端支持)
### Phase 5平台扩展v3.0
- [ ] **Web Worker迁移**将搜索引擎移至Worker线程
- [ ] **IndexedDB缓存**:离线缓存文本提取结果
- [ ] **PWA支持**:离线模式下仍可使用基本搜索功能
- [ ] **插件机制**:允许第三方开发者自定义高亮样式
---
## 附录A关键技术参考
### A.1 Fuse.js官方文档
https://fusejs.io/
### A.2 Range APIMDN
https://developer.mozilla.org/en-US/docs/Web/API/Range
### A.3 TreeWalker APIMDN
https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
### A.4 PDF.js textLayer
https://github.com/nickmoss/pdfjs-viewer-textlayer
---
## 附录B术语表
| 术语 | 定义 |
|------|------|
| **RAG** | Retrieval-Augmented Generation检索增强生成 |
| **SSE** | Server-Sent Events服务器推送事件 |
| **Fuse.js** | 轻量级模糊搜索库 |
| **TreeWalker** | DOM树遍历API |
| **Range** | DOM范围选择API |
| **Overlay** | 覆盖层用于PDF高亮 |
---
**文档维护者**AI Code Assistant
**最后更新**2026-05-30
**下次评审日期**实施完成后3天内

View File

@@ -0,0 +1,717 @@
# 文件上传与任务状态管理优化设计文档
**方案选择**A - 渐进式增强(用户体验优先)
**版本**V1.0
**日期**2026-05-30
**状态**:待审核
---
## 1. 设计概述
### 1.1 项目背景
基于《文件管理接口文档》V2.12.0)规范,对现有文件上传功能和文件列表下的任务向量化/出题状态组件进行系统性用户体验优化。
### 1.2 优化目标
- **主要目标**:提升用户交互体验,增强功能易用性和反馈机制
- **次要目标**:改善性能表现,优化错误处理流程
- **非目标**:不进行架构重构,不改变核心数据流,不引入新技术栈
### 1.3 设计原则
1. **KISS原则**:保持简单,避免过度工程化
2. **增量改进**:每个优化点独立可回滚
3. **向后兼容**不破坏现有API和组件接口
4. **用户驱动**:所有改进围绕实际使用场景
5. **渐进增强**:在现有代码基础上添加功能,不重写
---
## 2. 当前实现分析
### 2.1 现有架构
```
前端组件结构:
├── FileSelector.vue # 文件选择器用于AI对话中选择文件
├── exam/
│ ├── UploadTaskItem.vue # 任务状态展示卡片
│ ├── composables/
│ │ ├── useUploadTasks.js # 任务状态管理逻辑
│ │ └── useTaskManager.js # 任务管理器
│ ├── GeneratePanel.vue # 向量化/出题面板
│ └── ExamModuleContainer.vue
├── api/
│ └── file.js # 文件API接口定义
└── views/
└── ReaderPage.vue # 文档阅读页面
```
### 2.2 已识别问题清单
#### **P0 - 必须修复(影响核心功能)**
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P0-01 | 无上传进度条显示 | 文件上传体验 | 🔴 高 |
| P0-02 | 错误状态无重试机制 | 任务失败处理 | 🔴 高 |
| P0-03 | 轮询策略固定5秒间隔 | 性能浪费 | 🟡 中 |
#### **P1 - 应该改进(显著提升体验)**
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P1-01 | 不支持拖拽上传 | 上传便捷性 | 🟡 中 |
| P1-02 | 无前端文件验证 | 错误预防 | 🟡 中 |
| P1-03 | 错误提示不够友好 | 用户理解 | 🟡 中 |
| P1-04 | 不支持批量操作 | 效率提升 | 🟢 低 |
| P1-05 | 移动端适配不完善 | 多设备支持 | 🟢 低 |
#### **P2 - 可以优化(锦上添花)**
| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P2-01 | 无状态变更通知提醒 | 用户感知 | 🟢 低 |
| P2-02 | 缺少加载骨架屏 | 视觉体验 | 🟢 低 |
| P2-03 | 任务列表无虚拟滚动 | 大列表性能 | 🟢 低 |
---
## 3. 详细设计方案
### 3.1 模块一:文件上传增强(优先级:🔴 最高)
#### **3.1.1 功能需求**
##### **FR-01: 上传进度条**
- **描述**:显示实时上传百分比和预计剩余时间
- **触发条件**:文件大小 > 1MB 或上传时间 > 2秒时自动显示
- **UI设计**
```
┌─────────────────────────────────────┐
│ 📄 制度管理办法.docx (2.3MB) │
│ ████████████░░░░░░ 65% 剩余 3秒 │
└─────────────────────────────────────┘
```
##### **FR-02: 拖拽上传支持**
- **描述**:支持将文件从桌面/资源管理器拖拽到上传区域
- **技术方案**HTML5 Drag and Drop API
- **交互细节**
- 拖拽悬停时:边框高亮 + 提示文字"释放以上传"
- 拖拽离开时:恢复原状
- 支持多文件同时拖拽
##### **FR-03: 前端文件验证**
- **验证规则**
- ✅ 支持的文件类型:`.docx, .pdf, .txt, .md, .xlsx, .pptx`
- ✅ 最大文件大小50MB可配置
- ✅ 文件名长度≤200字符
- ✅ 特殊字符检查(禁止 `\ / : * ? " < > |`
- **错误提示**
- 类型不支持:"❌ 不支持的文件格式,请上传 .docx/.pdf/.txt 等文档"
- 文件过大:"❌ 文件超过50MB限制请压缩后重试"
- 其他错误:"⚠️ 文件验证失败:{具体原因}"
##### **FR-04: 上传前预览**
- **预览信息**
- 文件图标(根据扩展名显示不同图标)
- 文件名(高亮显示)
- 文件大小格式化为KB/MB
- 文件类型标签(如"Word文档"、"PDF文件"
##### **FR-05: 友好的错误处理**
- **错误分类**
- 🌐 **网络错误**:连接超时、服务器无响应
- 📁 **文件错误**:格式不支持、文件损坏
- 🔐 **权限错误**:未登录、无上传权限
- ⚙️ **服务器错误**500内部错误、存储空间不足
- **错误展示**
- 图标 + 标题 + 详细说明 + 操作建议
- 示例:
```
⚠️ 上传失败
原因:服务器响应超时(>60秒
建议:
• 检查网络连接是否正常
• 尝试压缩文件后重新上传
• 如持续失败,请联系管理员
[重新上传] [取消]
```
#### **3.1.2 技术实现方案**
##### **修改文件清单**
| 文件路径 | 改动类型 | 改动量 | 说明 |
|---------|---------|--------|------|
| `src/components/FileSelector.vue` | 修改 | ~150行 | 添加拖拽、验证、进度条 |
| `src/api/file.js` | 修改 | ~20行 | 添加onUploadProgress回调 |
| `src/utils/fileValidator.js` | 新增 | ~80行 | 文件验证工具函数 |
| `src/components/ui/ProgressBar.vue` | 新增 | ~60行 | 可复用进度条组件 |
| `src/components/ui/DropZone.vue` | 新增 | ~90行 | 拖拽上传区域组件 |
##### **核心代码示例**
**Axios进度监听** (`src/api/file.js`)
```javascript
uploadFile: (formData, onProgress) => {
return apiClient.post('/file/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress({
loaded: progressEvent.loaded,
total: progressEvent.total,
percent: percentCompleted
})
}
}
})
}
```
**拖拽上传组件** (`src/components/ui/DropZone.vue`)
```vue
<template>
<div
class="drop-zone"
:class="{ 'is-dragging': isDragging }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="$refs.fileInput.click()"
>
<div v-if="!isDragging" class="drop-zone-content">
<InboxOutlined class="drop-icon" />
<p class="drop-text">拖拽文件到此处,或<span class="link">点击选择</span></p>
<p class="drop-hint">支持 .docx .pdf .txt .xlsx .pptx最大 50MB</p>
</div>
<div v-else class="drop-zone-active">
<span class="active-text">📥 释放文件以上传</span>
</div>
<input
ref="fileInput"
type="file"
multiple
:accept="acceptedTypes"
@change="onFileSelected"
style="display: none"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { InboxOutlined } from '@ant-design/icons-vue'
const props = defineProps({
acceptedTypes: {
type: String,
default: '.docx,.pdf,.txt,.md,.xlsx,.pptx'
},
maxSize: {
type: Number,
default: 50 * 1024 * 1024 // 50MB
}
})
const emit = defineEmits(['files-selected', 'error'])
const isDragging = ref(false)
const fileInput = ref(null)
function onDragEnter(e) {
isDragging.value = true
}
function onDragOver(e) {
e.dataTransfer.dropEffect = 'copy'
}
function onDragLeave(e) {
if (!e.currentTarget.contains(e.relatedTarget)) {
isDragging.value = false
}
}
function onDrop(e) {
isDragging.value = false
const files = Array.from(e.dataTransfer.files)
validateAndEmit(files)
}
function onFileSelected(e) {
const files = Array.from(e.target.files)
validateAndEmit(files)
// 重置input以允许重复选择相同文件
e.target.value = ''
}
function validateAndEmit(files) {
const validFiles = []
const errors = []
files.forEach(file => {
// 验证文件类型
const ext = '.' + file.name.split('.').pop().toLowerCase()
if (!props.acceptedTypes.includes(ext)) {
errors.push({ file: file.name, reason: '不支持的文件格式' })
return
}
// 验证文件大小
if (file.size > props.maxSize) {
errors.push({ file: file.name, reason: `文件过大 (${formatSize(file.size)},限制${formatSize(props.maxSize)})` })
return
}
validFiles.push(file)
})
if (validFiles.length > 0) {
emit('files-selected', validFiles)
}
if (errors.length > 0) {
emit('error', errors)
}
}
</script>
```
---
### 3.2 模块二:任务状态优化(优先级:🟡 高)
#### **3.2.1 功能需求**
##### **FR-06: 一键重试按钮**
- **触发条件**:任务状态为 error 时显示
- **位置**UploadTaskItem 组件的错误标签旁
- **行为**
1. 点击后立即调用重新处理API
2. 按钮变为加载状态(转圈图标)
3. 成功后自动刷新任务列表
4. 失败后显示错误详情
- **UI设计**
```
❌ 向量化失败 [🔄 重试] [📋 详情]
```
##### **FR-07: 批量删除功能**
- **使用场景**:清理大量失败/已完成的历史任务
- **交互流程**
1. 进入批量模式(勾选框出现)
2. 选择多个任务(支持全选)
3. 点击"删除选中项(N)"按钮
4. 弹出确认对话框(显示即将删除的任务数)
5. 执行删除并显示进度
- **权限控制**:仅管理员可见此功能
##### **FR-08: Toast通知系统**
- **触发事件**
- ✅ 上传成功:"✅ 文件「{filename}」上传成功,正在处理中..."
- ✅ 向量化完成:"🎉 「{filename}」向量化完成,准备生成题目..."
- ✅ 出题完成:"📝 「{filename}」已生成{count}道题目"
- ❌ 处理失败:"❌ 「{filename}」处理失败:{原因}"
- **配置选项**
- 显示时长:成功=3秒失败=5秒
- 位置:右下角
- 可关闭:是
- 堆叠方式垂直堆叠最多3条同时显示
##### **FR-09: 智能轮询策略**
- **当前问题**固定5秒轮询无论是否有活跃任务
- **优化方案**
| 任务状态 | 轮询间隔 | 说明 |
|---------|---------|------|
| 有处理中任务 | 3秒 | 快速反馈 |
| 全部完成/失败 | 10秒 | 降低频率 |
| 页面不可见 | 30秒 | 节省资源 |
| 无任何任务 | 停止轮询 | 完全停止 |
- **实现方式**:使用 `document.visibilitychange` 事件检测页面可见性
##### **FR-10: 加载骨架屏**
- **应用场景**
1. 首次加载任务列表时
2. 刷新任务状态时(>500ms
- **UI效果**:灰色脉冲动画块模拟真实内容布局
- **组件复用**:创建通用 SkeletonCard 组件
#### **3.2.2 技术实现方案**
##### **修改文件清单**
| 文件路径 | 改动类型 | 改动量 | 说明 |
|---------|---------|--------|------|
| `src/components/exam/UploadTaskItem.vue` | 修改 | ~40行 | 添加重试按钮 |
| `src/components/exam/composables/useUploadTasks.js` | 修改 | ~80行 | 智能轮询+批量操作 |
| `src/components/ui/SkeletonCard.vue` | 新增 | ~50行 | 骨架屏组件 |
| `src/components/ui/ToastNotification.vue` | 新增 | ~90行 | 通知组件 |
| `src/utils/notification.js` | 新增 | ~60行 | 通知工具函数 |
##### **核心代码示例**
**智能轮询** (`useUploadTasks.js` 修改部分)
```javascript
// 新增智能轮询配置
const POLL_CONFIG = {
active: 3000, // 有活跃任务时3秒
idle: 10000, // 全部空闲时10秒
hidden: 30000, // 页面隐藏时30秒
maxInterval: 30000 // 最大间隔上限
}
let currentInterval = POLL_CONFIG.idle
let visibilityHandler = null
function updatePollingStrategy() {
const hasActiveTasks = shouldPoll()
const isHidden = document.hidden
let newInterval
if (isHidden) {
newInterval = POLL_CONFIG.hidden
} else if (hasActiveTasks) {
newInterval = POLL_CONFIG.active
} else {
newInterval = POLL_CONFIG.idle
}
// 仅当间隔变化时才重启定时器
if (newInterval !== currentInterval) {
currentInterval = newInterval
stopPolling()
if (tasks.value.length > 0) {
startPolling()
}
console.log(`[useUploadTasks] 轮询间隔调整为 ${currentInterval}ms`)
}
}
// 监听页面可见性
function setupVisibilityListener() {
if (visibilityHandler) return
visibilityHandler = () => {
updatePollingStrategy()
}
document.addEventListener('visibilitychange', visibilityHandler)
}
onMounted(() => {
setupVisibilityListener()
})
onUnmounted(() => {
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler)
}
})
```
**一键重试** (`UploadTaskItem.vue` 修改部分)
```vue
<template>
<!-- 在错误标签旁添加重试按钮 -->
<span v-if="vectorStatus.status === 'error'" class="uti-tag error">
{{ vectorStatus.label }}
<button class="retry-btn" @click="handleRetry" :disabled="retrying">
<LoadingOutlined v-if="retrying" spin />
<ReloadOutlined v-else />
重试
</button>
</span>
</template>
<script setup>
import { ref } from 'vue'
import { ReloadOutlined, LoadingOutlined } from '@ant-design/icons-vue'
const props = defineProps({
task: Object,
vectorStatus: Object,
examStatus: Object
})
const emit = defineEmits(['retry'])
const retrying = ref(false)
async function handleRetry() {
retrying.value = true
try {
await emit('retry', props.task.id)
} finally {
setTimeout(() => { retrying.value = false }, 1000)
}
}
</script>
```
---
### 3.3 模块三:移动端适配(优先级:🟢 中)
#### **3.3.1 适配策略**
##### **断点定义**
| 断点名称 | 宽度范围 | 目标设备 |
|---------|---------|---------|
| `sm` | ≥640px | 大屏手机横屏/小平板 |
| `md` | ≥768px | 平板竖屏 |
| `lg` | ≥1024px | 桌面端 |
##### **关键改动**
1. **UploadTaskItem 组件**
- 字体缩小12px → 11px
- 状态标签换行显示
- 错误信息默认展开(无需点击"详情"
2. **文件列表**
- 卡片布局改为单列
- 操作按钮改为底部固定栏
- 添加下拉刷新手势支持
3. **上传区域**
- 拖拽区域全屏宽度
- 点击区域增大最小44px触控目标
---
## 4. 数据流与接口变更
### 4.1 API接口调整
#### **新增接口**(可选,如后端已支持则使用):
| 接口 | 方法 | 用途 |
|------|------|------|
| `/api/file/{id}/retry` | POST | 重试失败任务 |
| `/api/file/batch-delete` | POST | 批量删除任务 |
#### **兼容性处理**
如果后端暂不支持上述接口,前端降级方案:
- **重试功能**:调用原有上传接口重新上传同一文件
- **批量删除**循环调用单个删除接口带loading状态
### 4.2 数据流图
```
用户操作
FileSelector.vue拖拽/选择文件)
fileValidator.js前端验证
├─ 通过 → 显示预览 → 开始上传
└─ 失败 → 显示友好错误提示
api/file.js uploadFile带进度回调
ProgressBar.vue实时更新进度
上传完成
useUploadTasks.js添加任务到列表
智能轮询3-30秒动态调整
UploadTaskItem.vue展示状态
├─ 处理中 → 动画图标
├─ 完成 → 成功图标 + Toast通知
└─ 失败 → 错误图标 + 重试按钮
```
---
## 5. 性能与兼容性考虑
### 5.1 性能优化措施
1. **按需加载**:新组件使用动态导入
```javascript
const DropZone = defineAsyncComponent(() => import('./components/ui/DropZone.vue'))
```
2. **防抖/节流**
- 搜索输入debounce 300ms
- 窗口resizethrottle 150ms
- 滚动事件passive listener
3. **内存管理**
- 轮询定时器在组件卸载时清除
- 大文件上传完成后释放引用
- 图片/文件预览使用URL.revokeObjectURL()
### 5.2 浏览器兼容性
| 功能 | 最低版本 | 降级方案 |
|------|---------|---------|
| Drag & Drop API | IE10+, 所有现代浏览器 | 回退到点击上传 |
| Progress Event | IE10+, 所有现代浏览器 | 不显示进度条 |
| Visibility API | IE10+, Chrome 13+ | 固定10秒轮询 |
| CSS Grid/Flexbox | IE11+ (partial), 现代浏览器全支持 | 使用float fallback |
---
## 6. 测试计划
### 6.1 单元测试
| 测试场景 | 输入 | 预期输出 | 优先级 |
|---------|------|---------|--------|
| 文件类型验证 | `test.exe` | 返回错误"不支持的格式" | P0 |
| 文件大小验证 | 60MB文件 | 返回错误"超过50MB限制" | P0 |
| 进度计算 | loaded=50, total=100 | percent=50 | P0 |
| 拖拽事件处理 | 有效文件 | 触发files-selected事件 | P1 |
| 轮询间隔调整 | 页面hidden | 间隔变为30秒 | P1 |
| 重试按钮点击 | error状态任务 | 调用retry事件 | P0 |
### 6.2 集成测试
1. **上传流程测试**
- 选择文件 → 验证 → 上传 → 进度显示 → 完成
- 拖拽文件 → 验证 → 上传 → 进度显示 → 完成
- 选择无效文件 → 显示错误 → 修正后重试
2. **任务状态测试**
- 上传后观察状态流转UPLOADED→VECTORIZING→VECTORIZED→EXAM_GENERATING→COMPLETED
- 模拟失败场景 → 点击重试 → 验证重试逻辑
- 批量选择 → 删除 → 确认删除成功
3. **边界情况测试**
- 同时上传10个文件
- 网络中断后恢复
- 页面刷新后状态保持
- 移动端触摸操作
### 6.3 兼容性测试
- ✅ Chrome 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Edge 90+
- ⚠️ iOS Safari 14+(移动端)
- ⚠️ Android Chrome 90+
---
## 7. 实施路线图
### Phase 1核心功能第1天- 6小时
**上午3小时**
- [ ] 创建 `src/utils/fileValidator.js` 文件验证工具
- [ ] 创建 `src/components/ui/ProgressBar.vue` 进度条组件
- [ ] 修改 `src/api/file.js` 添加进度回调支持
**下午3小时**
- [ ] 创建 `src/components/ui/DropZone.vue` 拖拽组件
- [ ] 修改 `src/components/FileSelector.vue` 集成新功能
- [ ] 测试上传流程(正常/异常场景)
### Phase 2任务状态优化第2天- 6小时
**上午3小时**
- [ ] 修改 `UploadTaskItem.vue` 添加重试按钮
- [ ] 修改 `useUploadTasks.js` 实现智能轮询
- [ ] 创建 `src/components/ui/ToastNotification.vue`
**下午3小时**
- [ ] 实现批量删除功能
- [ ] 创建骨架屏组件
- [ ] 测试任务状态流转
### Phase 3打磨与测试第3天- 6小时
**上午3小时**
- [ ] 移动端响应式适配
- [ ] 跨浏览器测试
- [ ] 性能 profiling
**下午3小时**
- [ ] 用户体验走查
- [ ] Bug修复
- [ ] 文档编写
---
## 8. 风险评估与缓解
### 8.1 技术风险
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|-------|------|---------|
| 后端不支持重试API | 中 | 中 | 前端降级为重新上传 |
| 大文件上传内存溢出 | 低 | 高 | 使用分片上传Phase 2考虑 |
| 拖拽API兼容性问题 | 低 | 低 | Feature detection + 降级方案 |
| 轮询性能开销 | 低 | 中 | 智能间隔 + 页面不可见时暂停 |
### 8.2 业务风险
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|-------|------|---------|
| 用户不接受新交互 | 低 | 低 | A/B测试 + 快速回滚能力 |
| 移动端体验不佳 | 中 | 中 | 充分的设备测试 |
| 与现有工作流冲突 | 低 | 中 | 保持向后兼容 + 渐进启用 |
---
## 9. 成功指标KPIs
### 9.1 定量指标
| 指标 | 当前值 | 目标值 | 测量方法 |
|------|-------|--------|---------|
| 上传操作成功率 | 未知 | ≥98% | 错误日志统计 |
| 平均上传等待感知时间 | 未知 | 减少30% | 用户调研 |
| 任务状态查询次数/会话 | 固定12次/分钟 | 动态3-30次 | 性能监控 |
| 错误重试成功率 | 0% | ≥80% | 重试按钮点击率 |
### 9.2 定性指标
- ✅ 用户能够清晰了解上传进度
- ✅ 任务失败时有明确的解决路径(重试按钮)
- ✅ 移动端操作流畅无明显卡顿
- ✅ 错误信息易于理解和行动
---
## 10. 附录
### 10.1 参考文档
- [文件管理接口文档 V2.12.0](./文件管理接口文档.md)
- Vue 3 官方文档https://vuejs.org/
- Ant Design Vue 组件库https://antdv.com/
- MDN Drag & Drop APIhttps://developer.mozilla.org/en-US/docs/Web/API/Drag_and_Drop
### 10.2 术语表
| 术语 | 定义 |
|------|------|
| **向量化(Vectorize)** | 将文档内容转换为向量表示,用于语义搜索 |
| **出题(Exam Generation)** | 基于文档内容自动生成考试题目 |
| **轮询(Polling)** | 客户端定期向服务器请求最新状态的机制 |
| **骨架屏(Skeleton)** | 内容加载时的占位动画效果 |
| **Toast通知** | 短暂出现的消息提示,通常位于屏幕角落 |
---
**文档结束**
*请审核本设计文档,确认后我们将进入实施阶段。*