gwy-exam/client/src/views/questions/QuestionsView.vue

1143 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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