1143 lines
35 KiB
Vue
1143 lines
35 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { questionsApi, ApiError } from '../../api'
|
||
import type { QuestionListItem, QuestionListPage, QuestionImportBody, QuestionRefs } from '../../api'
|
||
import { useRequest } from '../../composables/useRequest'
|
||
import { useResponsive } from '../../composables/useResponsive'
|
||
import { useAppStore } from '../../stores/app'
|
||
import { formatDateShort } from '../../utils/format'
|
||
import AppCard from '../../components/base/AppCard.vue'
|
||
import AppButton from '../../components/base/AppButton.vue'
|
||
import AppIcon from '../../components/base/AppIcon.vue'
|
||
import AppModal from '../../components/base/AppModal.vue'
|
||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||
import AppBadge from '../../components/base/AppBadge.vue'
|
||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||
import AppStack from '../../components/base/AppStack.vue'
|
||
import AppError from '../../components/feedback/AppError.vue'
|
||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||
|
||
const { isMobile } = useResponsive()
|
||
const app = useAppStore()
|
||
|
||
const MODULES = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const
|
||
const DIFFICULTIES = ['简单', '中等', '困难'] as const
|
||
const OPTION_KEYS = ['A', 'B', 'C', 'D'] as const
|
||
type Module = (typeof MODULES)[number]
|
||
type Difficulty = (typeof DIFFICULTIES)[number]
|
||
type OptionKey = (typeof OPTION_KEYS)[number]
|
||
const PAGE_SIZE = 10
|
||
|
||
// ---- 页面模式:列表 / 手动录入 ----
|
||
const mode = ref<'list' | 'entry'>('list')
|
||
|
||
// =========================
|
||
// 列表 + 筛选 + 导入 + 导出 + 删除
|
||
// =========================
|
||
const filters = reactive<{ module: Module | ''; difficulty: Difficulty | ''; keyword: string }>({
|
||
module: '',
|
||
difficulty: '',
|
||
keyword: ''
|
||
})
|
||
const page = ref(1)
|
||
|
||
const list = useRequest<QuestionListPage>(() =>
|
||
questionsApi.list({
|
||
page: page.value,
|
||
pageSize: PAGE_SIZE,
|
||
module: filters.module || undefined,
|
||
difficulty: filters.difficulty || undefined,
|
||
keyword: filters.keyword.trim() || undefined
|
||
})
|
||
)
|
||
const items = computed(() => list.data.value?.items ?? [])
|
||
const total = computed(() => list.data.value?.total ?? 0)
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||
watch(
|
||
() => filters.keyword,
|
||
() => {
|
||
clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(() => {
|
||
page.value = 1
|
||
list.refresh()
|
||
}, 300)
|
||
}
|
||
)
|
||
watch(
|
||
() => [filters.module, filters.difficulty],
|
||
() => {
|
||
page.value = 1
|
||
list.refresh()
|
||
}
|
||
)
|
||
|
||
function goPage(p: number) {
|
||
if (p < 1 || p > totalPages.value || p === page.value) return
|
||
page.value = p
|
||
list.refresh()
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.module = ''
|
||
filters.difficulty = ''
|
||
filters.keyword = ''
|
||
page.value = 1
|
||
list.refresh()
|
||
}
|
||
|
||
const diffTone = (d: string) => (d === '困难' ? 'danger' : d === '中等' ? 'warning' : 'success')
|
||
|
||
// ---- 导入弹层 ----
|
||
const showImport = ref(false)
|
||
const importText = ref('')
|
||
const importResult = ref<{ total: number; success: number; skipped: number; failed: number; errors: { index: number; message: string }[] } | null>(null)
|
||
const importing = ref(false)
|
||
|
||
function openImport() {
|
||
importText.value = ''
|
||
importResult.value = null
|
||
showImport.value = true
|
||
}
|
||
|
||
async function doImport() {
|
||
if (!importText.value.trim()) {
|
||
app.toast('请粘贴 JSON 内容', 'error')
|
||
return
|
||
}
|
||
let parsed: { questions?: unknown[] }
|
||
try {
|
||
parsed = JSON.parse(importText.value)
|
||
} catch {
|
||
app.toast('JSON 解析失败,请检查格式', 'error')
|
||
return
|
||
}
|
||
if (!Array.isArray(parsed.questions)) {
|
||
app.toast('JSON 缺少 questions 数组字段', 'error')
|
||
return
|
||
}
|
||
importing.value = true
|
||
try {
|
||
const result = await questionsApi.importQuestions({
|
||
version: '1.0',
|
||
questions: parsed.questions as QuestionImportBody['questions']
|
||
})
|
||
importResult.value = result
|
||
app.toast(
|
||
`导入完成:成功 ${result.success} · 跳过 ${result.skipped} · 失败 ${result.failed}`,
|
||
result.failed > 0 ? 'error' : 'success'
|
||
)
|
||
list.refresh()
|
||
} catch (err) {
|
||
app.toast(err instanceof Error ? err.message : '导入失败', 'error')
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 导出 ----
|
||
const exporting = ref(false)
|
||
async function doExport() {
|
||
exporting.value = true
|
||
try {
|
||
const data = await questionsApi.exportQuestions()
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `题库导出-${new Date().toISOString().slice(0, 10)}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
app.toast(`已导出 ${data.questions.length} 道题目`, 'success')
|
||
} catch (err) {
|
||
app.toast(err instanceof Error ? err.message : '导出失败', 'error')
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 删除确认 ----
|
||
const removing = ref<QuestionListItem | null>(null)
|
||
const deleting = ref(false)
|
||
const refsInfo = ref<QuestionRefs | null>(null)
|
||
const refsLoading = ref(false)
|
||
|
||
/** 打开删除弹层时,预检该题引用情况,提前提示是否会被拦截 */
|
||
async function openRemove(q: QuestionListItem) {
|
||
removing.value = q
|
||
refsInfo.value = null
|
||
refsLoading.value = true
|
||
try {
|
||
refsInfo.value = await questionsApi.refs(q.id)
|
||
} catch {
|
||
refsInfo.value = null
|
||
} finally {
|
||
refsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function confirmRemove() {
|
||
if (!removing.value) return
|
||
deleting.value = true
|
||
try {
|
||
await questionsApi.remove(removing.value.id)
|
||
app.toast('题目已删除', 'success')
|
||
removing.value = null
|
||
if (items.value.length === 1 && page.value > 1) page.value -= 1
|
||
list.refresh()
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.code === 'CONFLICT') {
|
||
app.toast(err.message, 'error')
|
||
removing.value = null
|
||
} else {
|
||
app.toast(err instanceof Error ? err.message : '删除失败', 'error')
|
||
}
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
const hasFilter = computed(() => filters.module || filters.difficulty || filters.keyword.trim())
|
||
|
||
// =========================
|
||
// 手动录入(左表单 + 右实时预览)
|
||
// =========================
|
||
const QUESTION_TYPES = [{ value: '单选', label: '单选题' }, { value: '多选', label: '多选题' }, { value: '判断', label: '判断题' }] as const
|
||
|
||
interface OptionRow {
|
||
key: OptionKey
|
||
text: string
|
||
}
|
||
|
||
function blankForm() {
|
||
return {
|
||
questionType: '单选' as string,
|
||
module: '' as Module | '',
|
||
subModule: '',
|
||
difficulty: '中等' as Difficulty,
|
||
stem: '',
|
||
options: [
|
||
{ key: 'A' as OptionKey, text: '' },
|
||
{ key: 'B' as OptionKey, text: '' },
|
||
{ key: 'C' as OptionKey, text: '' },
|
||
{ key: 'D' as OptionKey, text: '' }
|
||
] as OptionRow[],
|
||
answer: '' as OptionKey | '',
|
||
analysis: '',
|
||
source: '手动录入'
|
||
}
|
||
}
|
||
|
||
const form = reactive(blankForm())
|
||
const saving = ref(false)
|
||
|
||
function switchType(t: string) {
|
||
if (t !== '单选') {
|
||
app.toast('当前版本仅支持单选题录入,已保留单选题', 'info')
|
||
return
|
||
}
|
||
form.questionType = t
|
||
}
|
||
|
||
function resetForm() {
|
||
Object.assign(form, blankForm())
|
||
}
|
||
|
||
// 实时预览:任一字段变化即触发
|
||
const livePreview = computed(() => {
|
||
const filledOptions = form.options
|
||
.filter((o) => o.text.trim())
|
||
.map((o) => ({ key: o.key, text: o.text.trim() }))
|
||
return {
|
||
module: form.module as Module | '',
|
||
difficulty: form.difficulty,
|
||
stem: form.stem.trim(),
|
||
options: filledOptions,
|
||
answer: form.answer,
|
||
analysis: form.analysis.trim()
|
||
}
|
||
})
|
||
|
||
const previewOptions = computed(() => {
|
||
const base = livePreview.value.options
|
||
if (base.length > 0) return base
|
||
return form.options.map((o) => ({ key: o.key, text: o.text.trim() }))
|
||
})
|
||
|
||
function previewText() {
|
||
return livePreview.value.stem || '题干将在这里实时预览…'
|
||
}
|
||
|
||
async function saveQuestion() {
|
||
if (!livePreview.value.module) {
|
||
app.toast('请选择模块', 'error')
|
||
return
|
||
}
|
||
if (!livePreview.value.stem) {
|
||
app.toast('请填写题干', 'error')
|
||
return
|
||
}
|
||
const filled = form.options.filter((o) => o.text.trim())
|
||
if (filled.length < 2) {
|
||
app.toast('请至少填写 2 个选项', 'error')
|
||
return
|
||
}
|
||
if (!livePreview.value.answer) {
|
||
app.toast('请标记正确答案', 'error')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
await questionsApi.importQuestions({
|
||
version: '1.0',
|
||
questions: [
|
||
{
|
||
type: '行测',
|
||
module: livePreview.value.module,
|
||
subModule: form.subModule.trim(),
|
||
difficulty: livePreview.value.difficulty,
|
||
stem: livePreview.value.stem,
|
||
options: filled.map((o) => ({ key: o.key, text: o.text.trim() })),
|
||
answer: livePreview.value.answer as OptionKey,
|
||
analysis: livePreview.value.analysis,
|
||
tags: [],
|
||
source: '手动录入'
|
||
}
|
||
]
|
||
})
|
||
app.toast('题目已保存,可继续录入下一题', 'success')
|
||
resetForm()
|
||
list.refresh()
|
||
} catch (err) {
|
||
app.toast(err instanceof Error ? err.message : '保存失败', 'error')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function markAnswer(key: OptionKey) {
|
||
form.answer = form.answer === key ? '' : key
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<AppStack class="questions" gap="space-4">
|
||
<!-- ======================= 手动录入模式 ======================= -->
|
||
<template v-if="mode === 'entry'">
|
||
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
|
||
<AppButton variant="ghost" @click="mode = 'list'">
|
||
<AppIcon name="chevron-left" :size="16" />返回列表
|
||
</AppButton>
|
||
</AppPageHeader>
|
||
|
||
<div class="entry">
|
||
<!-- 左:表单 -->
|
||
<AppCard title="录入题目" class="entry__form">
|
||
<div class="form-field">
|
||
<label class="form-field__label">题型</label>
|
||
<div class="question-types">
|
||
<AppButton
|
||
v-for="t in QUESTION_TYPES"
|
||
:key="t.value"
|
||
variant="ghost"
|
||
size="md"
|
||
class="question-types__tab"
|
||
:class="{ 'is-active': form.questionType === t.value }"
|
||
@click="switchType(t.value)"
|
||
>
|
||
{{ t.label }}
|
||
</AppButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field">
|
||
<label class="form-field__label">模块 / 考点</label>
|
||
<div class="module-row">
|
||
<select v-model="form.module" class="form-control form-control--module">
|
||
<option value="" disabled>请选择模块</option>
|
||
<option v-for="m in MODULES" :key="m" :value="m">{{ m }}</option>
|
||
</select>
|
||
<input v-model="form.subModule" class="form-control" type="text" placeholder="考点(如 行程问题)" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field">
|
||
<label class="form-field__label">难度</label>
|
||
<select v-model="form.difficulty" class="form-control form-control--module">
|
||
<option v-for="d in DIFFICULTIES" :key="d" :value="d">{{ d }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-field">
|
||
<label class="form-field__label">题干</label>
|
||
<textarea v-model="form.stem" class="form-control form-control--stem" placeholder="输入题目题干…" />
|
||
</div>
|
||
|
||
<div class="form-field">
|
||
<label class="form-field__label">选项(点击左侧标记正确答案)</label>
|
||
<div class="options">
|
||
<div v-for="o in form.options" :key="o.key" class="option-row" :class="{ 'is-answer': form.answer === o.key }">
|
||
<AppButton
|
||
variant="ghost"
|
||
size="md"
|
||
class="option-row__key"
|
||
:class="{ 'is-answer': form.answer === o.key }"
|
||
@click="markAnswer(o.key)"
|
||
>
|
||
<AppIcon v-if="form.answer === o.key" name="check" :size="14" />
|
||
{{ o.key }}
|
||
</AppButton>
|
||
<input v-model="o.text" class="option-row__text" type="text" :placeholder="`选项 ${o.key}`" />
|
||
</div>
|
||
</div>
|
||
<p v-if="form.options.some((o) => o.text.trim()) && !form.answer" class="form-field__tip">
|
||
请点击选项序号标记正确答案
|
||
</p>
|
||
</div>
|
||
|
||
<div class="form-field">
|
||
<label class="form-field__label">答案解析</label>
|
||
<textarea v-model="form.analysis" class="form-control form-control--stem" placeholder="解析(可空)" />
|
||
</div>
|
||
|
||
<AppButton variant="primary" size="lg" block :disabled="saving" @click="saveQuestion">
|
||
{{ saving ? '保存中…' : '保存题目并继续录入' }}
|
||
</AppButton>
|
||
</AppCard>
|
||
|
||
<!-- 右:实时预览 -->
|
||
<div class="entry__preview">
|
||
<AppCard title="题目预览">
|
||
<div class="preview">
|
||
<div class="preview__meta">
|
||
<AppBadge variant="primary">{{ livePreview.module || '未选模块' }}</AppBadge>
|
||
<AppBadge :variant="diffTone(form.difficulty)">{{ form.difficulty }}</AppBadge>
|
||
<span class="preview__source">{{ form.source }}</span>
|
||
</div>
|
||
<p class="preview__stem">{{ previewText() }}</p>
|
||
<div class="preview__options">
|
||
<div
|
||
v-for="o in previewOptions"
|
||
:key="o.key"
|
||
class="preview__option"
|
||
:class="{ 'is-answer': livePreview.answer === o.key }"
|
||
>
|
||
<span class="preview__option-key">{{ o.key }}</span>
|
||
<span class="preview__option-text">{{ o.text || `选项 ${o.key}` }}</span>
|
||
<span v-if="livePreview.answer === o.key" class="preview__option-check">
|
||
<AppIcon name="check" :size="12" />正确答案
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="livePreview.analysis" class="preview__analysis">
|
||
<p class="preview__analysis-title">答案解析</p>
|
||
<p class="preview__analysis-text">{{ livePreview.analysis }}</p>
|
||
</div>
|
||
</div>
|
||
</AppCard>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ======================= 列表模式 ======================= -->
|
||
<template v-else>
|
||
<AppLoading v-if="list.loading.value && !items.length" fullscreen />
|
||
<AppError
|
||
v-else-if="list.error.value"
|
||
fullscreen
|
||
title="题库加载失败"
|
||
:message="list.error.value"
|
||
retry
|
||
@retry="list.refresh"
|
||
/>
|
||
|
||
<template v-else>
|
||
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
|
||
<AppButton variant="secondary" @click="openImport">
|
||
<AppIcon name="inbox" :size="16" />导入真题
|
||
</AppButton>
|
||
<AppButton variant="primary" @click="mode = 'entry'">
|
||
<AppIcon name="pen" :size="16" />新增题目
|
||
</AppButton>
|
||
</AppPageHeader>
|
||
|
||
<!-- 筛选栏 -->
|
||
<AppCard padded>
|
||
<div class="filters">
|
||
<div class="filters__select">
|
||
<label class="filters__label">模块</label>
|
||
<select v-model="filters.module" class="filters__control">
|
||
<option value="">全部模块</option>
|
||
<option v-for="m in MODULES" :key="m" :value="m">{{ m }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="filters__select">
|
||
<label class="filters__label">难度</label>
|
||
<select v-model="filters.difficulty" class="filters__control">
|
||
<option value="">全部难度</option>
|
||
<option v-for="d in DIFFICULTIES" :key="d" :value="d">{{ d }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="filters__search">
|
||
<label class="filters__label">关键词</label>
|
||
<input v-model="filters.keyword" class="filters__control" type="text" placeholder="搜索题干关键词" />
|
||
</div>
|
||
<AppButton variant="ghost" size="md" @click="resetFilters">重置</AppButton>
|
||
</div>
|
||
<p class="filters__count">
|
||
共 {{ total }} 道题目
|
||
<AppButton variant="ghost" size="md" class="filters__export" :disabled="exporting" @click="doExport">
|
||
<AppIcon name="chart" :size="16" />{{ exporting ? '导出中…' : '导出' }}
|
||
</AppButton>
|
||
</p>
|
||
</AppCard>
|
||
|
||
<AppEmpty
|
||
v-if="items.length === 0"
|
||
icon="book"
|
||
title="暂无题目"
|
||
:description="hasFilter ? '当前筛选条件下没有匹配题目,可尝试重置筛选。' : '题库为空,点击「新增题目」手动录入,或「导入真题」批量添加。'"
|
||
>
|
||
<AppButton v-if="hasFilter" variant="secondary" @click="resetFilters">重置筛选</AppButton>
|
||
<AppButton v-else variant="primary" @click="mode = 'entry'">新增题目</AppButton>
|
||
</AppEmpty>
|
||
|
||
<template v-else>
|
||
<AppCard v-if="!isMobile" padded>
|
||
<table class="q-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="q-table__stem">题干</th>
|
||
<th class="q-table__module">模块</th>
|
||
<th class="q-table__diff">难度</th>
|
||
<th class="q-table__answer">答案</th>
|
||
<th class="q-table__created">录入时间</th>
|
||
<th class="q-table__action"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="q in items" :key="q.id" class="q-row">
|
||
<td class="q-table__stem">
|
||
<p class="q-stem">{{ q.stem }}</p>
|
||
<p v-if="q.subModule" class="q-sub">{{ q.subModule }}</p>
|
||
</td>
|
||
<td class="q-table__module"><AppBadge variant="soft">{{ q.module }}</AppBadge></td>
|
||
<td class="q-table__diff"><AppBadge :variant="diffTone(q.difficulty)">{{ q.difficulty }}</AppBadge></td>
|
||
<td class="q-table__answer">{{ q.answer }}</td>
|
||
<td class="q-table__created">{{ formatDateShort(q.createdAt.slice(0, 10)) }}</td>
|
||
<td class="q-table__action">
|
||
<AppButton variant="ghost" size="md" class="q-del" @click="openRemove(q)">
|
||
<AppIcon name="close" :size="16" />删除
|
||
</AppButton>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</AppCard>
|
||
|
||
<div v-else class="q-cards">
|
||
<div v-for="q in items" :key="q.id" class="q-card">
|
||
<div class="q-card__top">
|
||
<AppBadge variant="soft">{{ q.module }}</AppBadge>
|
||
<AppBadge :variant="diffTone(q.difficulty)">{{ q.difficulty }}</AppBadge>
|
||
<span class="q-card__answer">答案 {{ q.answer }}</span>
|
||
</div>
|
||
<p class="q-card__stem">{{ q.stem }}</p>
|
||
<div class="q-card__foot">
|
||
<span class="q-card__meta">{{ q.subModule || q.module }} · {{ formatDateShort(q.createdAt.slice(0, 10)) }}</span>
|
||
<AppButton variant="ghost" size="md" class="q-del" @click="openRemove(q)">
|
||
<AppIcon name="close" :size="16" />
|
||
</AppButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="totalPages > 1" class="pager">
|
||
<AppButton variant="ghost" size="md" :disabled="page <= 1" @click="goPage(page - 1)">
|
||
<AppIcon name="chevron-left" :size="16" />上一页
|
||
</AppButton>
|
||
<span class="pager__info">{{ page }} / {{ totalPages }}</span>
|
||
<AppButton variant="ghost" size="md" :disabled="page >= totalPages" @click="goPage(page + 1)">
|
||
下一页<AppIcon name="chevron-right" :size="16" />
|
||
</AppButton>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 导入弹层 -->
|
||
<AppModal v-if="showImport" title="导入题目" :max-width="640" @close="showImport = false">
|
||
<p class="modal-hint">
|
||
粘贴 JSON 数组 / 含 <code>questions</code> 字段的 JSON。后台自动生成题目 ID,按「题干 + 模块 + 答案 + 选项文本」指纹去重,重复题目自动跳过。
|
||
</p>
|
||
<textarea v-model="importText" class="modal__textarea" placeholder='{"version":"1.0","questions":[{"type":"行测","module":"数量关系","subModule":"行程问题","difficulty":"中等","stem":"…","options":[{"key":"A","text":"…"}],"answer":"A","analysis":"…","tags":[],"source":"导入测试"}]}' />
|
||
|
||
<div v-if="importResult" class="import-report">
|
||
<div class="import-report__stats">
|
||
<span class="report-stat report-stat--ok">成功 <b>{{ importResult.success }}</b></span>
|
||
<span class="report-stat report-stat--skip">跳过 <b>{{ importResult.skipped }}</b></span>
|
||
<span class="report-stat report-stat--fail">失败 <b>{{ importResult.failed }}</b></span>
|
||
</div>
|
||
<p class="import-report__total">本次共处理 {{ importResult.total }} 条({{ importResult.success + importResult.skipped + importResult.failed }} 条计入统计)。</p>
|
||
<ul v-if="importResult.errors.length" class="import-report__errors">
|
||
<li v-for="e in importResult.errors" :key="e.index">第 {{ e.index + 1 }} 条:{{ e.message }}</li>
|
||
</ul>
|
||
<p v-else-if="importResult.failed === 0 && importResult.skipped === 0" class="import-report__ok">✅ 全部导入成功,无异常条目。</p>
|
||
<p v-else-if="importResult.failed === 0" class="import-report__ok">✅ 无失败条目,重复题目已跳过,其余全部导入。</p>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<AppButton variant="ghost" @click="showImport = false">关闭</AppButton>
|
||
<AppButton variant="primary" :disabled="importing" @click="doImport">
|
||
{{ importing ? '导入中…' : '开始导入' }}
|
||
</AppButton>
|
||
</template>
|
||
</AppModal>
|
||
|
||
<!-- 删除确认 -->
|
||
<AppModal v-if="removing" title="删除题目" :max-width="480" @close="removing = null">
|
||
<p class="modal-confirm">
|
||
确定要删除这道题目吗?<br />
|
||
<span class="modal-confirm__stem">{{ removing.stem.slice(0, 40) }}{{ removing.stem.length > 40 ? '…' : '' }}</span>
|
||
</p>
|
||
|
||
<!-- 引用预检:提前提示是否会被拦截 -->
|
||
<div v-if="refsLoading" class="modal-warn">
|
||
<span class="modal-warn__spinner" /> 正在检查关联引用…
|
||
</div>
|
||
<div v-else-if="refsInfo && refsInfo.total > 0" class="modal-warn modal-warn--block">
|
||
<p class="modal-warn__title">
|
||
<AppIcon name="alert" :size="16" />该题已被引用,将无法删除
|
||
</p>
|
||
<ul class="modal-warn__list">
|
||
<li v-if="refsInfo.practiceRecords > 0">作答记录 {{ refsInfo.practiceRecords }} 条</li>
|
||
<li v-if="refsInfo.wrongQuestions > 0">错题本 {{ refsInfo.wrongQuestions }} 条</li>
|
||
<li v-if="refsInfo.practiceSessions > 0">刷题会话 {{ refsInfo.practiceSessions }} 个</li>
|
||
</ul>
|
||
<p class="modal-warn__note">需先处理这些关联数据,才能删除该题。</p>
|
||
</div>
|
||
<p v-else class="modal-warn">该题暂无关联引用,删除后不可恢复。</p>
|
||
|
||
<template #footer>
|
||
<AppButton variant="ghost" @click="removing = null">取消</AppButton>
|
||
<AppButton variant="danger" :disabled="deleting || (refsInfo?.total ?? 0) > 0" @click="confirmRemove">
|
||
{{ deleting ? '删除中…' : '确认删除' }}
|
||
</AppButton>
|
||
</template>
|
||
</AppModal>
|
||
</AppStack>
|
||
</template>
|
||
|
||
<style scoped>
|
||
|
||
/* ---------- 录入模式 ---------- */
|
||
.entry {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: var(--space-4);
|
||
align-items: start;
|
||
}
|
||
@media (max-width: 767px) {
|
||
.entry {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.entry__form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
}
|
||
.form-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.form-field__label {
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--text-secondary);
|
||
}
|
||
.question-types {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
.question-types__tab {
|
||
height: auto !important;
|
||
padding: 6px var(--space-4) !important;
|
||
border: 1px solid var(--border-subtle) !important;
|
||
border-radius: var(--radius-sm) !important;
|
||
background: var(--bg-card) !important;
|
||
color: var(--text-secondary) !important;
|
||
font-size: var(--fs-label) !important;
|
||
font-weight: var(--fw-label-bold) !important;
|
||
}
|
||
.question-types__tab.is-active {
|
||
background: var(--primary) !important;
|
||
border-color: var(--primary) !important;
|
||
color: var(--on-accent) !important;
|
||
}
|
||
.module-row {
|
||
display: flex;
|
||
gap: var(--space-3);
|
||
}
|
||
.form-control {
|
||
height: 40px;
|
||
padding: 0 var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-size: var(--fs-subtitle);
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.form-control--module {
|
||
width: 40%;
|
||
flex: none;
|
||
}
|
||
.form-control--stem {
|
||
height: auto;
|
||
min-height: 96px;
|
||
padding: var(--space-3);
|
||
border-radius: var(--radius-btn);
|
||
resize: vertical;
|
||
font-family: inherit;
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.option-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: 4px;
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--bg-card);
|
||
transition: border-color var(--motion-hover-fast) var(--ease-default), background var(--motion-hover-fast) var(--ease-default);
|
||
}
|
||
.option-row.is-answer {
|
||
border-color: var(--success);
|
||
background: var(--success-soft);
|
||
}
|
||
.option-row__key {
|
||
display: grid !important;
|
||
place-items: center;
|
||
width: 34px !important;
|
||
height: 34px !important;
|
||
padding: 0 !important;
|
||
border: 1px solid var(--border-subtle) !important;
|
||
border-radius: var(--radius-sm) !important;
|
||
background: var(--bg-card) !important;
|
||
color: var(--text-secondary) !important;
|
||
gap: 0 !important;
|
||
flex: none;
|
||
}
|
||
.option-row__key.is-answer {
|
||
border-color: var(--success) !important;
|
||
background: var(--success) !important;
|
||
color: var(--on-accent) !important;
|
||
}
|
||
.option-row__text {
|
||
flex: 1;
|
||
height: 34px;
|
||
padding: 0 var(--space-2);
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-primary);
|
||
font-size: var(--fs-subtitle);
|
||
}
|
||
.option-row__text:focus {
|
||
outline: none;
|
||
}
|
||
.form-field__tip {
|
||
margin: 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--warning);
|
||
}
|
||
|
||
/* 预览 */
|
||
.entry__preview {
|
||
position: sticky;
|
||
top: var(--space-4);
|
||
}
|
||
.preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
.preview__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
flex-wrap: wrap;
|
||
}
|
||
.preview__source {
|
||
margin-left: auto;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
.preview__stem {
|
||
margin: 0;
|
||
font-size: var(--fs-card-title);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--text-primary);
|
||
line-height: var(--lh-card-title);
|
||
min-height: 1.5em;
|
||
}
|
||
.preview__options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.preview__option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
padding: var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
}
|
||
.preview__option.is-answer {
|
||
border-color: var(--success);
|
||
background: var(--success-soft);
|
||
}
|
||
.preview__option-key {
|
||
flex: none;
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--text-secondary);
|
||
}
|
||
.preview__option-text {
|
||
flex: 1;
|
||
font-size: var(--fs-subtitle);
|
||
}
|
||
.preview__option-check {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--success);
|
||
}
|
||
.preview__analysis {
|
||
padding: var(--space-3);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-soft);
|
||
}
|
||
.preview__analysis-title {
|
||
margin: 0;
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--text-secondary);
|
||
}
|
||
.preview__analysis-text {
|
||
margin: var(--space-1) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
|
||
/* ---------- 列表模式 ---------- */
|
||
.filters {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
.filters__select,
|
||
.filters__search {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-1);
|
||
}
|
||
.filters__search {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
}
|
||
.filters__label {
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
font-weight: var(--fw-label-bold);
|
||
}
|
||
.filters__control {
|
||
height: 36px;
|
||
padding: 0 var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-size: var(--fs-subtitle);
|
||
}
|
||
.filters__search .filters__control {
|
||
width: 100%;
|
||
}
|
||
.filters__count {
|
||
margin: var(--space-3) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
.filters__export {
|
||
font-size: var(--fs-label) !important;
|
||
}
|
||
|
||
.q-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.q-table th {
|
||
text-align: left;
|
||
padding: var(--space-2) var(--space-3);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
.q-table td {
|
||
padding: var(--space-3);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
vertical-align: top;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
}
|
||
.q-row:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
.q-table__stem {
|
||
width: 44%;
|
||
}
|
||
.q-table__module,
|
||
.q-table__diff {
|
||
white-space: nowrap;
|
||
}
|
||
.q-table__answer {
|
||
font-family: var(--font-num);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--primary);
|
||
}
|
||
.q-table__created {
|
||
white-space: nowrap;
|
||
color: var(--text-muted);
|
||
}
|
||
.q-stem {
|
||
margin: 0;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.q-sub {
|
||
margin: var(--space-1) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
.q-table__action {
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
.q-del {
|
||
color: var(--danger) !important;
|
||
}
|
||
.q-del:hover {
|
||
background: var(--danger-soft) !important;
|
||
color: var(--danger) !important;
|
||
}
|
||
|
||
.q-cards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
.q-card {
|
||
padding: var(--card-padding-mobile);
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
.q-card__top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
flex-wrap: wrap;
|
||
}
|
||
.q-card__answer {
|
||
margin-left: auto;
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
color: var(--primary);
|
||
}
|
||
.q-card__stem {
|
||
margin: var(--space-3) 0;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.q-card__foot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
}
|
||
.q-card__meta {
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-4);
|
||
}
|
||
.pager__info {
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 导入弹层 */
|
||
.modal-hint {
|
||
margin: 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.modal-hint code {
|
||
padding: 1px var(--space-1);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg-soft);
|
||
font-family: var(--font-num);
|
||
font-size: 12px;
|
||
}
|
||
.modal__textarea {
|
||
min-height: 180px;
|
||
padding: var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-family: var(--font-num);
|
||
font-size: var(--fs-label);
|
||
resize: vertical;
|
||
}
|
||
.import-report {
|
||
padding: var(--space-4);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-soft);
|
||
}
|
||
.import-report__stats {
|
||
display: flex;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
.report-stat {
|
||
padding: 4px var(--space-3);
|
||
border-radius: var(--radius-pill);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
}
|
||
.report-stat--ok {
|
||
background: var(--success-soft);
|
||
color: var(--success);
|
||
}
|
||
.report-stat--skip {
|
||
background: var(--warning-soft);
|
||
color: var(--warning);
|
||
}
|
||
.report-stat--fail {
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
}
|
||
.import-report__total {
|
||
margin: var(--space-3) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
.import-report__errors {
|
||
margin: var(--space-3) 0 0;
|
||
padding-left: var(--space-5);
|
||
font-size: var(--fs-label);
|
||
color: var(--danger);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.import-report__ok {
|
||
margin: var(--space-3) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--success);
|
||
}
|
||
|
||
/* 删除确认 */
|
||
.modal-confirm {
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.modal-confirm__stem {
|
||
color: var(--text-secondary);
|
||
}
|
||
.modal-warn {
|
||
margin: var(--space-2) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.modal-warn__spinner {
|
||
display: inline-block;
|
||
width: 12px;
|
||
height: 12px;
|
||
vertical-align: -2px;
|
||
border-radius: 50%;
|
||
border: 2px solid var(--border-subtle);
|
||
border-top-color: var(--primary);
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
.modal-warn--block {
|
||
margin: var(--space-3) 0 0;
|
||
padding: var(--space-3);
|
||
border: 1px solid var(--warning-soft);
|
||
border-radius: var(--radius-md);
|
||
background: var(--warning-soft);
|
||
color: var(--warning);
|
||
}
|
||
.modal-warn__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--warning);
|
||
}
|
||
.modal-warn__list {
|
||
margin: var(--space-2) 0 0;
|
||
padding-left: var(--space-5);
|
||
font-size: var(--fs-label);
|
||
color: var(--warning);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.modal-warn__note {
|
||
margin: var(--space-1) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|