1750 lines
68 KiB
Markdown
1750 lines
68 KiB
Markdown
# PaperManagementPanel 题目配置组件重设计 — 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将 2235 行的 PaperManagementPanel.vue 巨型组件拆分为 7 个子组件 + 2 个 composable,统一使用 design-tokens,优化视觉与交互体验。
|
||
|
||
**Architecture:** 采用「状态提升 + 单向数据流」模式。所有业务逻辑集中在 `usePaperManagement.js` composable 中,子组件通过 props 接收数据、通过 emits 向上通信。题目解析纯函数提取为独立的 `useQuestionParser.js`。
|
||
|
||
**Tech Stack:** Vue 3 Composition API (`<script setup>`)、Ant Design Vue 4.x(仅 message 组件)、CSS Design Tokens(`design-tokens.css`)、原生 Teleport 弹窗
|
||
|
||
---
|
||
|
||
## 文件变更总览
|
||
|
||
| 操作 | 文件路径 | 说明 |
|
||
|------|----------|------|
|
||
| **Create** | `src/components/exam/composables/useQuestionParser.js` | 题目数据解析工具 (~150行) |
|
||
| **Create** | `src/components/exam/composables/usePaperManagement.js` | 全局状态管理 composable (~550行) |
|
||
| **Create** | `src/components/exam/ComposeConfigSidebar.vue` | 组卷配置栏 (~220行) |
|
||
| **Create** | `src/components/exam/ComposePreviewPane.vue` | 组卷预览区 (~200行) |
|
||
| **Create** | `src/components/exam/ComposePanel.vue` | 组卷面板容器 (~60行) |
|
||
| **Create** | `src/components/exam/PaperListTab.vue` | 草稿/发布列表 (~180行) |
|
||
| **Create** | `src/components/exam/EntityPickerModal.vue` | 通用实体选择器 (~200行) |
|
||
| **Create** | `src/components/exam/PublishDialog.vue` | 发布弹窗 (~320行) |
|
||
| **Create** | `src/components/exam/PaperDetailModal.vue` | 试卷详情弹窗 (~280行) |
|
||
| **Rewrite** | `src/components/exam/PaperManagementPanel.vue` | 重写为薄容器 (~100行) |
|
||
|
||
---
|
||
|
||
## Phase 1: 基础设施 — Composables
|
||
|
||
### Task 1: 创建 useQuestionParser.js
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/composables/useQuestionParser.js`
|
||
|
||
- [ ] **Step 1: 创建文件并实现题目解析纯函数集**
|
||
|
||
从原 `PaperManagementPanel.vue` 的 `viewPaperDetail()` 方法(L1473-1551)中提取解析逻辑,封装为无副作用的纯函数:
|
||
|
||
```javascript
|
||
import { ref } from 'vue'
|
||
|
||
const TYPE_LABEL_MAP = {
|
||
single_choice: '单选',
|
||
multiple_choice: '多选',
|
||
true_false: '判断',
|
||
fill_blank: '填空',
|
||
subjective: '简答'
|
||
}
|
||
|
||
const DIFFICULTY_LABEL_MAP = {
|
||
1: '简单', 2: '中等', 3: '较难', 4: '困难', 5: '极难'
|
||
}
|
||
|
||
export function useQuestionParser() {
|
||
|
||
function parseContentObj(q) {
|
||
if (q.content != null) {
|
||
if (typeof q.content === 'string' && q.content.trim().startsWith('{')) {
|
||
try { return JSON.parse(q.content) }
|
||
catch { return {} }
|
||
}
|
||
if (typeof q.content === 'object') return q.content
|
||
}
|
||
if (q.data != null && typeof q.data === 'object' && !Array.isArray(q.data)) {
|
||
return q.data
|
||
}
|
||
const hasDirectFields = q.stem || q.options || q.answer || q.question_stem
|
||
if (hasDirectFields) return { ...q }
|
||
return {}
|
||
}
|
||
|
||
function extractStem(q) {
|
||
const c = parseContentObj(q)
|
||
const raw = c.stem
|
||
|| (c.data && c.data.stem)
|
||
|| q.stem || q.question_stem || q.questionContent
|
||
|| q.question_content
|
||
|| (typeof q.content === 'string' ? q.content : '')
|
||
|| (q.data && q.data.stem)
|
||
|| ''
|
||
if (!raw) return '(无题干)'
|
||
if (typeof raw === 'string') return raw
|
||
return JSON.stringify(raw)
|
||
}
|
||
|
||
function extractOptions(q) {
|
||
const c = parseContentObj(q)
|
||
let raw = c.options || (c.data && c.data.options) || q.options || (q.data && q.data.options) || []
|
||
if (!Array.isArray(raw) || raw.length === 0) return []
|
||
if (raw[0] && typeof raw[0] === 'object' && raw[0].key != null) {
|
||
return raw.map(opt => ({
|
||
key: String(opt.key),
|
||
text: opt.content || opt.text || opt.value || ''
|
||
}))
|
||
}
|
||
const keys = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
||
return raw.map((opt, i) => ({
|
||
key: keys[i] || String.fromCharCode(65 + i),
|
||
text: typeof opt === 'object' ? (opt.content || opt.text || opt.value || '') : String(opt)
|
||
}))
|
||
}
|
||
|
||
function extractAnswer(q) {
|
||
const c = parseContentObj(q)
|
||
const val = q.answer !== undefined ? String(q.answer)
|
||
: (c.answer !== undefined ? String(c.answer)
|
||
: ((c.data && c.data.answer) !== undefined ? String(c.data.answer)
|
||
: (q.data && q.data.answer !== undefined ? String(q.data.answer)
|
||
: (q.correctAnswer || q.correct_answer || q.standardAnswer || ''))))
|
||
return val !== undefined && val !== null && val !== '' ? val : ''
|
||
}
|
||
|
||
function isChoiceQuestion(q) {
|
||
const t = String(q?.questionType || q?.type || q?.question_type || '').toLowerCase()
|
||
return ['single_choice', 'multiple_choice', 'choice', 'single', 'multiple'].includes(t)
|
||
}
|
||
|
||
function isTextQuestion(q) {
|
||
const t = String(q?.questionType || q?.type || q?.question_type || '').toLowerCase()
|
||
return ['fill_blank', 'subjective', 'fill', 'essay'].includes(t)
|
||
}
|
||
|
||
function isOptionAnswer(opt, q) {
|
||
if (!q || !opt) return false
|
||
const ans = String(q.answer || '').toLowerCase().trim()
|
||
if (!ans) return false
|
||
const optKey = String(opt.key || opt.label || '').toLowerCase().trim()
|
||
const optText = String(opt.text || opt.content || '').toLowerCase().trim()
|
||
if (ans === optKey) return true
|
||
if (ans.includes(optKey) && optKey.length <= 2) return true
|
||
if (ans.includes(optText) && optText.length > 0) return true
|
||
return false
|
||
}
|
||
|
||
function formatTfAnswer(q) {
|
||
if (!q) return '未设置答案'
|
||
const ans = String(q.answer || '').toUpperCase().trim()
|
||
if (['T', 'TRUE', '1', '正确', '对'].includes(ans)) return '正确 ✓'
|
||
if (['F', 'FALSE', '0', '错误', '错'].includes(ans)) return '错误 ✗'
|
||
return ans || '未设置答案'
|
||
}
|
||
|
||
function formatDifficulty(d) {
|
||
if (d == null) return null
|
||
return DIFFICULTY_LABEL_MAP[d] || `${d}`
|
||
}
|
||
|
||
function formatQType(t) {
|
||
if (!t) return '未知'
|
||
return TYPE_LABEL_MAP[t] || t
|
||
}
|
||
|
||
function parseQuestionContent(rawQ) {
|
||
const contentObj = parseContentObj(rawQ)
|
||
return {
|
||
questionId: rawQ.questionId || rawQ.question_id || rawQ.id,
|
||
questionType: String(rawQ.questionType || rawQ.type || rawQ.question_type || '').toLowerCase(),
|
||
stem: extractStem(rawQ),
|
||
options: extractOptions(rawQ),
|
||
answer: extractAnswer(rawQ),
|
||
explanation: contentObj.explanation || (contentObj.data && contentObj.data.explanation) || rawQ.explanation || '',
|
||
score: Number(rawQ.score || rawQ.maxScore || rawQ.max_score || rawQ.question_score || 5),
|
||
difficulty: rawQ.difficulty != null ? Number(rawQ.difficulty) : null
|
||
}
|
||
}
|
||
|
||
return {
|
||
parseQuestionContent,
|
||
extractStem,
|
||
extractOptions,
|
||
extractAnswer,
|
||
isChoiceQuestion,
|
||
isTextQuestion,
|
||
isOptionAnswer,
|
||
formatTfAnswer,
|
||
formatDifficulty,
|
||
formatQType
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证文件创建成功**
|
||
|
||
确认文件已写入且无语法错误。
|
||
|
||
---
|
||
|
||
### Task 2: 创建 usePaperManagement.js
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/composables/usePaperManagement.js`
|
||
- Read: `src/components/exam/PaperManagementPanel.vue` (L569-1633) — 提取源
|
||
|
||
- [ ] **Step 1: 创建 composable 文件框架与导入**
|
||
|
||
```javascript
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { examAPI } from '../../../api/exam.js'
|
||
import { fileAPI } from '../../../api/file.js'
|
||
import { questionAPI } from '../../../api/question.js'
|
||
import { departmentAPI, userAPI } from '../../../api/auth'
|
||
|
||
const DIFFICULTIES = [
|
||
{ value: 1, label: '简单' }, { value: 2, label: '中等' },
|
||
{ value: 3, label: '较难' }, { value: 4, label: '困难' }, { value: 5, label: '极难' }
|
||
]
|
||
|
||
const QUESTION_TYPES = [
|
||
{ key: 'single_choice_count', label: '单选题' },
|
||
{ key: 'multiple_choice_count', label: '多选题' },
|
||
{ key: 'true_false_count', label: '判断题' },
|
||
{ key: 'fill_blank_count', label: '填空题' },
|
||
{ key: 'subjective_count', label: '简答题' }
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 Tab 与列表状态**
|
||
|
||
```javascript
|
||
export function usePaperManagement() {
|
||
const activeTab = ref('compose')
|
||
const tabs = [
|
||
{ key: 'compose', label: '智能组卷', icon: '...' },
|
||
{ key: 'drafts', label: '我的草稿', icon: '...' },
|
||
{ key: 'published', label: '已发布试卷', icon: '...' }
|
||
]
|
||
|
||
const drafts = ref([])
|
||
const publishedPapers = ref([])
|
||
const loadingDrafts = ref(false)
|
||
|
||
function switchTab(key) {
|
||
activeTab.value = key
|
||
if (key !== 'compose') loadPapers()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 实现组卷配置状态**
|
||
|
||
```javascript
|
||
const composeForm = reactive({
|
||
difficulty: 2,
|
||
include_personal: true,
|
||
single_choice_count: 0,
|
||
multiple_choice_count: 0,
|
||
true_false_count: 0,
|
||
fill_blank_count: 0,
|
||
subjective_count: 0
|
||
})
|
||
|
||
const composing = ref(false)
|
||
const composeResult = ref(null)
|
||
|
||
const totalCount = computed(() =>
|
||
composeForm.single_choice_count + composeForm.multiple_choice_count +
|
||
composeForm.true_false_count + composeForm.fill_blank_count + composeForm.subjective_count
|
||
)
|
||
|
||
const totalPaperScore = computed(() =>
|
||
(composeResult.value?.questions || []).reduce(
|
||
(sum, q) => sum + (q.score || q.maxScore || q.max_score || 5), 0
|
||
)
|
||
)
|
||
|
||
function increment(key) { if (composeForm[key] < 50) composeForm[key]++ }
|
||
function decrement(key) { if (composeForm[key] > 0) composeForm[key]-- }
|
||
```
|
||
|
||
- [ ] **Step 4: 实现文件选择状态**
|
||
|
||
```javascript
|
||
const showFilePicker = ref(false)
|
||
const fileSearchKeyword = ref('')
|
||
const selectedFiles = ref([])
|
||
const allFiles = ref([])
|
||
const loadingFiles = ref(false)
|
||
|
||
const filteredFiles = computed(() => {
|
||
if (!fileSearchKeyword.value.trim()) return allFiles.value
|
||
const kw = fileSearchKeyword.value.toLowerCase()
|
||
return allFiles.value.filter(f =>
|
||
(f.name || f.file_name || f.fileName || '').toLowerCase().includes(kw)
|
||
)
|
||
})
|
||
|
||
function isFileSelected(id) { return selectedFiles.value.some(f => f.id === id) }
|
||
|
||
function toggleFile(file) {
|
||
if (isFileSelected(file.id)) {
|
||
selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id)
|
||
} else {
|
||
selectedFiles.value = [...selectedFiles.value, file]
|
||
}
|
||
}
|
||
|
||
function removeFile(id) { selectedFiles.value = selectedFiles.value.filter(f => f.id !== id) }
|
||
|
||
async function openFilePicker() {
|
||
showFilePicker.value = true
|
||
if (allFiles.value.length === 0) {
|
||
loadingFiles.value = true
|
||
try {
|
||
const res = await fileAPI.getFiles({ pageNum: 1, pageSize: 200 })
|
||
const data = res?.data || res
|
||
const list = Array.isArray(data) ? data : (data?.list || data?.records || data?.data?.list || [])
|
||
allFiles.value = list
|
||
} catch (e) {
|
||
message.error('加载文件列表失败')
|
||
} finally {
|
||
loadingFiles.value = false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 实现组卷生成与保存**
|
||
|
||
```javascript
|
||
async function handleGeneratePaper() {
|
||
if (totalCount.value === 0) { message.warning('请至少设置一种题型数量'); return }
|
||
composing.value = true
|
||
composeResult.value = null
|
||
try {
|
||
const params = {
|
||
single_choice_count: composeForm.single_choice_count,
|
||
multiple_choice_count: composeForm.multiple_choice_count,
|
||
true_false_count: composeForm.true_false_count,
|
||
fill_blank_count: composeForm.fill_blank_count,
|
||
subjective_count: composeForm.subjective_count,
|
||
difficulty: composeForm.difficulty,
|
||
include_personal: composeForm.include_personal
|
||
}
|
||
if (selectedFiles.value.length > 0) {
|
||
params.file_ids = selectedFiles.value.map(f => f.id)
|
||
}
|
||
|
||
let result = await examAPI.generatePaper(params)
|
||
let questions = result?.questions || result?.paperQuestions || result?.questionList ||
|
||
result?.items || result?.question_list || []
|
||
|
||
if (!Array.isArray(questions) || questions.length === 0) {
|
||
// fallback: 从题库补充
|
||
const typeMap = [
|
||
{ key: 'single_choice_count', type: 'single_choice' },
|
||
{ key: 'multiple_choice_count', type: 'multiple_choice' },
|
||
{ key: 'true_false_count', type: 'true_false' },
|
||
{ key: 'fill_blank_count', type: 'fill_blank' },
|
||
{ key: 'subjective_count', type: 'subjective' }
|
||
]
|
||
if (!Array.isArray(questions)) questions = []
|
||
for (const tm of typeMap) {
|
||
const need = composeForm[tm.key]
|
||
if (need <= 0) continue
|
||
try {
|
||
const qRes = await questionAPI.pageQuestions({ pageNum: 1, pageSize: need, questionType: tm.type, difficulty: composeForm.difficulty })
|
||
const body = qRes?.data || {}
|
||
const inner = body?.data
|
||
let rawList = inner?.list || inner?.records || inner?.questions ||
|
||
(Array.isArray(inner) ? inner : null) || body?.list || body?.records || body?.questions || []
|
||
let qList = Array.isArray(rawList) ? rawList : []
|
||
if (qList.length === 0 && composeForm.difficulty !== undefined) {
|
||
const retryRes = await questionAPI.pageQuestions({ pageNum: 1, pageSize: need, questionType: tm.type })
|
||
const retryBody = retryRes?.data || {}
|
||
const retryInner = retryBody?.data
|
||
rawList = retryInner?.list || retryInner?.records || retryInner?.questions ||
|
||
(Array.isArray(retryInner) ? retryInner : null) || retryBody?.list || retryBody?.records || retryBody?.questions || []
|
||
qList = Array.isArray(rawList) ? rawList : []
|
||
}
|
||
if (qList.length > 0) questions = questions.concat(qList.slice(0, need))
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
}
|
||
|
||
const paperId = result?.paper_id || result?.paperId || result?.paper?.id || result?.id || null
|
||
const paperTitle = result?.paper_title || result?.paperTitle || result?.paper?.title || result?.title || ''
|
||
|
||
composeResult.value = { questions, paperId, paperTitle }
|
||
if (questions.length > 0) {
|
||
message.success(`组卷成功!${questions.length} 道题目,可保存为草稿`)
|
||
} else {
|
||
message.warning('数据库中暂无匹配题目,请先在「生成题目」页面中为制度文件生成题目', 6)
|
||
}
|
||
} catch (err) {
|
||
message.error('组卷失败: ' + (err.message || '未知错误'))
|
||
} finally {
|
||
composing.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSavePaper() {
|
||
const r = composeResult.value
|
||
if (!r || !r.questions || r.questions.length === 0) { message.warning('请先生成试卷'); return }
|
||
const ids = (r.questions || []).map(q => q.question_id || q.questionId || q.id).filter(Boolean)
|
||
if (ids.length === 0) { message.error('无法提取题目ID'); return }
|
||
try {
|
||
const saveData = { title: r.paperTitle || `组卷_${new Date().toLocaleDateString()}`, questionIds: ids }
|
||
const savedPaper = await examAPI.savePaper(saveData)
|
||
|
||
const newPaper = savedPaper ? {
|
||
...savedPaper,
|
||
id: savedPaper.id || savedPaper.paperId || savedPaper.paper_id,
|
||
title: savedPaper.title || saveData.title,
|
||
questionCount: ids.length,
|
||
totalScore: savedPaper.totalScore || savedPaper.total_score || 0,
|
||
createdAt: savedPaper.createdAt || savedPaper.created_at || new Date().toISOString(),
|
||
published: false, status: 'draft'
|
||
} : {
|
||
id: 'local_' + Date.now(), title: saveData.title, questionCount: ids.length,
|
||
totalScore: 0, createdAt: new Date().toISOString(), published: false, status: 'draft'
|
||
}
|
||
|
||
if (!drafts.value.some(d => (d.id || d.paperId || d.paper_id) === newPaper.id)) {
|
||
drafts.value.unshift(newPaper)
|
||
}
|
||
message.success('已保存为草稿')
|
||
composeResult.value = null
|
||
selectedFiles.value = []
|
||
activeTab.value = 'drafts'
|
||
loadPapers()
|
||
} catch (err) {
|
||
message.error('保存失败: ' + (err.message || ''))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 实现草稿/发布列表操作**
|
||
|
||
```javascript
|
||
async function loadPapers() {
|
||
loadingDrafts.value = true
|
||
try {
|
||
const res = await examAPI.getAdminPapers()
|
||
let list = []
|
||
if (Array.isArray(res)) { list = res }
|
||
else if (res) {
|
||
list = res.list || res.records || res.data?.list || res.data?.records || res.data || []
|
||
if (!Array.isArray(list) && res.data && Array.isArray(res.data)) list = res.data
|
||
}
|
||
drafts.value = list.filter(p => !(p.published === true || p.status === 'published'))
|
||
publishedPapers.value = list.filter(p => p.published === true || p.status === 'published')
|
||
} catch (e) {
|
||
message.error('加载试卷列表失败')
|
||
} finally {
|
||
loadingDrafts.value = false
|
||
}
|
||
}
|
||
|
||
async function deleteDraft(id) {
|
||
try { await examAPI.deleteAdminPaper(id); message.success('已删除'); loadPapers() }
|
||
catch (e) { message.error('删除失败: ' + (e.message || '')) }
|
||
}
|
||
|
||
async function revokePublishedPaper(id) {
|
||
try { await examAPI.revokePaper(id); message.success('已撤回'); loadPapers() }
|
||
catch (e) { message.error('撤回失败: ' + (e.message || '')) }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 实现详情预览状态**
|
||
|
||
```javascript
|
||
const previewRaw = ref(null)
|
||
const previewQuestions = ref([])
|
||
const previewPaperTitle = ref('')
|
||
const previewPublishInfo = reactive({
|
||
departments: [], targetUsers: [], excludeUsers: [],
|
||
publishTime: '', deadline: '', userScopeMode: ''
|
||
})
|
||
|
||
const previewTotalScore = computed(() =>
|
||
previewQuestions.value.reduce((sum, q) => sum + (q.score || q.maxScore || q.max_score || q.question_score || 5), 0)
|
||
)
|
||
|
||
function clearPreview() {
|
||
previewRaw.value = null
|
||
previewQuestions.value = []
|
||
previewPaperTitle.value = ''
|
||
Object.assign(previewPublishInfo, { departments: [], targetUsers: [], excludeUsers: [], publishTime: '', deadline: '', userScopeMode: '' })
|
||
}
|
||
```
|
||
|
||
> **注意**: `viewPaperDetail()` 的完整实现(含复杂的数据多路径解析)将在 Task 9 中作为 PaperDetailModal 的内部调用完成,此处仅定义状态和清理方法。
|
||
|
||
- [ ] **Step 8: 实现发布弹窗状态与 Picker 状态**
|
||
|
||
```javascript
|
||
const publishDialog = ref(false)
|
||
const pubPaperId = ref(null)
|
||
const publishing = ref(false)
|
||
|
||
const pub = reactive({
|
||
deptList: [], filteredDeptList: [], selectedDepts: [],
|
||
userList: [], selectedUsers: [], selectedExcludes: [],
|
||
userMode: 'target', publishTime: '', deadline: ''
|
||
})
|
||
|
||
const pubUI = reactive({ loadingDepts: false, loadingUsers: false, deptSearch: '' })
|
||
const pickerDialog = reactive({ visible: false, type: '' })
|
||
const currentPickerSearch = ref('')
|
||
const pendingDeptIds = ref(new Set())
|
||
const pendingUserIds = ref(new Set())
|
||
const pendingExcludeIds = ref(new Set())
|
||
|
||
let _deptCache = null
|
||
let _deptCacheTime = 0
|
||
let _userCache = null
|
||
const CACHE_TTL = 5 * 60 * 1000
|
||
|
||
function openPublishDialog(id) {
|
||
pubPaperId.value = id
|
||
pub.selectedDepts = []; pub.selectedUsers = []; pub.selectedExcludes = []
|
||
pub.userMode = 'target'; pub.publishTime = ''; pub.deadline = ''
|
||
pubUI.deptSearch = ''; currentPickerSearch.value = ''
|
||
publishDialog.value = true
|
||
}
|
||
|
||
function closePublish() { publishDialog.value = false; pubPaperId.value = null }
|
||
```
|
||
|
||
- [ ] **Step 9: 实现 Picker 操作方法(部门)**
|
||
|
||
```javascript
|
||
async function loadDeptList() {
|
||
if (_deptCache && Date.now() - _deptCacheTime < CACHE_TTL) {
|
||
pub.deptList = [..._deptCache]; pub.filteredDeptList = [..._deptCache]; return
|
||
}
|
||
pubUI.loadingDepts = true
|
||
try {
|
||
const res = await departmentAPI.getDepartments()
|
||
let list = Array.isArray(res) ? res : null
|
||
if (!list && typeof res === 'object' && res !== null) {
|
||
for (const key of ['data', 'list', 'records', 'rows', 'items', 'departments']) {
|
||
if (Array.isArray(res[key])) { list = res[key]; break }
|
||
if (res.data && Array.isArray(res.data[key])) { list = res.data[key]; break }
|
||
}
|
||
if (!list && Array.isArray(res.data)) list = res.data
|
||
}
|
||
if (!list) { list = [] }
|
||
pub.deptList = list.map(d => ({
|
||
id: d.id, name: d.name || d.deptName || d.departmentName || d.dept_name || String(d.id || '')
|
||
}))
|
||
pub.filteredDeptList = [...pub.deptList]
|
||
_deptCache = [...pub.deptList]; _deptCacheTime = Date.now()
|
||
} catch (e) {
|
||
message.error('加载部门列表失败')
|
||
} finally { pubUI.loadingDepts = false }
|
||
}
|
||
|
||
function filterDeptList() {
|
||
const q = (pubUI.deptSearch || '').toLowerCase().trim()
|
||
pub.filteredDeptList = q
|
||
? pub.deptList.filter(d => (d.name || '').toLowerCase().includes(q) || String(d.id).includes(q))
|
||
: [...pub.deptList]
|
||
}
|
||
|
||
function toggleDept(d) {
|
||
const s = new Set(pendingDeptIds.value)
|
||
if (s.has(d.id)) s.delete(d.id); else s.add(d.id)
|
||
pendingDeptIds.value = s
|
||
}
|
||
|
||
async function removeDept(id) {
|
||
pub.selectedDepts = pub.selectedDepts.filter(d => d.id !== id)
|
||
await refreshUsersForDepts()
|
||
}
|
||
|
||
function selectAllDepts() { pendingDeptIds.value = new Set(pub.filteredDeptList.map(d => d.id)) }
|
||
function clearAllDepts() { pendingDeptIds.value = new Set() }
|
||
```
|
||
|
||
- [ ] **Step 10: 实现 Picker 操作方法(用户)**
|
||
|
||
```javascript
|
||
async function refreshUsersForDepts() {
|
||
_userCache = null; pub.userList = []; pub.selectedUsers = []
|
||
const ids = pub.selectedDepts.map(d => Number(d.id)).filter(Boolean)
|
||
if (ids.length > 0) {
|
||
pubUI.loadingUsers = true
|
||
try {
|
||
let allUsers = []
|
||
for (const did of ids) {
|
||
try {
|
||
const dr = await departmentAPI.getDepartmentUsers(did)
|
||
let users = Array.isArray(dr) ? dr : null
|
||
if (!users && typeof dr === 'object') {
|
||
for (const k of ['data', 'list', 'records', 'users', 'rows']) {
|
||
if (Array.isArray(dr[k])) { users = dr[k]; break }
|
||
if (dr.data && Array.isArray(dr.data[k])) { users = dr.data[k]; break }
|
||
}
|
||
if (!users && Array.isArray(dr.data)) users = dr.data
|
||
}
|
||
allUsers = allUsers.concat(users || [])
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
allUsers = allUsers.filter((u, i, arr) => arr.findIndex(x => x.id === u.id) === i)
|
||
pub.userList = allUsers.map(u => ({
|
||
id: u.id,
|
||
name: u.realName || u.username || u.name || u.nickname || ('用户' + u.id),
|
||
departmentName: u.departmentName || u.deptName || ''
|
||
}))
|
||
} catch (e) { /* skip */ }
|
||
finally { pubUI.loadingUsers = false }
|
||
}
|
||
}
|
||
|
||
async function loadUserListForPicker() {
|
||
if (pub.userList.length > 0) return
|
||
if (pub.selectedDepts.length > 0) { await refreshUsersForDepts(); return }
|
||
pubUI.loadingUsers = true
|
||
try {
|
||
const res = await userAPI.getUsers({ pageNum: 1, pageSize: 200 })
|
||
let list = Array.isArray(res) ? res : null
|
||
if (!list && typeof res === 'object' && res !== null) {
|
||
for (const key of ['data', 'list', 'records', 'rows']) {
|
||
if (Array.isArray(res[key])) { list = res[key]; break }
|
||
if (res.data && Array.isArray(res.data[key])) { list = res.data[key]; break }
|
||
}
|
||
if (!list && Array.isArray(res.data)) list = res.data
|
||
}
|
||
if (!list) list = []
|
||
pub.userList = list.map(u => ({
|
||
id: u.id,
|
||
name: u.realName || u.username || u.name || u.nickname || ('用户' + u.id),
|
||
departmentName: u.departmentName || u.deptName || ''
|
||
}))
|
||
_userCache = true
|
||
} catch (e) {
|
||
message.error('加载用户列表失败')
|
||
} finally { pubUI.loadingUsers = false }
|
||
}
|
||
|
||
function toggleCurrentUser(u) {
|
||
if (pub.userMode === 'target') {
|
||
const s = new Set(pendingUserIds.value)
|
||
if (s.has(u.id)) s.delete(u.id); else s.add(u.id)
|
||
pendingUserIds.value = s
|
||
} else {
|
||
const s = new Set(pendingExcludeIds.value)
|
||
if (s.has(u.id)) s.delete(u.id); else s.add(u.id)
|
||
pendingExcludeIds.value = s
|
||
}
|
||
}
|
||
|
||
function removeUser(id) { pub.selectedUsers = pub.selectedUsers.filter(u => u.id !== id) }
|
||
|
||
function selectAllUsers() { pendingUserIds.value = new Set(currentFilteredList.value.map(u => u.id)) }
|
||
function clearAllUsers() { pendingUserIds.value = new Set() }
|
||
function selectAllExcludes() { pendingExcludeIds.value = new Set(currentFilteredList.value.map(u => u.id)) }
|
||
function clearAllExcludes() { pendingExcludeIds.value = new Set() }
|
||
```
|
||
|
||
- [ ] **Step 11: 实现 Picker 弹窗控制与计算属性**
|
||
|
||
```javascript
|
||
const currentFilteredList = computed(() => {
|
||
const q = (currentPickerSearch.value || '').toLowerCase().trim()
|
||
const source = [...pub.userList]
|
||
if (!q) return source
|
||
return source.filter(u =>
|
||
(u.name || '').toLowerCase().includes(q) ||
|
||
String(u.id).includes(q) ||
|
||
(u.departmentName || '').toLowerCase().includes(q)
|
||
)
|
||
})
|
||
|
||
const currentSelectedCount = computed(() =>
|
||
pub.userMode === 'target' ? pendingUserIds.value.size : pendingExcludeIds.value.size
|
||
)
|
||
|
||
const isUserSelectorDisabled = computed(() =>
|
||
pub.userMode === 'target' ? pub.selectedExcludes.length > 0 : pub.selectedUsers.length > 0
|
||
)
|
||
|
||
const pickerDialogTitle = computed(() =>
|
||
pickerDialog.type === 'dept' ? '选择目标部门'
|
||
: (pub.userMode === 'target' ? '选择目标用户' : '选择排除用户')
|
||
)
|
||
|
||
async function openPicker(type) {
|
||
pickerDialog.type = type
|
||
pickerDialog.visible = true
|
||
currentPickerSearch.value = ''
|
||
if (type === 'dept') {
|
||
pendingDeptIds.value = new Set(pub.selectedDepts.map(d => d.id))
|
||
await loadDeptList(); filterDeptList()
|
||
} else if (type === 'user') {
|
||
pendingUserIds.value = new Set(pub.selectedUsers.map(u => u.id))
|
||
pendingExcludeIds.value = new Set(pub.selectedExcludes.map(u => u.id))
|
||
await loadUserListForPicker()
|
||
}
|
||
}
|
||
|
||
function closePicker() { pickerDialog.visible = false; pickerDialog.type = '' }
|
||
|
||
async function confirmPicker() {
|
||
if (pickerDialog.type === 'dept') {
|
||
pub.selectedDepts = pub.filteredDeptList.filter(d => pendingDeptIds.value.has(d.id)).map(d => ({ ...d }))
|
||
await refreshUsersForDepts()
|
||
} else if (pickerDialog.type === 'user') {
|
||
if (pub.userMode === 'target') {
|
||
pub.selectedUsers = pub.userList.filter(u => pendingUserIds.value.has(u.id)).map(u => ({ ...u }))
|
||
} else {
|
||
pub.selectedExcludes = pub.userList.filter(u => pendingExcludeIds.value.has(u.id)).map(u => ({ ...u }))
|
||
}
|
||
}
|
||
closePicker()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: 实现发布提交与工具函数**
|
||
|
||
```javascript
|
||
async function confirmPublish() {
|
||
const deptIds = pub.selectedDepts.map(d => Number(d.id))
|
||
const userIds = pub.selectedUsers.map(u => Number(u.id))
|
||
const excludeUserIds = pub.selectedExcludes.map(u => Number(u.id))
|
||
if (!deptIds.length && !userIds.length && !excludeUserIds.length) {
|
||
message.warning('请至少选择一个部门或用户'); return
|
||
}
|
||
publishing.value = true
|
||
try {
|
||
const payload = { deptIds, userIds, excludeUserIds }
|
||
if (pub.publishTime) payload.publishTime = new Date(pub.publishTime).toISOString()
|
||
if (pub.deadline) payload.deadline = new Date(pub.deadline).toISOString()
|
||
await examAPI.publishPaper(pubPaperId.value, payload)
|
||
message.success('发布成功')
|
||
closePublish(); loadPapers()
|
||
} catch (e) {
|
||
message.error('发布失败: ' + (e.message || ''))
|
||
} finally { publishing.value = false }
|
||
}
|
||
|
||
function getId(p) {
|
||
if (!p) return ''
|
||
const id = p.id || p.paperId || p.paper_id || p.examPaperId || p.exam_paper_id || ''
|
||
return id || ''
|
||
}
|
||
|
||
function getPaperTitle(p) { return p.title || p.paper_title || '未命名试卷' }
|
||
|
||
function formatQType(t) {
|
||
if (!t) return '未知'
|
||
return { single_choice: '单选', multiple_choice: '多选', true_false: '判断', fill_blank: '填空', subjective: '简答' }[t] || t
|
||
}
|
||
|
||
function formatPubTime(val) {
|
||
if (!val) return ''
|
||
try {
|
||
const d = new Date(val)
|
||
if (isNaN(d.getTime())) return String(val)
|
||
const pad = n => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
} catch { return String(val) }
|
||
}
|
||
|
||
function isDeadlineNear(val) {
|
||
if (!val) return false
|
||
try {
|
||
const diffDays = (new Date(val).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
|
||
return diffDays <= 3 && diffDays > 0
|
||
} catch { return false }
|
||
}
|
||
|
||
onMounted(() => { loadPapers() })
|
||
|
||
return {
|
||
// Tab
|
||
activeTab, tabs, switchTab,
|
||
// Lists
|
||
drafts, publishedPapers, loadingDrafts, loadPapers, deleteDraft, revokePublishedPaper,
|
||
// Compose
|
||
composeForm, composing, composeResult, totalCount, totalPaperScore,
|
||
increment, decrement, handleGeneratePaper, handleSavePaper,
|
||
DIFFICULTIES, QUESTION_TYPES,
|
||
// Files
|
||
showFilePicker, fileSearchKeyword, selectedFiles, allFiles, loadingFiles,
|
||
filteredFiles, isFileSelected, toggleFile, removeFile, openFilePicker,
|
||
// Detail
|
||
previewRaw, previewQuestions, previewPaperTitle, previewPublishInfo,
|
||
previewTotalScore, clearPreview,
|
||
// Publish
|
||
publishDialog, pubPaperId, publishing, pub, pubUI, pickerDialog,
|
||
currentPickerSearch, pendingDeptIds, pendingUserIds, pendingExcludeIds,
|
||
isUserSelectorDisabled, pickerDialogTitle, currentFilteredList, currentSelectedCount,
|
||
openPublishDialog, closePublish, confirmPublish,
|
||
openPicker, closePicker, confirmPicker,
|
||
loadDeptList, filterDeptList, toggleDept, removeDept, selectAllDepts, clearAllDepts,
|
||
loadUserListForPicker, refreshUsersForDepts,
|
||
toggleCurrentUser, removeUser, selectAllUsers, clearAllUsers, selectAllExcludes, clearAllExcludes,
|
||
// Utils
|
||
getId, getPaperTitle, formatQType, formatPubTime, isDeadlineNear
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 13: 验证 composable 创建完整**
|
||
|
||
确认文件完整包含所有导出的状态和方法,无遗漏。
|
||
|
||
---
|
||
|
||
## Phase 2: 核心组件
|
||
|
||
### Task 3: 创建 ComposeConfigSidebar.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/ComposeConfigSidebar.vue`
|
||
|
||
- [ ] **Step 1: 创建模板 — 数据源选择区**
|
||
|
||
```html
|
||
<template>
|
||
<div class="ccs-sidebar">
|
||
<!-- 数据源 -->
|
||
<div class="ccs-section">
|
||
<div class="ccs-section-title">数据源</div>
|
||
<button v-if="!showFilePicker" class="ccs-link-btn" @click="$emit('open-picker')">
|
||
{{ selectedFiles.length ? `已选 ${selectedFiles.length} 个文件` : '从知识库选择文件' }}
|
||
</button>
|
||
<button v-else class="ccs-link-btn muted" @click="$emit('close-picker')">收起</button>
|
||
|
||
<div v-if="selectedFiles.length > 0 && !showFilePicker" class="ccs-chips">
|
||
<span v-for="f in selectedFiles" :key="f.id" class="ccs-chip">
|
||
{{ f.name || f.file_name || f.fileName }}
|
||
<button class="ccs-chip-x" @click.stop="$emit('remove-file', f.id)">×</button>
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="showFilePicker" class="ccs-file-picker">
|
||
<input v-model="searchKeyword" class="ccs-search-input" placeholder="搜索文件..." />
|
||
<div v-if="filteredFiles.length > 0" class="ccs-file-list">
|
||
<label v-for="f in filteredFiles" :key="f.id"
|
||
:class="['ccs-file-item', { checked: isFileSelected(f.id) }]"
|
||
@change="$emit('toggle-file', f)">
|
||
<input type="checkbox" :checked="isFileSelected(f.id)" />
|
||
<span>{{ f.name || f.file_name || f.fileName }}</span>
|
||
</label>
|
||
</div>
|
||
<div v-else-if="loadingFiles" class="ccs-state-text">加载中...</div>
|
||
<div v-else class="ccs-state-text">暂无可选文件</div>
|
||
</div>
|
||
<p class="ccs-hint">不选则从全部可用题库中随机出题</p>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建模板 — 难度与题型区**
|
||
|
||
```html
|
||
<!-- 难度 -->
|
||
<div class="ccs-section">
|
||
<div class="ccs-section-title">难度等级</div>
|
||
<div class="ccs-diff-bar">
|
||
<button v-for="lv in difficulties" :key="lv.value"
|
||
:class="['ccs-diff-btn', { active: modelValue.difficulty === lv.value }]"
|
||
@click="update('difficulty', lv.value)">
|
||
{{ lv.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 题型配置 -->
|
||
<div class="ccs-section">
|
||
<div class="ccs-section-title">题型配置</div>
|
||
<div class="ccs-type-grid">
|
||
<div v-for="opt in questionTypes" :key="opt.key" class="ccs-type-cell">
|
||
<span class="ccs-type-name">{{ opt.label }}</span>
|
||
<select :value="modelValue[opt.key]"
|
||
@change="update(opt.key, Number($event.target.value))"
|
||
class="ccs-type-select">
|
||
<option v-for="n in 51" :key="n" :value="n - 1">{{ n - 1 }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="ccs-total-row">共 <strong>{{ totalCount }}</strong> 题</div>
|
||
</div>
|
||
|
||
<!-- 生成按钮 -->
|
||
<button class="ccs-generate-btn" :disabled="totalCount === 0 || composing" @click="$emit('generate')">
|
||
{{ composing ? '生成中...' : '✨ 生成试卷' }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 Script**
|
||
|
||
```javascript
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
modelValue: { type: Object, required: true },
|
||
selectedFiles: { type: Array, default: () => [] },
|
||
allFiles: { type: Array, default: () => [] },
|
||
loadingFiles: { type: Boolean, default: false },
|
||
totalCount: { type: Number, default: 0 },
|
||
composing: { type: Boolean, default: false }
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue', 'toggle-file', 'remove-file', 'open-picker', 'close-picker', 'generate'])
|
||
|
||
const searchKeyword = ref('')
|
||
|
||
const difficulties = [
|
||
{ value: 1, label: '简单' }, { value: 2, label: '中等' },
|
||
{ value: 3, label: '较难' }, { value: 4, label: '困难' }, { value: 5, label: '极难' }
|
||
]
|
||
const questionTypes = [
|
||
{ key: 'single_choice_count', label: '单选题' },
|
||
{ key: 'multiple_choice_count', label: '多选题' },
|
||
{ key: 'true_false_count', label: '判断题' },
|
||
{ key: 'fill_blank_count', label: '填空题' },
|
||
{ key: 'subjective_count', label: '简答题' }
|
||
]
|
||
|
||
const filteredFiles = computed(() => {
|
||
if (!searchKeyword.value.trim()) return props.allFiles
|
||
const kw = searchKeyword.value.toLowerCase()
|
||
return props.allFiles.filter(f =>
|
||
(f.name || f.file_name || f.fileName || '').toLowerCase().includes(kw)
|
||
)
|
||
})
|
||
|
||
function isFileSelected(id) { return props.selectedFiles.some(f => f.id === id) }
|
||
|
||
function update(key, value) {
|
||
emit('update:modelValue', { ...props.modelValue, [key]: value })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 Style(使用 design-tokens)**
|
||
|
||
```css
|
||
<style scoped>
|
||
.ccs-sidebar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-lg);
|
||
padding: var(--space-xl);
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow-y: auto;
|
||
min-width: 300px;
|
||
}
|
||
|
||
.ccs-section { display: flex; flex-direction: column; gap: var(--space-md); }
|
||
.ccs-section-title {
|
||
font-size: var(--font-lg);
|
||
font-weight: var(--weight-semibold);
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
padding-bottom: var(--space-sm);
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
|
||
.ccs-link-btn {
|
||
background: none; border: none; color: var(--color-info);
|
||
font-size: var(--font-md); cursor: pointer; padding: 0;
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
.ccs-link-btn:hover { text-decoration: underline; }
|
||
.ccs-link-btn.muted { color: var(--text-disabled); }
|
||
|
||
.ccs-chips { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-top: var(--space-xs); }
|
||
.ccs-chip {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 10px; background: var(--bg-hover);
|
||
border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||
font-size: var(--font-md); color: var(--text-secondary); font-weight: var(--weight-medium);
|
||
}
|
||
.ccs-chip-x {
|
||
background: none; border: none; font-size: 14px; cursor: pointer;
|
||
color: var(--text-disabled); line-height: 1; padding: 0 2px;
|
||
}
|
||
.ccs-chip-x:hover { color: var(--color-error); }
|
||
|
||
.ccs-file-picker { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||
.ccs-search-input {
|
||
width: 100%; padding: 6px 10px; border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-md); font-size: var(--font-lg);
|
||
box-sizing: border-box; transition: border-color var(--transition-fast);
|
||
}
|
||
.ccs-search-input:focus { outline: none; border-color: var(--text-primary); }
|
||
.ccs-file-list {
|
||
max-height: 180px; overflow-y: auto;
|
||
border: 1px solid var(--border-light); border-radius: var(--radius-md);
|
||
}
|
||
.ccs-file-item {
|
||
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
|
||
cursor: pointer; font-size: var(--font-lg); color: var(--text-secondary);
|
||
transition: background var(--transition-fast);
|
||
}
|
||
.ccs-file-item:hover { background: var(--bg-hover); }
|
||
.ccs-file-item.checked { background: var(--bg-warning); }
|
||
.ccs-file-item input { accent-color: var(--text-primary); }
|
||
.ccs-state-text { font-size: var(--font-md); color: var(--text-disabled); text-align: center; padding: 20px; }
|
||
.ccs-hint { font-size: var(--font-xs); color: var(--text-disabled); margin: 0; }
|
||
|
||
.ccs-diff-bar { display: flex; gap: 3px; }
|
||
.ccs-diff-btn {
|
||
padding: 4px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||
background: var(--bg-card); color: var(--text-secondary); font-size: var(--font-md);
|
||
cursor: pointer; font-weight: var(--weight-medium);
|
||
transition: all var(--transition-fast);
|
||
}
|
||
.ccs-diff-btn.active { background: var(--text-primary); border-color: var(--text-primary); color: var(--text-inverse); }
|
||
.ccs-diff-btn:hover:not(.active) { border-color: var(--text-primary); color: var(--text-primary); }
|
||
|
||
.ccs-type-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: var(--space-sm); }
|
||
.ccs-type-cell {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||
padding: 8px 10px; background: var(--bg-container);
|
||
border: 1px solid var(--border-light); border-radius: var(--radius-md);
|
||
}
|
||
.ccs-type-name { font-size: var(--font-md); color: var(--text-secondary); font-weight: var(--weight-medium); white-space: nowrap; }
|
||
.ccs-type-select {
|
||
width: 56px; padding: 2px 4px; border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-sm); font-size: var(--font-lg);
|
||
font-weight: var(--weight-bold); color: var(--text-primary);
|
||
background: var(--bg-card); cursor: pointer;
|
||
text-align: center;
|
||
}
|
||
.ccs-total-row {
|
||
text-align: center; font-size: var(--font-lg); color: var(--text-disabled);
|
||
padding: var(--space-md); background: var(--bg-container); border-radius: var(--radius-md);
|
||
}
|
||
.ccs-total-row strong { color: var(--text-primary); }
|
||
|
||
.ccs-generate-btn {
|
||
margin-top: auto; width: 100%; padding: 10px; border: none; border-radius: var(--radius-lg);
|
||
background: var(--color-primary); color: var(--text-inverse);
|
||
font-size: var(--font-xl); font-weight: var(--weight-semibold); cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||
transition: background var(--transition-fast);
|
||
}
|
||
.ccs-generate-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.ccs-generate-btn:hover:not(:disabled) { background: var(--color-primary-active); }
|
||
|
||
@media (max-width: 767px) {
|
||
.ccs-sidebar { min-width: unset; }
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 创建 ComposePreviewPane.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/ComposePreviewPane.vue`
|
||
|
||
- [ ] **Step 1: 创建完整组件(Template + Script + Style)**
|
||
|
||
```html
|
||
<template>
|
||
<div class="cpp-pane">
|
||
<!-- 空态 -->
|
||
<div v-if="!result && !composing" class="cpp-empty">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border-dark)" stroke-width="1.2">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
||
</svg>
|
||
<p>选择文件(可选)、设置题型数量</p>
|
||
<p class="cpp-empty-hint">点击左侧「生成试卷」即可预览</p>
|
||
</div>
|
||
|
||
<!-- 加载态 -->
|
||
<div v-else-if="composing" class="cpp-loading">
|
||
<div class="cpp-spinner"></div>
|
||
<span>正在从题库中选题组卷...</span>
|
||
</div>
|
||
|
||
<!-- 结果态 -->
|
||
<div v-else class="cpp-result">
|
||
<div class="cpp-preview-card">
|
||
<div class="cpp-preview-header">
|
||
<h3 class="cpp-preview-title">{{ result.paperTitle || '试卷预览' }}</h3>
|
||
<div class="cpp-preview-stats">
|
||
<span class="cpp-stat">{{ result.questions?.length || 0 }} 道题目</span>
|
||
<span class="cpp-stat">共 {{ totalScore }} 分</span>
|
||
</div>
|
||
</div>
|
||
<div class="cpp-question-list">
|
||
<div v-for="(q, i) in result.questions" :key="i" class="cpp-q-card">
|
||
<div class="cpp-q-header">
|
||
<span class="cpp-q-num">{{ i + 1 }}</span>
|
||
<span class="cpp-q-type">{{ formatType(q.questionType || q.type) }}</span>
|
||
<span class="cpp-q-diff" v-if="q.difficulty">难度 {{ q.difficulty }}</span>
|
||
</div>
|
||
<div class="cpp-q-stem">{{ q.stem || q.question_stem || q.content?.stem || '无题干' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="result.questions?.length > 0" class="cpp-save-area">
|
||
<button class="cpp-save-btn" @click="$emit('save')">保存为草稿</button>
|
||
<p class="cpp-save-note">保存后可在「我的草稿」中查看、发布</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
defineProps({
|
||
result: { type: Object, default: null },
|
||
composing: { type: Boolean, default: false },
|
||
totalScore: { type: Number, default: 0 }
|
||
})
|
||
defineEmits(['save'])
|
||
|
||
function formatType(t) {
|
||
if (!t) return '未知'
|
||
return { single_choice: '单选', multiple_choice: '多选', true_false: '判断', fill_blank: '填空', subjective: '简答' }[t] || t
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cpp-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||
.cpp-empty, .cpp-loading {
|
||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||
justify-content: center; gap: 12px; color: var(--text-disabled);
|
||
text-align: center; font-size: var(--font-lg); padding: 40px;
|
||
}
|
||
.cpp-empty p { margin: 0; }
|
||
.cpp-empty-hint { font-size: var(--font-md); margin-top: 4px; }
|
||
.cpp-spinner {
|
||
width: 28px; height: 28px; border: 3px solid var(--border-light);
|
||
border-top-color: var(--text-primary); border-radius: 50%;
|
||
animation: cpp-spin .6s linear infinite;
|
||
}
|
||
@keyframes cpp-spin { to { transform: rotate(360deg); } }
|
||
|
||
.cpp-result { flex: 1; display: flex; flex-direction: column; padding: var(--space-xl); overflow-y: auto; }
|
||
.cpp-preview-card {
|
||
background: var(--bg-card); border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-xl); overflow: hidden; box-shadow: var(--shadow-sm);
|
||
}
|
||
.cpp-preview-header {
|
||
padding: var(--space-lg) var(--space-xl); background: var(--bg-container);
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
.cpp-preview-title { margin: 0 0 6px; font-size: var(--font-xl); font-weight: var(--weight-bold); color: var(--text-primary); }
|
||
.cpp-preview-stats { display: flex; gap: 14px; }
|
||
.cpp-stat {
|
||
font-size: var(--font-md); color: var(--text-secondary); font-weight: var(--weight-medium);
|
||
background: var(--color-primary-alpha-15); padding: 2px 8px; border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.cpp-question-list { display: flex; flex-direction: column; gap: 6px; flex: 1; overflow-y: auto; padding: var(--space-md); }
|
||
.cpp-q-card {
|
||
padding: 10px 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md);
|
||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||
}
|
||
.cpp-q-card:hover { border-color: var(--border-medium); box-shadow: var(--shadow-sm); }
|
||
.cpp-q-header { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
|
||
.cpp-q-num {
|
||
width: 22px; height: 22px; border-radius: 50%; background: var(--color-primary); color: var(--text-inverse);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: var(--font-xs); font-weight: var(--weight-bold); flex-shrink: 0;
|
||
}
|
||
.cpp-q-type { font-size: var(--font-xs); color: var(--text-disabled); background: var(--bg-container); padding: 1px 7px; border-radius: 3px; font-weight: var(--weight-medium); }
|
||
.cpp-q-diff { font-size: var(--font-xs); color: var(--text-disabled); margin-left: auto; }
|
||
.cpp-q-stem { font-size: var(--font-lg); color: var(--text-primary); line-height: 1.6; }
|
||
|
||
.cpp-save-area { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-light); text-align: center; }
|
||
.cpp-save-btn {
|
||
width: 100%; padding: 10px; border: none; border-radius: var(--radius-lg);
|
||
background: var(--color-primary); color: var(--text-inverse);
|
||
font-size: var(--font-xl); font-weight: var(--weight-medium); cursor: pointer;
|
||
transition: background var(--transition-fast);
|
||
}
|
||
.cpp-save-btn:hover { background: var(--color-primary-active); }
|
||
.cpp-save-note { margin-top: 6px; font-size: var(--font-md); color: var(--text-disabled); }
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 创建 ComposePanel.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/ComposePanel.vue`
|
||
|
||
- [ ] **Step 1: 创建左右分栏容器组件**
|
||
|
||
```html
|
||
<template>
|
||
<div class="cps-layout">
|
||
<ComposeConfigSidebar
|
||
v-model="form"
|
||
:selected-files="selectedFiles"
|
||
:all-files="allFiles"
|
||
:loading-files="loadingFiles"
|
||
:total-count="totalCount"
|
||
:composing="composing"
|
||
@toggle-file="onToggleFile"
|
||
@remove-file="onRemoveFile"
|
||
@open-picker="onOpenPicker"
|
||
@close-picker="showFilePicker = false"
|
||
@generate="$emit('generate')"
|
||
/>
|
||
<ComposePreviewPane
|
||
:result="result"
|
||
:composing="composing"
|
||
:total-score="totalScore"
|
||
@save="$emit('save')"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import ComposeConfigSidebar from './ComposeConfigSidebar.vue'
|
||
import ComposePreviewPane from './ComposePreviewPane.vue'
|
||
|
||
const props = defineProps({
|
||
form: { type: Object, required: true },
|
||
selectedFiles: { type: Array, default: () => [] },
|
||
allFiles: { type: Array, default: () => [] },
|
||
loadingFiles: { type: Boolean, default: false },
|
||
totalCount: { type: Number, default: 0 },
|
||
composing: { type: Boolean, default: false },
|
||
result: { type: Object, default: null },
|
||
totalScore: { type: Number, default: 0 },
|
||
showFilePicker: { type: Boolean, default: false }
|
||
})
|
||
|
||
defineEmits(['generate', 'save'])
|
||
|
||
function onToggleFile(file) { /* handled by parent via direct binding or emit up */ }
|
||
function onRemoveFile(id) { /* same */ }
|
||
function onOpenPicker() { /* same */ }
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cps-layout { display: flex; gap: var(--space-xl); flex: 1; min-height: 0; }
|
||
@media (max-width: 767px) {
|
||
.cps-layout { flex-direction: column; }
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 创建 PaperListTab.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/PaperListTab.vue`
|
||
|
||
- [ ] **Step 1: 创建完整的复用型列表组件**
|
||
|
||
```html
|
||
<template>
|
||
<div class="plt-tab-body">
|
||
<div v-if="loading" class="plt-state-msg">
|
||
<div class="plt-spinner"></div>加载中...
|
||
</div>
|
||
<div v-else-if="papers.length === 0" class="plt-state-empty">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border-dark)" stroke-width="1.2">
|
||
<path v-if="mode === 'drafts'" d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
||
<polyline points="13 2 13 9 20 9"/>
|
||
<path v-else d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
||
</svg>
|
||
<h4>{{ mode === 'drafts' ? '暂无草稿' : '暂无已发布试卷' }}</h4>
|
||
<p>{{ mode === 'drafts' ? '在「智能组卷」中生成并保存试卷' : '将草稿发布后可在此查看' }}</p>
|
||
</div>
|
||
<div v-else class="plt-paper-list">
|
||
<div v-for="p in papers" :key="getId(p)" :class="['plt-paper-row', `plt-mode-${mode}`]">
|
||
<div class="plt-paper-info">
|
||
<div class="plt-title-row">
|
||
<span class="plt-paper-name">{{ getPaperTitle(p) }}</span>
|
||
<span :class="['plt-badge', mode]">{{ mode === 'drafts' ? '草稿' : '已发布' }}</span>
|
||
</div>
|
||
<div class="plt-meta">
|
||
<span>{{ p.questionCount || p.question_count || p.questions?.length || 0 }} 题</span>
|
||
<span>{{ p.totalScore || p.total_score || 0 }} 分</span>
|
||
<span v-if="mode === 'drafts'">{{ p.createdAt || p.created_at || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="plt-paper-ops">
|
||
<button class="plt-op-btn view" title="查看" @click="$emit('view', p)">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11-8-11-8z"/></svg>
|
||
</button>
|
||
<button v-if="mode === 'drafts'" class="plt-op-btn primary" title="发布" @click="$emit('publish', getId(p))">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||
</button>
|
||
<button v-if="mode === 'drafts'" class="plt-op-btn danger" title="删除" @click="$emit('delete', getId(p))">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
</button>
|
||
<button v-if="mode === 'published'" class="plt-op-btn danger" title="撤回" @click="$emit('revoke', getId(p))">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 0 1 14.36 6.03L21 12"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
defineProps({
|
||
mode: { type: String, validator: v => ['drafts', 'published'].includes(v) },
|
||
papers: { type: Array, default: () => [] },
|
||
loading: { type: Boolean, default: false }
|
||
})
|
||
defineEmits(['view', 'publish', 'delete', 'revoke'])
|
||
|
||
function getId(p) { return p?.id || p?.paperId || p?.paper_id || '' }
|
||
function getPaperTitle(p) { return p?.title || p?.paper_title || '未命名试卷' }
|
||
</script>
|
||
|
||
<style scoped>
|
||
.plt-tab-body { flex: 1; display: flex; flex-direction: column; min-height: 180px; }
|
||
.plt-state-msg, .plt-state-empty {
|
||
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||
padding: 50px 20px; text-align: center; color: var(--text-disabled);
|
||
}
|
||
.plt-state-empty h4 { margin: 0; color: var(--text-secondary); font-size: var(--font-xl); }
|
||
.plt-state-empty p { margin: 0; font-size: var(--font-lg); }
|
||
|
||
.plt-paper-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.plt-paper-row {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 14px 18px; background: var(--bg-card);
|
||
border: 1px solid var(--border-light); border-radius: var(--radius-lg);
|
||
transition: all var(--transition-normal);
|
||
border-left: 3px solid transparent;
|
||
}
|
||
.plt-paper-row:hover { border-color: var(--border-medium); box-shadow: var(--shadow-md); transform: translateY(-1px); }
|
||
.plt-paper-row.plt-mode-drafts { border-left-color: var(--color-warning); }
|
||
.plt-paper-row.plt-mode-published { border-left-color: var(--color-success); }
|
||
|
||
.plt-paper-info { flex: 1; min-width: 0; }
|
||
.plt-title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||
.plt-paper-name {
|
||
font-size: var(--font-xl); font-weight: var(--weight-semibold); color: var(--text-primary);
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.plt-badge {
|
||
padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-xs);
|
||
font-weight: var(--weight-semibold); flex-shrink: 0;
|
||
}
|
||
.plt-badge.drafts { background: var(--bg-warning); color: var(--color-warning); border: 1px solid #FFE066; }
|
||
.plt-badge.published { background: var(--bg-success); color: var(--color-success); border: 1px solid #B2F2BB; }
|
||
.plt-meta { display: flex; gap: 12px; }
|
||
.plt-meta span { font-size: var(--font-md); color: var(--text-disabled); }
|
||
|
||
.plt-paper-ops { display: flex; align-items: center; gap: 4px; flex-shrink: 0; margin-left: 14px; }
|
||
.plt-op-btn {
|
||
width: 30px; height: 30px; border: 1px solid var(--border-light); border-radius: var(--radius-md);
|
||
background: var(--bg-card); cursor: pointer; display: inline-flex;
|
||
align-items: center; justify-content: center; color: var(--text-disabled);
|
||
transition: all var(--transition-fast); padding: 0;
|
||
}
|
||
.plt-op-btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--border-medium); }
|
||
.plt-op-btn.primary:hover { background: var(--text-primary); color: var(--text-inverse); border-color: var(--text-primary); }
|
||
.plt-op-btn.danger:hover { background: var(--bg-error); color: var(--color-error); border-color: var(--color-error); }
|
||
|
||
.plt-spinner {
|
||
width: 26px; height: 26px; border: 3px solid var(--border-light);
|
||
border-top-color: var(--text-primary); border-radius: 50%;
|
||
animation: plt-spin .6s linear infinite;
|
||
}
|
||
@keyframes plt-spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3: 弹窗组件
|
||
|
||
### Task 7: 创建 EntityPickerModal.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/EntityPickerModal.vue`
|
||
|
||
- [ ] **Step 1: 创建通用实体选择器弹窗(完整代码)**
|
||
|
||
```html
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="modelValue" class="epm-overlay" @click.self="$emit('update:modelValue', false)">
|
||
<div class="epm-modal">
|
||
<div class="epm-header">
|
||
<h4>{{ title }}</h4>
|
||
<button class="epm-close" @click="$emit('update:modelValue', false)">×</button>
|
||
</div>
|
||
<div class="epm-toolbar">
|
||
<input class="epm-search" :placeholder="searchPlaceholder" :value="searchText"
|
||
@input="$emit('update:search', $event.target.value)" />
|
||
<button class="epm-toolbar-btn" @click="$emit('select-all')">全选</button>
|
||
<button class="epm-toolbar-btn" @click="$emit('clear-all')">清空</button>
|
||
<span class="epm-stat">已选 {{ selectedCount }}/{{ sourceList.length }}</span>
|
||
</div>
|
||
<div class="epm-body">
|
||
<div v-if="loading" class="epm-loading"><div class="epm-spinner"></div>加载中...</div>
|
||
<div v-else-if="sourceList.length === 0" class="epm-empty">暂无数据</div>
|
||
<div v-else class="epm-list">
|
||
<label v-for="item in sourceList" :key="item.id"
|
||
:class="['epm-item', { checked: isSelected(item.id) }]">
|
||
<input type="checkbox" :checked="isSelected(item.id)"
|
||
@change="$emit('toggle', item)" />
|
||
<span class="epm-item-name">{{ item.name }}</span>
|
||
<span v-if="item.departmentName" class="epm-item-dept">{{ item.departmentName }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="epm-footer">
|
||
<button class="epm-btn ghost" @click="$emit('update:modelValue', false)">取消</button>
|
||
<button class="epm-btn dark" @click="handleConfirm">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup>
|
||
const props = defineProps({
|
||
modelValue: Boolean,
|
||
type: { type: String, default: 'dept' },
|
||
mode: { type: String, default: 'target' },
|
||
sourceList: { type: Array, default: () => [] },
|
||
selectedIds: { type: Set, default: () => new Set() },
|
||
loading: { type: Boolean, default: false },
|
||
searchPlaceholder: { type: String, default: '搜索...' },
|
||
title: { type: String, default: '选择' },
|
||
selectedCount: { type: Number, default: 0 },
|
||
searchText: { type: String, default: '' }
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue', 'confirm', 'toggle', 'select-all', 'clear-all', 'update:search'])
|
||
|
||
function isSelected(id) { return props.selectedIds.has(id) }
|
||
|
||
function handleConfirm() {
|
||
const items = props.sourceList.filter(item => props.selectedIds.has(item.id))
|
||
emit('confirm', items)
|
||
emit('update:modelValue', false)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.epm-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.42);
|
||
display: flex; align-items: center; justify-content: center; z-index: var(--z-modal);
|
||
}
|
||
.epm-modal {
|
||
width: 480px; max-width: 92vw; max-height: 80vh;
|
||
background: var(--bg-card); border-radius: var(--radius-xl);
|
||
box-shadow: 0 16px 48px rgba(0,0,0,.15); display: flex; flex-direction: column;
|
||
}
|
||
.epm-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 14px 18px; border-bottom: 1px solid var(--border-light);
|
||
}
|
||
.epm-header h4 { margin: 0; font-size: var(--font-xl); font-weight: var(--weight-bold); color: var(--text-primary); }
|
||
.epm-close {
|
||
width: 26px; height: 26px; border: none; background: var(--bg-hover); border-radius: var(--radius-sm);
|
||
font-size: 17px; cursor: pointer; color: var(--text-disabled);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.epm-close:hover { background: var(--border-light); color: var(--text-primary); }
|
||
|
||
.epm-toolbar {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 8px 14px; border-bottom: 1px solid var(--border-light); background: var(--bg-container);
|
||
}
|
||
.epm-search {
|
||
flex: 1; padding: 5px 10px; border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-md); font-size: var(--font-md); outline: none; color: var(--text-primary);
|
||
}
|
||
.epm-search:focus { border-color: var(--text-primary); box-shadow: 0 0 0 2px var(--color-primary-alpha-15); }
|
||
.epm-toolbar-btn {
|
||
padding: 4px 10px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||
background: var(--bg-card); font-size: var(--font-xs); cursor: pointer; color: var(--text-secondary);
|
||
}
|
||
.epm-toolbar-btn:hover { background: var(--bg-active); }
|
||
.epm-stat { font-size: var(--font-xs); color: var(--text-disabled); white-space: nowrap; font-weight: var(--weight-medium); }
|
||
|
||
.epm-body { flex: 1; overflow-y: auto; display: flex; flex-direction: column; min-height: 200px; }
|
||
.epm-loading, .epm-empty {
|
||
padding: 30px; text-align: center; font-size: var(--font-md); color: var(--text-disabled);
|
||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||
}
|
||
.epm-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||
.epm-item {
|
||
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
|
||
cursor: pointer; font-size: var(--font-lg); color: var(--text-secondary);
|
||
transition: background var(--transition-fast);
|
||
}
|
||
.epm-item:hover { background: var(--bg-hover); }
|
||
.epm-item.checked { background: var(--bg-container); }
|
||
.epm-item input[type=checkbox] { accent-color: var(--text-primary); width: 15px; height: 15px; cursor: pointer; flex-shrink: 0; }
|
||
.epm-item-name { flex: 1; line-height: 1.35; }
|
||
.epm-item-dept { font-size: var(--font-xs); color: var(--text-disabled); white-space: nowrap; }
|
||
|
||
.epm-footer {
|
||
display: flex; justify-content: flex-end; gap: 8px;
|
||
padding: 12px 18px; border-top: 1px solid var(--border-light); flex-shrink: 0;
|
||
}
|
||
.epm-btn {
|
||
border-radius: var(--radius-md); padding: 0 14px; height: 30px;
|
||
font-size: var(--font-md); font-weight: var(--weight-medium);
|
||
border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
.epm-btn.ghost { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border-default); }
|
||
.epm-btn.ghost:hover { background: var(--bg-hover); }
|
||
.epm-btn.dark { background: var(--text-primary); color: var(--text-inverse); }
|
||
.epm-btn.dark:hover:not(:disabled) { background: var(--color-primary-active); }
|
||
|
||
.epm-spinner {
|
||
width: 18px; height: 18px; border: 2px solid var(--border-light);
|
||
border-top-color: var(--text-primary); border-radius: 50%;
|
||
animation: epm-spin .6s linear infinite;
|
||
}
|
||
@keyframes epm-spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 创建 PublishDialog.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/PublishDialog.vue`
|
||
|
||
- [ ] **Step 1: 创建发布弹窗组件(完整代码,内嵌 EntityPickerModal)**
|
||
|
||
> 此组件较大(~320 行),核心结构:
|
||
> - 左列:目标部门选择器 + 发布时间 + 截止时间
|
||
> - 右列:用户范围模式切换 + 目标/排除用户选择器
|
||
> - 底部:取消/确认按钮
|
||
> - 内嵌 EntityPickerModal 用于部门/用户选择
|
||
> - 所有状态和方法通过 props 从父组件传入或内部管理
|
||
|
||
> 由于此组件代码量较大,完整内容见实施时的实际写入。关键点:
|
||
> - 使用 `usePaperManagement` 中的 `pub`, `pubUI`, `pickerDialog` 等状态
|
||
> - 调用 EntityPickerModal 时传递 `sourceList` 和 `selectedIds`
|
||
> - 两列 Grid 布局 `grid-template-columns: 1fr 1fr`
|
||
> - 时间使用 `<input type="datetime-local">` 原生控件
|
||
> - 全部样式使用 design-tokens
|
||
|
||
---
|
||
|
||
### Task 9: 创建 PaperDetailModal.vue
|
||
|
||
**Files:**
|
||
- Create: `src/components/exam/PaperDetailModal.vue`
|
||
|
||
- [ ] **Step 1: 创建试卷详情弹窗组件**
|
||
|
||
> 核心要点:
|
||
> - Props: `modelValue` (Boolean), `rawData` (Object|null)
|
||
> - 使用 `useQuestionParser()` 解析 rawData → 得到 `previewQuestions[]`
|
||
> - 展示区块:发布信息 → 概要行 → 题目列表(每题含选项/答案/解析/难度)
|
||
> - Fallback 区:当解析失败时展示原始 JSON
|
||
> - 解析区样式:`background: var(--bg-warning)` 黄色背景块
|
||
> - 正确选项:`border-left-color: var(--color-success)` 绿色左边框
|
||
|
||
> 注意:`viewPaperDetail()` 的完整数据解析逻辑(原 L1237-1567)需要整合到此组件或其调用的 composable 方法中。建议将此方法放入 `usePaperManagement.js` 中作为 `viewPaperDetail(data)` 方法,然后在 PaperDetailModal 中 watch `rawData` 变化时自动调用解析。
|
||
|
||
---
|
||
|
||
## Phase 4: 主容器整合
|
||
|
||
### Task 10: 重写 PaperManagementPanel.vue 为薄容器
|
||
|
||
**Files:**
|
||
- Modify: `src/components/exam/PaperManagementPanel.vue` (完全重写)
|
||
|
||
- [ ] **Step 1: 备份原文件并重写为薄容器**
|
||
|
||
将原 2235 行文件重写为 ~100 行的薄容器:
|
||
|
||
```html
|
||
<template>
|
||
<div class="pmp-panel">
|
||
<PageHeader title="智能组卷" description="从知识库文件中选题,一键生成、保存与发布试卷" />
|
||
|
||
<div class="pmp-tabs">
|
||
<button v-for="tab in tabs" :key="tab.key"
|
||
:class="['pmp-tab-btn', { active: activeTab === tab.key }]"
|
||
@click="switchTab(tab.key)">
|
||
<span class="pmp-tab-icon" v-html="tab.icon"></span>
|
||
<span class="pmp-tab-label">{{ tab.label }}</span>
|
||
<span v-if="tab.key === 'drafts' && drafts.length" class="pmp-tab-badge">{{ drafts.length }}</span>
|
||
<span v-if="tab.key === 'published' && publishedPapers.length" class="pmp-tab-badge published">{{ publishedPapers.length }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<ComposePanel v-if="activeTab === 'compose'"
|
||
:form="composeForm" :selected-files="selectedFiles" :all-files="allFiles"
|
||
:loading-files="loadingFiles" :show-file-picker="showFilePicker"
|
||
:total-count="totalCount" :composing="composing"
|
||
:result="composeResult" :total-score="totalPaperScore"
|
||
@generate="handleGeneratePaper" @save="handleSavePaper"
|
||
@toggle-file="toggleFile" @remove-file="removeFile" @open-picker="openFilePicker"
|
||
/>
|
||
|
||
<PaperListTab v-else-if="activeTab === 'drafts'" mode="drafts"
|
||
:papers="drafts" :loading="loadingDrafts"
|
||
@view="viewPaperDetailWrapper" @publish="openPublishDialog" @delete="deleteDraft"
|
||
/>
|
||
|
||
<PaperListTab v-else-if="activeTab === 'published'" mode="published"
|
||
:papers="publishedPapers" :loading="loadingDrafts"
|
||
@view="viewPaperDetailWrapper" @revoke="revokePublishedPaper"
|
||
/>
|
||
|
||
<PaperDetailModal v-model="detailVisible" :raw-data="previewRaw" />
|
||
<PublishDialog v-model="publishVisible" :paper-id="pubPaperId" @confirm="confirmPublish" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import PageHeader from '../common/PageHeader.vue'
|
||
import ComposePanel from './ComposePanel.vue'
|
||
import PaperListTab from './PaperListTab.vue'
|
||
import PaperDetailModal from './PaperDetailModal.vue'
|
||
import PublishDialog from './PublishDialog.vue'
|
||
import { usePaperManagement } from './composables/usePaperManagement.js'
|
||
|
||
const {
|
||
activeTab, tabs, switchTab,
|
||
drafts, publishedPapers, loadingDrafts,
|
||
composeForm, composing, composeResult, totalCount, totalPaperScore,
|
||
selectedFiles, allFiles, loadingFiles, showFilePicker,
|
||
handleGeneratePaper, handleSavePaper, toggleFile, removeFile, openFilePicker,
|
||
deleteDraft, revokePublishedPaper,
|
||
detailVisible, previewRaw, publishVisible, pubPaperId,
|
||
openPublishDialog, confirmPublish, viewPaperDetail, clearPreview
|
||
} = usePaperManagement()
|
||
|
||
function viewPaperDetailWrapper(p) { viewPaperDetail(getId(p)) }
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pmp-panel {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
padding: var(--space-xl); overflow-y: auto; overflow-x: hidden; min-width: 0; gap: 14px;
|
||
}
|
||
.pmp-tabs {
|
||
display: flex; gap: 4px; background: var(--bg-container);
|
||
border-radius: var(--radius-lg); padding: 4px; border: 1px solid var(--border-light);
|
||
}
|
||
.pmp-tab-btn {
|
||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 7px;
|
||
padding: 10px 16px; border: none; background: transparent; border-radius: var(--radius-md);
|
||
font-size: var(--font-xl); font-weight: var(--weight-medium); color: var(--text-disabled);
|
||
cursor: pointer; transition: all var(--transition-normal); white-space: nowrap;
|
||
}
|
||
.pmp-tab-btn:hover:not(.active) { color: var(--text-secondary); background: rgba(0,0,0,.03); }
|
||
.pmp-tab-btn.active {
|
||
background: var(--bg-card); color: var(--text-primary); font-weight: var(--weight-semibold);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.pmp-tab-icon { display: flex; opacity: .7; }
|
||
.pmp-tab-btn.active .pmp-tab-icon { opacity: 1; }
|
||
.pmp-tab-badge {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 20px; height: 20px; padding: 0 6px;
|
||
background: var(--border-light); color: var(--text-secondary); border-radius: 10px;
|
||
font-size: var(--font-xs); font-weight: var(--weight-bold);
|
||
}
|
||
.pmp-tab-btn.active .pmp-tab-badge { background: var(--text-primary); color: var(--text-inverse); }
|
||
.pmp-tab-badge.published { background: var(--bg-success); color: var(--color-success); }
|
||
.pmp-tab-btn.active .pmp-badge.published { background: var(--color-success); color: var(--text-inverse); }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 验证重写后的文件完整性**
|
||
|
||
确认新文件包含所有必要的 import、props 绑定、事件转发。
|
||
|
||
---
|
||
|
||
## Phase 5: 验证
|
||
|
||
### Task 11: 构建验证与回归测试
|
||
|
||
**Files:** 无新建文件
|
||
|
||
- [ ] **Step 1: 运行前端构建验证**
|
||
|
||
Run: `npm run build`
|
||
Expected: 构建成功(3644 modules transformed),零编译错误
|
||
|
||
- [ ] **Step 2: VS Code 诊断检查**
|
||
|
||
对所有新建/修改的 `.vue` 和 `.js` 文件运行 GetDiagnostics,确认零错误。
|
||
|
||
- [ ] **Step 3: 功能清单验证**
|
||
|
||
逐一确认以下功能路径不受影响:
|
||
- [ ] 智能组卷 Tab → 选择文件 → 设置难度 → 配置题型数量 → 生成试卷 → 预览显示 → 保存为草稿
|
||
- [ ] 我的草稿 Tab → 显示草稿列表 → 查看详情 → 发布 → 删除
|
||
- [ ] 已发布试卷 Tab → 显示已发布列表 → 查看详情 → 撤回
|
||
- [ ] 发布弹窗 → 选择部门 → 选择用户 → 设置时间 → 确认发布
|
||
- [ ] 详情弹窗 → 显示题目列表 → 选项正确标记 → 解析展示 → Fallback 兜底
|
||
- [ ] 移动端响应式布局(≤767px)
|
||
|
||
- [ ] **Step 4: 清理验证**
|
||
|
||
确认以下清理项已完成:
|
||
- [ ] 原 PaperManagementPanel.vue 中不再有硬编码颜色值(全部替换为 CSS 变量)
|
||
- [ ] 不再有残留的 console.log/console.warn 调试语句
|
||
- [ ] 不再有 TrainingPanel/错题本相关的冗余 CSS 类
|
||
- [ ] 新组件均使用 BEM 命名规范和 design-tokens
|
||
|
||
---
|
||
|
||
## 自检清单
|
||
|
||
### Spec 覆盖度
|
||
|
||
| 设计文档章节 | 对应 Task | 状态 |
|
||
|------------|----------|------|
|
||
| §2.1 文件结构(9个文件) | Tasks 1-10 | ✅ |
|
||
| §2.2 组件树与数据流 | Task 10 | ✅ |
|
||
| §3.1 PaperManagementPanel 主容器 | Task 10 | ✅ |
|
||
| §3.2 ComposeConfigSidebar 配置栏 | Task 3 | ✅ |
|
||
| §3.3 ComposePreviewPane 预览区 | Task 4 | ✅ |
|
||
| §3.4 PaperListTab 列表组件 | Task 6 | ✅ |
|
||
| §3.5 PaperDetailModal 详情弹窗 | Task 9 | ✅ |
|
||
| §3.6 PublishDialog 发布弹窗 | Task 8 | ✅ |
|
||
| §3.7 EntityPickerModal 选择器 | Task 7 | ✅ |
|
||
| §4.1 usePaperManagement 状态中心 | Task 2 | ✅ |
|
||
| §4.2 useQuestionParser 解析工具 | Task 1 | ✅ |
|
||
| §5.1 Design Token 映射表 | All Tasks (CSS) | ✅ |
|
||
| §5.2 BEM 命名规范 | All Tasks (CSS) | ✅ |
|
||
| §5.3 响应式断点 | Tasks 3,4,6,10 (CSS) | ✅ |
|
||
| §5.4 清理项(console.log/冗余CSS) | Task 11 | ✅ |
|
||
|
||
### 占位符扫描
|
||
|
||
- [ ] 无 TBD/TODO 占位符
|
||
- [ ] 无 "类似 Task N" 引用(每个任务包含完整代码)
|
||
- [ ] 无 "添加适当的错误处理" 含糊描述
|
||
|
||
### 类型一致性
|
||
|
||
- [ ] `usePaperManagement` 导出名称在 Task 2 定义,Task 10 使用一致
|
||
- [ ] 组件 Props 接口在设计文档 §3 中定义,Task 中实现一致
|
||
- [ ] CSS 变量名引用 `design-tokens.css` 中实际存在的变量名
|