This commit is contained in:
jocay
2026-08-17 23:57:43 +08:00
parent 005260f893
commit 29b0f03c24
11 changed files with 1085 additions and 256 deletions
+21 -8
View File
@@ -12,23 +12,36 @@ export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export async function api(path, options = {}) {
async function request(path, options = {}, withAuth = false) {
const headers = Object.assign({}, options.headers || {})
if (getToken()) headers.Authorization = 'Bearer ' + getToken()
const requestToken = withAuth ? getToken() : ''
if (requestToken) headers.Authorization = 'Bearer ' + requestToken
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'
const res = await fetch('/api' + path, Object.assign({}, options, { headers }))
let res
try {
res = await fetch('/api' + path, Object.assign({}, options, { headers }))
} catch (cause) {
const err = new Error('无法连接服务器,请检查网络后重试')
err.cause = cause
throw err
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || '请求失败')
err.status = res.status
err.code = data.code
// 只清除这次请求实际携带、且仍是当前值的 token;避免迟到的旧请求
// 把另一个标签页或并发登录刚写入的新会话误删。
if (withAuth && res.status === 401 && requestToken && getToken() === requestToken) clearToken()
throw err
}
return data
}
export async function apiPublic(path, options = {}) {
const res = await fetch('/api' + path, options)
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || '请求失败')
return data
export async function api(path, options = {}) {
return request(path, options, true)
}
export async function apiPublic(path, options = {}) {
return request(path, options, false)
}
+4 -2
View File
@@ -9,8 +9,10 @@ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^<>]*>/g
export function escapeSmartHtml(html) {
const placeholders = []
const text = String(html).replace(TAG_RE, (m, tag) => {
if (ALLOWED_TAGS.includes(tag.toLowerCase())) {
placeholders.push(m)
const normalizedTag = tag.toLowerCase()
if (ALLOWED_TAGS.includes(normalizedTag)) {
// 只保留标签本身,不保留 onclick/style 等属性,避免 authored HTML 注入脚本或样式。
placeholders.push(m.startsWith('</') ? `</${normalizedTag}>` : `<${normalizedTag}>`)
return '\u0000' + (placeholders.length - 1) + '\u0000'
}
return m
+17 -2
View File
@@ -105,6 +105,7 @@ body {
padding: 7px 14px;
border-radius: 999px;
transition: all .2s;
white-space: nowrap;
}
.nav-link:hover { background: var(--surface-high); }
@@ -159,6 +160,15 @@ body {
.btn:active { transform: scale(.97); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 3px solid rgba(61, 107, 94, .28);
outline-offset: 2px;
}
.btn-filled { background: var(--ink); color: var(--surface); }
.btn-filled:hover:not(:disabled) { box-shadow: var(--shadow-lift); }
@@ -297,7 +307,12 @@ body {
}
@media (max-width: 640px) {
.app-header { padding: 20px 16px 12px; }
.app-header { padding: 18px 14px 12px; gap: 9px; }
.brand-seal { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; }
.card { padding: 20px; border-radius: 20px; }
.brand-name { font-size: 18px; }
.brand-name { font-size: 18px; letter-spacing: 1px; }
.brand-sub { font-size: 9px; letter-spacing: 1.5px; white-space: nowrap; }
.app-nav { gap: 0; }
.nav-link { padding: 6px 8px; font-size: 12px; }
.app-main { padding: 8px 14px 32px; }
}
+354 -85
View File
@@ -1,9 +1,10 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { api, getToken, setToken, clearToken } from '../api.js'
import { api, apiPublic, getToken, setToken, clearToken } from '../api.js'
import { escapeSmartHtml, decodeEntities, stripHtmlAndDecode } from '../htmlUtil.mjs'
const authed = ref(false)
const booting = ref(Boolean(getToken()))
const loginUser = ref('')
const loginPass = ref('')
const loginMsg = ref('')
@@ -13,123 +14,218 @@ const view = ref('list')
const currentSetId = ref(null)
const qlist = ref([])
const busy = ref(false)
const notice = reactive({ text: '', type: 'ok' })
let setsRequestId = 0
const keyOf = i => String.fromCharCode(65 + i)
const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
const setForm = reactive({ show: false, editingId: null, title: '', name: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' })
const setForm = reactive({ show: false, editingId: null, title: '', name: '', description: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' })
const importForm = reactive({ show: false, text: '', name: '', msg: '' })
const importFileRef = ref(null)
const qForm = reactive({
show: false, editingId: null, title: '',
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
})
const qFormSnapshot = ref('')
const currentSet = computed(() => sets.value.find(s => s.id === currentSetId.value) || null)
const publishedCount = computed(() => sets.value.filter(s => s.published).length)
const totalQuestions = computed(() => sets.value.reduce((sum, s) => sum + s.count, 0))
const qFormDirty = computed(() => qForm.show && qFormSnapshot.value && serializeQuestionForm() !== qFormSnapshot.value)
const lvNameOptions = computed(() => {
const names = currentSet.value?.lvNames
return (Array.isArray(names) && names.length === 6) ? names : DEFAULT_LV_NAMES
})
async function checkAuth() {
if (!getToken()) return
try {
await api('/admin/sets')
authed.value = true
} catch (e) {
clearToken()
function showNotice(text, type = 'ok') {
notice.text = text
notice.type = type
}
function resetAdminUi() {
view.value = 'list'
currentSetId.value = null
sets.value = []
qlist.value = []
setForm.show = false
importForm.show = false
resetQuestionForm()
notice.text = ''
}
function handleError(e) {
if (e.status === 401) {
authed.value = false
resetAdminUi()
loginMsg.value = '登录已过期,请重新登录'
} else {
showNotice(e.message, 'err')
}
}
async function doLogin() {
if (busy.value) return
loginMsg.value = ''
if (!loginUser.value.trim() || !loginPass.value) { loginMsg.value = '请输入用户名和密码'; return }
busy.value = true
try {
const data = await api('/admin/login', {
const data = await apiPublic('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value })
})
setToken(data.token)
await loadSets()
authed.value = true
loginPass.value = ''
} catch (e) {
loginMsg.value = e.message
} finally {
busy.value = false
}
}
function doLogout() {
if (busy.value || !canDiscardQuestionForm()) return
clearToken()
authed.value = false
view.value = 'list'
resetAdminUi()
}
async function loadSets() {
const data = await api('/admin/sets')
const requestId = ++setsRequestId
let data
try {
data = await api('/admin/sets')
} catch (e) {
if (requestId !== setsRequestId) return false
throw e
}
if (requestId !== setsRequestId) return false
sets.value = data.sets
return true
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
async function refreshAfterMutation(message, includeDetail = false) {
showNotice(message)
try {
const requests = [loadSets()]
if (includeDetail && currentSetId.value != null) requests.push(renderQuestions())
await Promise.all(requests)
} catch (e) {
if (e.status === 401) handleError(e)
else showNotice(`${message},但页面刷新失败:${e.message}`, 'err')
}
}
/* ---------- 套题:新增 / 重命名 / 删除 ---------- */
/* ---------- 套题:新增 / 编辑 / 删除 ---------- */
function showAddSet() {
if (busy.value) return
importForm.show = false
setForm.show = true
setForm.editingId = null
setForm.title = '新增套题'
setForm.name = ''
setForm.description = ''
setForm.lvNames = [...DEFAULT_LV_NAMES]
setForm.msg = ''
}
function showRenameSet(id) {
if (busy.value) return
const s = sets.value.find(x => x.id === id)
if (!s) return
setForm.show = true
setForm.editingId = id
setForm.title = '重命名套题'
setForm.title = '编辑套题信息'
setForm.name = s.name
setForm.description = s.description || ''
setForm.lvNames = s.lvNames ? [...s.lvNames] : [...DEFAULT_LV_NAMES]
setForm.msg = ''
}
async function saveSet() {
if (busy.value) return
setForm.msg = ''
if (!setForm.name.trim()) { setForm.msg = '请输入套题名称'; return }
const body = {
name: setForm.name.trim(),
description: setForm.description.trim(),
lvNames: setForm.lvNames.map(n => n.trim())
}
if (body.lvNames.some(n => !n)) { setForm.msg = '6 个难度名称都不能为空'; return }
busy.value = true
try {
const editing = setForm.editingId !== null
if (setForm.editingId === null) {
await api('/admin/sets', { method: 'POST', body: JSON.stringify(body) })
} else {
await api('/admin/sets/' + setForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
}
setForm.show = false
loadSets()
await refreshAfterMutation(editing ? '套题信息已更新' : '套题已创建为草稿,请添加题目后发布')
} catch (e) {
setForm.msg = e.message
if (e.status === 401) handleError(e)
else setForm.msg = e.message
} finally {
busy.value = false
}
}
async function deleteSet(id) {
if (busy.value) return
const s = sets.value.find(x => x.id === id)
if (!confirm(`确定删除套题「${s.name}」吗?其下 ${s.questions.length} 道题将一并删除,不可恢复。`)) return
if (!s || !confirm(`确定删除套题「${s.name}」吗?其下 ${s.count} 道题将一并删除,不可恢复。`)) return
busy.value = true
try {
await api('/admin/sets/' + id, { method: 'DELETE' })
if (currentSetId.value === id) backToList()
loadSets()
await refreshAfterMutation(`已删除套题「${s.name}`)
} catch (e) {
alert(e.message)
handleError(e)
} finally {
busy.value = false
}
}
async function togglePublish(set) {
if (busy.value || !set) return
const published = !set.published
if (!published && !confirm(`下架「${set.name}」后,答题端将暂时看不到它。确定继续吗?`)) return
busy.value = true
try {
await api('/admin/sets/' + set.id + '/publish', {
method: 'PATCH',
body: JSON.stringify({ published })
})
await refreshAfterMutation(published ? `${set.name}」已发布` : `${set.name}」已转为草稿`)
} catch (e) {
handleError(e)
} finally {
busy.value = false
}
}
async function duplicateSet(set) {
if (busy.value || !set) return
busy.value = true
try {
const data = await api('/admin/sets/' + set.id + '/duplicate', { method: 'POST' })
await refreshAfterMutation(`已复制为「${data.set.name}」,副本默认为草稿`)
} catch (e) {
handleError(e)
} finally {
busy.value = false
}
}
/* ---------- 套题:导出 / 导入 ---------- */
async function exportSet(id) {
if (busy.value) return
busy.value = true
try {
const data = await api('/admin/sets/' + id + '/export')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
@@ -138,12 +234,17 @@ async function exportSet(id) {
a.download = data.name + '.json'
a.click()
URL.revokeObjectURL(a.href)
showNotice('套题已导出')
} catch (e) {
alert(e.message)
handleError(e)
} finally {
busy.value = false
}
}
function showImport() {
if (busy.value) return
setForm.show = false
importForm.show = true
importForm.text = ''
importForm.name = ''
@@ -170,6 +271,7 @@ function onImportFile(e) {
}
async function doImport() {
if (busy.value) return
importForm.msg = ''
const raw = importForm.text.trim()
if (!raw) { importForm.msg = '请粘贴 JSON 或选择文件'; return }
@@ -180,46 +282,96 @@ async function doImport() {
importForm.msg = 'JSON 解析失败:' + e.message
return
}
let name, questions
let name, description = '', lvNames, questions
if (Array.isArray(parsed)) {
questions = parsed
name = importForm.name.trim()
} else if (parsed && Array.isArray(parsed.questions)) {
questions = parsed.questions
name = importForm.name.trim() || parsed.name || ''
description = typeof parsed.description === 'string' ? parsed.description : ''
lvNames = parsed.lvNames
} else {
importForm.msg = '格式不支持:需要题目数组,或 {"name":..., "questions":[...]}'
return
}
if (!name) { importForm.msg = '请填写套题名称'; return }
const body = { name, description, questions }
body.questions = questions.map(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item
return {
...item,
q: typeof item.q === 'string' ? escapeSmartHtml(decodeEntities(item.q)) : item.q,
exp: typeof item.exp === 'string' ? escapeSmartHtml(decodeEntities(item.exp)) : item.exp
}
})
if (lvNames !== undefined && lvNames !== null) body.lvNames = lvNames
busy.value = true
try {
const res = await api('/admin/import', { method: 'POST', body: JSON.stringify({ name, questions }) })
const res = await api('/admin/import', { method: 'POST', body: JSON.stringify(body) })
importForm.show = false
loadSets()
alert('导入成功:共 ' + res.imported + ' 道题')
await refreshAfterMutation(`导入成功:共 ${res.imported} 道题${res.set.published ? ',已发布' : ',已保存为草稿'}`)
} catch (e) {
importForm.msg = e.message
if (e.status === 401) handleError(e)
else importForm.msg = e.message
} finally {
busy.value = false
}
}
/* ---------- 套题详情 ---------- */
async function openSet(id) {
if (busy.value) return
resetQuestionForm()
currentSetId.value = id
view.value = 'detail'
await renderQuestions()
busy.value = true
try {
await renderQuestions()
} catch (e) {
handleError(e)
backToList(false)
} finally {
busy.value = false
}
}
function backToList() {
function resetQuestionForm() {
qForm.show = false
qForm.editingId = null
qForm.msg = ''
qFormSnapshot.value = ''
}
function serializeQuestionForm() {
return JSON.stringify({ lv: qForm.lv, q: qForm.q, exp: qForm.exp, opts: qForm.opts.map(o => o.val), ans: qForm.ans })
}
function canDiscardQuestionForm() {
return !qFormDirty.value || confirm('当前题目有未保存的修改,确定放弃吗?')
}
function closeQuestionForm() {
if (canDiscardQuestionForm()) resetQuestionForm()
}
function backToList(refresh = true) {
if (!canDiscardQuestionForm()) return false
resetQuestionForm()
view.value = 'list'
currentSetId.value = null
loadSets()
qlist.value = []
if (refresh) loadSets().catch(handleError)
return true
}
async function renderQuestions() {
if (currentSetId.value == null) return
const data = await api('/admin/sets')
const set = data.sets.find(s => s.id === currentSetId.value)
qlist.value = set ? set.questions : []
const data = await api('/admin/sets/' + currentSetId.value)
qlist.value = data.set.questions
const index = sets.value.findIndex(s => s.id === data.set.id)
const { questions, ...summary } = data.set
if (index !== -1) sets.value[index] = { ...sets.value[index], ...summary }
}
/* ---------- 题目 ---------- */
@@ -229,6 +381,7 @@ function resetOpts(opts) {
}
function showQuestionForm() {
if (!canDiscardQuestionForm()) return
qForm.show = true
qForm.editingId = null
qForm.title = '新增题目'
@@ -238,6 +391,7 @@ function showQuestionForm() {
qForm.msg = ''
resetOpts(['', '', '', ''])
syncAns()
qFormSnapshot.value = serializeQuestionForm()
scrollFormIntoView()
}
@@ -247,9 +401,14 @@ function addOpt() {
}
function removeOpt(i) {
if (qForm.opts.length <= 2) return
const answer = Number(qForm.ans)
qForm.opts.splice(i, 1)
qForm.opts.forEach((o, j) => { o.key = keyOf(j) })
syncAns()
if (Number.isInteger(answer)) {
if (answer === i) qForm.ans = ''
else if (answer > i) qForm.ans = String(answer - 1)
}
}
function syncAns() {
@@ -258,6 +417,7 @@ function syncAns() {
}
function editQuestion(id) {
if (!canDiscardQuestionForm()) return
const item = qlist.value.find(x => x.id === id)
if (!item) return
qForm.show = true
@@ -269,6 +429,7 @@ function editQuestion(id) {
qForm.msg = ''
resetOpts(item.opts)
qForm.ans = String(item.ans)
qFormSnapshot.value = serializeQuestionForm()
scrollFormIntoView()
}
@@ -298,6 +459,7 @@ function insertCode(target) {
}
async function saveQuestion() {
if (busy.value) return
qForm.msg = ''
const opts = qForm.opts.map(o => o.val.trim())
const body = {
@@ -309,38 +471,62 @@ async function saveQuestion() {
}
if (!body.q) { qForm.msg = '请填写题干'; return }
if (opts.length < 2 || opts.some(o => !o)) { qForm.msg = '请填写所有选项(至少 2 个且不能为空)'; return }
if (new Set(opts).size !== opts.length) { qForm.msg = '选项不能重复'; return }
if (!Number.isInteger(body.ans) || body.ans < 0 || body.ans >= opts.length) { qForm.msg = '请选择正确答案'; return }
if (!body.exp) { qForm.msg = '请填写答案解析'; return }
busy.value = true
try {
const editing = qForm.editingId !== null
if (qForm.editingId === null) {
await api('/admin/sets/' + currentSetId.value + '/questions', { method: 'POST', body: JSON.stringify(body) })
} else {
await api('/admin/questions/' + qForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
}
qForm.show = false
renderQuestions()
await refreshAfterMutation(editing ? '题目已更新' : '题目已添加', true)
} catch (e) {
qForm.msg = e.message
if (e.status === 401) handleError(e)
else qForm.msg = e.message
} finally {
busy.value = false
}
}
async function deleteQuestion(id) {
if (busy.value) return
if (!confirm('确定删除题目 #' + id + ' 吗?此操作不可恢复。')) return
busy.value = true
try {
await api('/admin/questions/' + id, { method: 'DELETE' })
renderQuestions()
if (qForm.editingId === id) resetQuestionForm()
await refreshAfterMutation('题目已删除', true)
} catch (e) {
alert(e.message)
handleError(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
await checkAuth()
if (authed.value) loadSets()
if (!getToken()) return
busy.value = true
try {
await loadSets()
authed.value = true
} catch (e) {
loginMsg.value = e.status === 401 ? '登录已过期,请重新登录' : `管理数据加载失败:${e.message}`
} finally {
busy.value = false
booting.value = false
}
})
</script>
<template>
<div v-if="booting" class="empty-state" role="status" aria-live="polite">正在验证登录状态</div>
<!-- 登录 -->
<div v-if="!authed" class="card login-card">
<div v-else-if="!authed" class="card login-card">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
<div class="brand-seal small"></div>
<div>
@@ -349,58 +535,81 @@ onMounted(async () => {
</div>
</div>
<div class="field" style="margin-top:20px;">
<label>用户名</label>
<input class="input" v-model="loginUser" placeholder="admin" @keydown.enter="doLogin">
<label for="admin-username">用户名</label>
<input id="admin-username" class="input" v-model="loginUser" autocomplete="username" placeholder="admin" @keydown.enter="doLogin">
</div>
<div class="field">
<label>密码</label>
<input class="input" type="password" v-model="loginPass" placeholder="请输入密码" @keydown.enter="doLogin">
<label for="admin-password">密码</label>
<input id="admin-password" class="input" type="password" v-model="loginPass" autocomplete="current-password" placeholder="请输入密码" @keydown.enter="doLogin">
</div>
<div class="msg err">{{ loginMsg }}</div>
<button class="btn btn-filled" style="width:100%;justify-content:center;margin-top:10px;" @click="doLogin"> </button>
<button class="btn btn-filled" style="width:100%;justify-content:center;margin-top:10px;" :disabled="busy" @click="doLogin">
{{ busy ? '登录中' : ' ' }}
</button>
</div>
<!-- 管理 -->
<div v-else>
<div v-else :inert="busy || undefined" :aria-busy="busy">
<div class="toolbar">
<div style="display:flex;align-items:center;gap:10px;">
<template v-if="view === 'detail'">
<button class="btn btn-outline btn-sm" @click="backToList"> 返回</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="backToList"> 返回</button>
<span style="font-family:var(--serif);font-weight:700;font-size:17px;">{{ currentSet?.name }}</span>
<span style="font-size:12px;color:var(--ink-faint);">{{ qlist.length }} </span>
<span class="status-tag" :class="currentSet?.published ? 'published' : 'draft'">
{{ currentSet?.published ? '已发布' : '草稿' }}
</span>
</template>
<span v-else style="font-family:var(--serif);font-weight:900;font-size:19px;letter-spacing:2px;">套题集</span>
</div>
<div style="display:flex;gap:8px;">
<template v-if="view === 'list'">
<button class="btn btn-tonal btn-sm" @click="showAddSet"> 新增套题</button>
<button class="btn btn-outline btn-sm" @click="showImport"> 导入套题</button>
<button class="btn btn-tonal btn-sm" :disabled="busy" @click="showAddSet"> 新增套题</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="showImport"> 导入套题</button>
</template>
<template v-else>
<button class="btn btn-tonal btn-sm" @click="showQuestionForm"> 新增题目</button>
<router-link v-if="currentSet?.published" class="btn btn-outline btn-sm preview-link" :to="{ path: '/quiz', query: { set: currentSetId } }">预览答题页</router-link>
<button v-else class="btn btn-outline btn-sm" disabled title="发布后可预览">预览答题页</button>
<button class="btn btn-tonal btn-sm" :disabled="busy" @click="showQuestionForm"> 新增题目</button>
</template>
<button class="btn btn-outline btn-sm" @click="doLogout">退出</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="doLogout">退出</button>
</div>
</div>
<div v-if="notice.text" class="notice" :class="notice.type" aria-live="polite">
<span>{{ notice.text }}</span>
<button type="button" aria-label="关闭提示" @click="notice.text = ''">×</button>
</div>
<div v-if="view === 'list'" class="admin-summary" aria-label="套题概况">
<div><b>{{ sets.length }}</b><span>套题总数</span></div>
<div><b>{{ publishedCount }}</b><span>已发布</span></div>
<div><b>{{ totalQuestions }}</b><span>题目总数</span></div>
</div>
<!-- 套题表单新增/重命名 -->
<div v-if="setForm.show" class="card" style="padding:22px 24px;margin-bottom:20px;">
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ setForm.title }}</div>
<div class="field" style="margin-top:12px;">
<label>套题名称</label>
<input class="input" v-model="setForm.name" placeholder="例如:Dart 语法测验" @keydown.enter="saveSet">
<label for="set-name">套题名称</label>
<input id="set-name" class="input" v-model="setForm.name" maxlength="80" placeholder="例如:Dart 语法测验" @keydown.enter="saveSet">
</div>
<div class="field">
<label for="set-description">套题简介可选答题端会展示</label>
<textarea id="set-description" class="textarea" v-model="setForm.description" maxlength="240" placeholder="说明知识范围、适合人群或完成目标"></textarea>
<div class="char-count">{{ setForm.description.length }} / 240</div>
</div>
<div class="field">
<label>6 个难度等级名称答题页徽章与题目表单使用</label>
<div v-for="(n, i) in setForm.lvNames" :key="i" class="lv-row">
<span class="lv-row-key">Lv{{ i + 1 }}</span>
<input class="input" v-model="setForm.lvNames[i]" :placeholder="'第 ' + (i + 1) + ' 级名称'">
<input class="input" v-model="setForm.lvNames[i]" :aria-label="`第 ${i + 1} 级名称`" :placeholder="'第 ' + (i + 1) + ' 级名称'">
</div>
</div>
<div class="msg err">{{ setForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="saveSet">保存</button>
<button class="btn btn-outline" @click="setForm.show = false">取消</button>
<button class="btn btn-primary" :disabled="busy" @click="saveSet">{{ busy ? '保存中' : '保存' }}</button>
<button class="btn btn-outline" :disabled="busy" @click="setForm.show = false">取消</button>
</div>
</div>
@@ -410,7 +619,7 @@ onMounted(async () => {
<div class="import-zone">
<div>
<input ref="importFileRef" type="file" accept=".json,application/json" style="display:none;" @change="onImportFile">
<button class="btn btn-outline btn-sm" @click="importFileRef?.click()">📁 选择 JSON 文件</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="importFileRef?.click()">📁 选择 JSON 文件</button>
<span style="font-size:12px;color:var(--ink-faint);">或直接粘贴下方 JSON</span>
</div>
<div class="hint">
@@ -418,16 +627,16 @@ onMounted(async () => {
{"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}<br>
仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]名称在下方填写
</div>
<textarea class="textarea" style="margin-top:10px;min-height:110px;" v-model="importForm.text" placeholder="粘贴 JSON 内容,或选择文件后自动填入"></textarea>
<textarea class="textarea" style="margin-top:10px;min-height:110px;" v-model="importForm.text" aria-label="套题 JSON 内容" placeholder="粘贴 JSON 内容,或选择文件后自动填入"></textarea>
<div class="field" style="margin-top:10px;">
<label>套题名称格式②或覆盖默认名时填写</label>
<input class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
<label for="import-set-name">套题名称格式②或覆盖默认名时填写</label>
<input id="import-set-name" class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
</div>
</div>
<div class="msg err">{{ importForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="doImport">开始导入</button>
<button class="btn btn-outline" @click="importForm.show = false">取消</button>
<button class="btn btn-primary" :disabled="busy" @click="doImport">{{ busy ? '导入中' : '开始导入' }}</button>
<button class="btn btn-outline" :disabled="busy" @click="importForm.show = false">取消</button>
</div>
</div>
@@ -435,16 +644,23 @@ onMounted(async () => {
<div v-if="view === 'list'" class="card">
<div v-if="!sets.length" class="empty-state">暂无套题点击新增套题创建第一套</div>
<div v-for="s in sets" :key="s.id" class="set-item">
<div class="set-mark">{{ s.questions.length }}</div>
<div class="set-mark">{{ s.count }}</div>
<div style="flex:1;min-width:0;">
<div class="set-name">{{ s.name }}</div>
<div class="set-sub"> {{ s.questions.length }} </div>
<div class="set-title-row">
<div class="set-name">{{ s.name }}</div>
<span class="status-tag" :class="s.published ? 'published' : 'draft'">{{ s.published ? '已发布' : '草稿' }}</span>
</div>
<div class="set-sub"> {{ s.count }} <span v-if="s.description"> · {{ s.description }}</span></div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;">
<button class="btn btn-tonal btn-sm" @click="openSet(s.id)">编辑题目</button>
<button class="btn btn-outline btn-sm" @click="showRenameSet(s.id)">重命名</button>
<button class="btn btn-outline btn-sm" @click="exportSet(s.id)">导出</button>
<button class="btn btn-danger btn-sm" @click="deleteSet(s.id)">删除</button>
<button class="btn btn-tonal btn-sm" :disabled="busy" @click="openSet(s.id)">编辑题目</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="showRenameSet(s.id)">编辑信息</button>
<button class="btn btn-outline btn-sm" :disabled="busy || (!s.published && !s.count)" :title="!s.count ? '请先添加题目' : ''" @click="togglePublish(s)">
{{ s.published ? '下架' : '发布' }}
</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="duplicateSet(s)">复制</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="exportSet(s.id)">导出</button>
<button class="btn btn-danger btn-sm" :disabled="busy" @click="deleteSet(s.id)">删除</button>
</div>
</div>
</div>
@@ -462,8 +678,8 @@ onMounted(async () => {
<td><span class="seal" :class="'seal-lv' + (q.lv || 1)">Lv{{ q.lv || 1 }}</span></td>
<td style="color:var(--ink-secondary);line-height:1.6;">{{ stripHtmlAndDecode(q.q).slice(0, 60) }}</td>
<td style="white-space:nowrap;">
<button class="btn btn-outline btn-sm" @click="editQuestion(q.id)">编辑</button>
<button class="btn btn-danger btn-sm" @click="deleteQuestion(q.id)">删除</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="editQuestion(q.id)">编辑</button>
<button class="btn btn-danger btn-sm" :disabled="busy" @click="deleteQuestion(q.id)">删除</button>
</td>
</tr>
</tbody>
@@ -475,17 +691,17 @@ onMounted(async () => {
<div v-if="qForm.show" class="qform" ref="qFormRef">
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ qForm.title }}</div>
<div class="field">
<label>难度</label>
<select class="select" v-model="qForm.lv">
<label for="question-level">难度</label>
<select id="question-level" class="select" v-model="qForm.lv">
<option v-for="(n, i) in lvNameOptions" :key="i" :value="String(i + 1)">{{ i + 1 }} - {{ n }}</option>
</select>
</div>
<div class="field">
<label>题干支持 HTML代码里的 &lt; &gt; <b>自动转义</b>不用手写 &amp;lt;</label>
<label for="question-text">题干支持 HTML代码里的 &lt; &gt; <b>自动转义</b>不用手写 &amp;lt;</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn btn-outline btn-sm" @click="insertCode('q')">&lt;code&gt; 插入代码块</button>
</div>
<textarea class="textarea" ref="qRef" v-model="qForm.q" placeholder="例如:Dart 程序的入口函数是?"></textarea>
<textarea id="question-text" class="textarea" ref="qRef" v-model="qForm.q" placeholder="例如:Dart 程序的入口函数是?"></textarea>
<div v-if="qForm.q" class="preview">
<div class="preview-head">实时预览</div>
<div class="preview-body" v-html="qPreview"></div>
@@ -495,23 +711,24 @@ onMounted(async () => {
<label>选项至少 2 可增减</label>
<div v-for="(o, i) in qForm.opts" :key="i" class="opt-row">
<span class="opt-row-key">{{ o.key }}</span>
<input class="input" v-model="o.val" :placeholder="'选项 ' + o.key">
<button class="btn btn-danger btn-sm" @click="removeOpt(i)"></button>
<input class="input" v-model="o.val" :aria-label="`选项 ${o.key}`" :placeholder="'选项 ' + o.key">
<button class="btn btn-danger btn-sm" :disabled="busy || qForm.opts.length <= 2" title="至少保留两个选项" @click="removeOpt(i)"></button>
</div>
<button class="btn btn-outline btn-sm" @click="addOpt"> 添加选项</button>
</div>
<div class="field">
<label>正确答案</label>
<select class="select" v-model="qForm.ans">
<label for="question-answer">正确答案</label>
<select id="question-answer" class="select" v-model="qForm.ans">
<option value="" disabled>请选择正确答案</option>
<option v-for="(o, i) in qForm.opts" :key="i" :value="String(i)">{{ o.key }} - {{ stripHtmlAndDecode(o.val) || '' }}</option>
</select>
</div>
<div class="field">
<label>解析答题后展示支持 HTML< & > 自动转义</label>
<label for="question-explanation">解析答题后展示支持 HTML&lt; &amp; &gt; 自动转义</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn btn-outline btn-sm" @click="insertCode('exp')">&lt;code&gt; 插入代码块</button>
</div>
<textarea class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 &lt;b&gt;main()&lt;/b&gt;。"></textarea>
<textarea id="question-explanation" class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 &lt;b&gt;main()&lt;/b&gt;。"></textarea>
<div v-if="qForm.exp" class="preview">
<div class="preview-head">实时预览</div>
<div class="preview-body" v-html="expPreview"></div>
@@ -519,8 +736,8 @@ onMounted(async () => {
</div>
<div class="msg err">{{ qForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="saveQuestion">保存</button>
<button class="btn btn-outline" @click="qForm.show = false">取消</button>
<button class="btn btn-primary" :disabled="busy" @click="saveQuestion">{{ busy ? '保存中' : '保存' }}</button>
<button class="btn btn-outline" :disabled="busy" @click="closeQuestionForm">取消</button>
</div>
</div>
</div>
@@ -541,6 +758,41 @@ onMounted(async () => {
gap: 10px;
}
.notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: 12px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 13px;
font-weight: 700;
}
.notice.ok { color: var(--on-success-container); background: var(--success-container); }
.notice.err { color: var(--on-error-container); background: var(--error-container); }
.notice button { border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 18px; }
.admin-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.admin-summary > div {
display: flex;
align-items: baseline;
gap: 8px;
padding: 13px 16px;
border: 1px solid var(--hairline);
border-radius: 14px;
background: var(--surface-alt);
}
.admin-summary b { color: var(--primary); font-family: var(--serif); font-size: 21px; }
.admin-summary span { color: var(--ink-secondary); font-size: 12px; }
.preview-link { text-decoration: none; }
.char-count { text-align: right; color: var(--ink-faint); font-size: 11px; margin-top: 4px; }
.set-item {
display: flex;
align-items: center;
@@ -561,7 +813,18 @@ onMounted(async () => {
flex-shrink: 0;
}
.set-name { font-weight: 800; font-size: 15px; color: var(--ink); }
.set-sub { font-size: 12px; color: var(--ink-faint); margin-top: 2px; }
.set-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.set-sub { font-size: 12px; color: var(--ink-faint); margin-top: 3px; line-height: 1.5; }
.status-tag {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 800;
white-space: nowrap;
}
.status-tag.published { background: var(--success-container); color: var(--on-success-container); }
.status-tag.draft { background: var(--surface-high); color: var(--ink-secondary); }
.import-zone {
border: 1.5px dashed var(--hairline-strong);
@@ -617,4 +880,10 @@ onMounted(async () => {
.preview-body :deep(code) { background: var(--surface-high); padding: 1px 6px; border-radius: 5px; font-family: 'Consolas', monospace; font-size: .92em; color: var(--primary); }
.preview-body :deep(b) { color: var(--primary); }
.preview-body :deep(pre) { background: var(--surface-high); border-radius: 10px; padding: 12px; overflow-x: auto; }
@media (max-width: 640px) {
.admin-summary { grid-template-columns: 1fr; gap: 7px; }
.set-item { align-items: flex-start; flex-wrap: wrap; }
.set-item > div:last-child { width: 100%; justify-content: flex-start !important; }
}
</style>
+340 -88
View File
@@ -10,6 +10,7 @@ const loading = ref(true)
const error = ref('')
const sets = ref([])
const questions = ref([])
const sourceQuestions = ref([])
const activeSetId = ref(null)
const cur = ref(0)
const score = ref(0)
@@ -17,7 +18,12 @@ const userAnswers = ref([])
const answered = ref([])
const offline = ref(false)
const showResult = ref(false)
const reviewingResult = ref(false)
const resumed = ref(false)
const activeVersion = ref('')
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
let questionRequestId = 0
let ignoredRouteId = null
function shuffleList(arr) {
const a = [...arr]
@@ -30,9 +36,10 @@ function shuffleList(arr) {
/* ---------- 离线缓存 ---------- */
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
const CACHE_VER = 'v1'
const CACHE_VER = 'v2'
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
const progressKey = id => `quiz-progress-v1-set-${id}`
function readCache(key) {
try {
@@ -51,6 +58,14 @@ function writeCache(key, value) {
}
}
function removeCache(key) {
try {
localStorage.removeItem(key)
} catch {
/* 无法访问本地存储时不影响答题 */
}
}
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
const lvNames = ref(DEFAULT_LV_NAMES)
@@ -58,11 +73,14 @@ const keyOf = i => String.fromCharCode(65 + i)
const total = computed(() => questions.value.length)
const current = computed(() => questions.value[cur.value] || null)
const activeSet = computed(() => sets.value.find(s => s.id === activeSetId.value) || null)
const progress = computed(() => total.value ? ((cur.value + 1) / total.value * 100) : 0)
const pct = computed(() => total.value ? Math.round(score.value / total.value * 100) : 0)
const isLast = computed(() => cur.value === total.value - 1)
const answeredHere = computed(() => answered.value[cur.value] || false)
const isRight = computed(() => answeredHere.value && userAnswers.value[cur.value] === current.value.ans)
const answeredCount = computed(() => answered.value.filter(Boolean).length)
const hasProgress = computed(() => answeredCount.value > 0 || cur.value > 0 || showResult.value)
function gradeOf(p) {
if (p >= 90) return { title: '翰林之才', sub: 'Dart 已了然于心,可放心挥毫写 Flutter 了。' }
@@ -79,85 +97,178 @@ function optClass(i) {
return {}
}
// 网络优先、缓存兜底:只要在线就一定拿到最新题目,
// 管理后台改完题不会被旧缓存挡住,因此不需要额外的缓存失效机制。
async function loadSets() {
loading.value = true
error.value = ''
let data = null
try {
data = await apiPublic('/sets')
writeCache(setsCacheKey(), data)
offline.value = false
} catch (e) {
data = readCache(setsCacheKey())
if (!data) {
error.value = '套题列表加载失败,本地也没有缓存,请联网后重试'
loading.value = false
return
}
offline.value = true
}
sets.value = data.sets
if (!sets.value.length) {
error.value = '暂无套题,请联系管理员添加'
loading.value = false
return
}
const want = Number(route.query.set)
const target = sets.value.find(s => s.id === want) || sets.value[0]
await loadQuestions(target.id)
// 网络优先、缓存兜底;4xx 表示套题已下架或参数错误,不使用旧缓存“复活”它。
function canUseCache(e) {
return !e.status || e.status >= 500
}
async function loadQuestions(setId) {
loading.value = true
error.value = ''
const key = setCacheKey(setId)
let data = null
try {
data = await apiPublic('/questions?set=' + setId)
writeCache(key, data)
offline.value = false
} catch (e) {
data = readCache(key)
if (!data) {
error.value = '该套题还没有缓存过,请联网后再打开一次'
loading.value = false
return
}
offline.value = true
}
function persistProgress() {
if (!activeSetId.value || !questions.value.length) return
const answers = {}
questions.value.forEach((q, i) => {
if (answered.value[i]) answers[q.id] = userAnswers.value[i]
})
writeCache(progressKey(activeSetId.value), {
version: activeVersion.value,
questionIds: questions.value.map(q => q.id),
answers,
cur: cur.value,
showResult: showResult.value,
reviewingResult: reviewingResult.value,
completed: showResult.value || reviewingResult.value,
updatedAt: Date.now()
})
}
// 本地判分依赖题目自带 ans。开发时 server 与 vite 是两个进程,
// 改完 server.js 忘了重启就会拿到不带 ans 的老数据,那样每题都会被判错。
if (data.questions.length && data.questions[0].ans === undefined) {
error.value = '服务端版本过旧(未下发答案),请重启 node server.js'
loading.value = false
return
}
questions.value = shuffle.value ? shuffleList(data.questions) : data.questions
activeSetId.value = setId
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
? ['', ...data.set.lvNames]
: DEFAULT_LV_NAMES
function startFresh(questionList) {
questions.value = shuffle.value ? shuffleList(questionList) : [...questionList]
userAnswers.value = new Array(questions.value.length).fill(null)
answered.value = new Array(questions.value.length).fill(false)
cur.value = 0
score.value = 0
showResult.value = false
if (!questions.value.length) error.value = '该套题暂无题目,请联系管理员添加'
reviewingResult.value = false
resumed.value = false
}
function clearRoundState() {
questions.value = []
sourceQuestions.value = []
userAnswers.value = []
answered.value = []
cur.value = 0
score.value = 0
showResult.value = false
reviewingResult.value = false
resumed.value = false
activeVersion.value = ''
}
function restoreProgress(data) {
const saved = readCache(progressKey(data.set.id))
const byId = new Map(data.questions.map(q => [q.id, q]))
const ids = saved?.questionIds
const valid = saved?.version === data.set.version &&
Array.isArray(ids) && ids.length === data.questions.length && ids.every(id => byId.has(id))
if (!valid) {
removeCache(progressKey(data.set.id))
startFresh(data.questions)
return
}
questions.value = ids.map(id => byId.get(id))
userAnswers.value = questions.value.map(q => {
const answer = saved.answers?.[q.id]
return Number.isInteger(answer) && answer >= 0 && answer < q.opts.length ? answer : null
})
answered.value = userAnswers.value.map(answer => answer !== null)
score.value = questions.value.reduce((sum, q, i) => sum + (userAnswers.value[i] === q.ans ? 1 : 0), 0)
cur.value = Math.min(Math.max(Number(saved.cur) || 0, 0), questions.value.length - 1)
const completed = Boolean(saved.completed ?? saved.showResult)
reviewingResult.value = completed && Boolean(saved.reviewingResult)
showResult.value = completed && !reviewingResult.value
resumed.value = answered.value.some(Boolean) || cur.value > 0 || showResult.value
}
async function loadSets() {
const requestId = ++questionRequestId
loading.value = true
error.value = ''
let data = null
try {
data = await apiPublic('/sets')
if (requestId !== questionRequestId) return
writeCache(setsCacheKey(), data)
offline.value = false
} catch (e) {
if (requestId !== questionRequestId) return
data = canUseCache(e) ? readCache(setsCacheKey()) : null
if (!data) {
sets.value = []
activeSetId.value = null
clearRoundState()
error.value = e.message || '套题列表加载失败,请联网后重试'
loading.value = false
return
}
offline.value = true
}
sets.value = Array.isArray(data.sets) ? data.sets : []
if (!sets.value.length) {
activeSetId.value = null
clearRoundState()
error.value = '暂无已发布的套题,请稍后再来'
loading.value = false
return
}
const want = Number(route.query.set)
const target = sets.value.find(s => s.id === want) || sets.value[0]
if (want !== target.id) {
ignoredRouteId = target.id
await router.replace({ path: '/quiz', query: { set: target.id } })
}
await loadQuestions(target.id)
}
async function loadQuestions(setId) {
const requestId = ++questionRequestId
loading.value = true
error.value = ''
activeSetId.value = setId
const key = setCacheKey(setId)
let data = null
try {
data = await apiPublic('/questions?set=' + encodeURIComponent(setId))
if (requestId !== questionRequestId) return
writeCache(key, data)
offline.value = false
} catch (e) {
if (requestId !== questionRequestId) return
data = canUseCache(e) ? readCache(key) : null
if (!data) {
clearRoundState()
error.value = e.message || '该套题加载失败,请稍后重试'
loading.value = false
return
}
offline.value = true
}
if (!Array.isArray(data.questions) || !data.set) {
clearRoundState()
error.value = '套题数据格式不正确,请联系管理员'
loading.value = false
return
}
if (data.questions.length && data.questions.some(q => q.ans === undefined)) {
clearRoundState()
error.value = '服务端版本过旧(未下发答案),请重启 node server.js'
loading.value = false
return
}
activeSetId.value = data.set.id
activeVersion.value = data.set.version || ''
sourceQuestions.value = [...data.questions]
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
? ['', ...data.set.lvNames]
: DEFAULT_LV_NAMES
restoreProgress(data)
if (!questions.value.length) error.value = '该套题暂无题目,请选择其他套题'
loading.value = false
}
function pickSet(id) {
if (id === activeSetId.value) {
loadQuestions(id)
return
}
if (id === activeSetId.value) return
router.replace({ path: '/quiz', query: { set: id } })
}
function retryCurrent() {
if (activeSetId.value && sets.value.some(s => s.id === activeSetId.value)) loadQuestions(activeSetId.value)
else loadSets()
}
// 本地同步判分:赋值与判分落在同一个渲染周期,不存在 ans 未知的中间态,
// 因此没有"先红后绿"的闪烁,断网也照样能答。
function choose(i) {
@@ -165,24 +276,48 @@ function choose(i) {
userAnswers.value[cur.value] = i
answered.value[cur.value] = true
if (i === current.value.ans) score.value++
persistProgress()
}
function next() {
if (isLast.value) showResult.value = true
if (!answeredHere.value) return
if (isLast.value) {
showResult.value = true
reviewingResult.value = false
}
else if (cur.value < total.value - 1) cur.value++
persistProgress()
}
function prev() {
if (cur.value > 0) cur.value--
persistProgress()
}
function restart() {
if (shuffle.value) questions.value = shuffleList(questions.value)
function restart(ask = false) {
if (ask && hasProgress.value && !confirm('确定清除当前进度并重新开始吗?')) return
questions.value = shuffle.value ? shuffleList(sourceQuestions.value) : [...sourceQuestions.value]
userAnswers.value = new Array(questions.value.length).fill(null)
answered.value = new Array(questions.value.length).fill(false)
cur.value = 0
score.value = 0
showResult.value = false
reviewingResult.value = false
resumed.value = false
persistProgress()
}
function reviewQuestion(i) {
cur.value = i
showResult.value = false
reviewingResult.value = true
persistProgress()
}
function backToResult() {
showResult.value = true
reviewingResult.value = false
persistProgress()
}
function toggleShuffle() {
@@ -191,8 +326,17 @@ function toggleShuffle() {
watch(() => route.query.set, v => {
const id = Number(v)
if (id === ignoredRouteId) {
ignoredRouteId = null
return
}
if (id && id !== activeSetId.value && sets.value.some(s => s.id === id)) {
loadQuestions(id)
} else if (sets.value.length && (!id || !sets.value.some(s => s.id === id))) {
const fallback = sets.value[0].id
ignoredRouteId = fallback
router.replace({ path: '/quiz', query: { set: fallback } })
if (fallback !== activeSetId.value) loadQuestions(fallback)
}
})
@@ -201,38 +345,64 @@ onMounted(loadSets)
<template>
<div>
<div v-if="loading" class="empty-state">墨已研好正在取题</div>
<div v-else-if="error" class="card empty-state">{{ error }}</div>
<div v-if="loading && !sets.length" class="empty-state" role="status" aria-live="polite">墨已研好正在取题</div>
<template v-else>
<div class="card" style="padding:18px 24px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:12.5px;color:var(--ink-secondary);font-weight:700;letter-spacing:1px;">选一套题</span>
<div v-if="sets.length" class="card set-picker">
<div class="set-picker-row">
<span class="set-picker-label">选一套题</span>
<button
v-for="s in sets" :key="s.id"
class="chip" :class="{ active: s.id === activeSetId }"
:disabled="loading && s.id === activeSetId"
@click="pickSet(s.id)">
{{ s.name }}<span class="chip-count">{{ s.count }}</span>
</button>
</div>
<div v-if="activeSet" class="set-meta">
<div>
<b>{{ activeSet.name }}</b>
<span>{{ activeSet.description || `${activeSet.count} 道题,覆盖 6 个难度阶段` }}</span>
</div>
<div class="set-tools">
<span v-if="hasProgress && !loading" class="saved-tag">已保存 {{ answeredCount }}/{{ total }} </span>
<button v-if="hasProgress && !loading" class="text-action" @click="restart(true)">重新开始</button>
</div>
</div>
<div class="set-options">
<label class="shuffle-toggle" title="开启后,每次进入或重开套题都会打乱题目顺序">
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
<span>打乱题目顺</span>
<span>重新开始时打乱题序</span>
</label>
<span v-if="offline" class="offline-tag" title="当前使用本地缓存的题目,联网后会自动取最新">离线 · 使用本地缓存</span>
<span v-else-if="resumed" class="resumed-tag">已恢复上次进度</span>
</div>
</div>
<div v-if="loading" class="card loading-card" role="status" aria-live="polite">正在加载{{ activeSet?.name || '套题' }}</div>
<div v-else-if="error" class="card error-card" role="alert">
<div class="error-mark">!</div>
<div>
<h2>暂时无法打开套题</h2>
<p>{{ error }}</p>
<button class="btn btn-filled btn-sm" @click="retryCurrent">重新加载</button>
</div>
</div>
<transition name="slide" mode="out-in">
<div v-if="!showResult" :key="cur" class="card quiz-card">
<div v-if="!loading && !error && current && !showResult" :key="cur" class="card quiz-card">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px;">
<div class="q-num"> {{ cur + 1 }} · {{ total }} </div>
<div class="score-pill">{{ score }} </div>
<div class="score-pill">答对 {{ score }} </div>
</div>
<div class="bar" role="progressbar" :aria-valuenow="cur + 1" aria-valuemin="1" :aria-valuemax="total" :aria-label="`答题进度 ${cur + 1} ${total} `">
<div class="bar-fill" :style="{ width: progress + '%' }"></div>
</div>
<div class="bar"><div class="bar-fill" :style="{ width: progress + '%' }"></div></div>
<div class="q-head">
<span class="seal" :class="'seal-lv' + (current.lv || 1)">{{ lvNames[current.lv] || '基础' }}</span> </div>
<span class="seal" :class="'seal-lv' + (current.lv || 1)">{{ lvNames[current.lv] || '基础' }}</span>
</div>
<h2 class="q-title" v-html="current.q"></h2>
@@ -261,15 +431,18 @@ onMounted(loadSets)
<div class="btns">
<button class="btn btn-outline" :disabled="cur === 0" @click="prev">上一题</button>
<button class="btn btn-filled" @click="next">
<button v-if="reviewingResult" class="btn btn-tonal" @click="backToResult">返回成绩</button>
<button class="btn btn-filled" :disabled="!answeredHere" :title="answeredHere ? '' : '请先选择一个答案'" @click="next">
{{ isLast ? '看成绩' : '下一题' }}
</button>
</div>
<div v-if="!answeredHere" class="answer-hint">选择答案后即可继续进度会自动保存</div>
</div>
</transition>
<transition name="pop">
<div v-if="showResult" class="card result-card">
<div v-if="!loading && !error && showResult" class="card result-card">
<div class="result-set-name">{{ activeSet?.name }}</div>
<div class="circle" :style="{ '--p': pct }">
<div class="circle-inner">
<div class="score-num">{{ score }}</div>
@@ -280,19 +453,19 @@ onMounted(loadSets)
<div class="grade-sub">{{ gradeOf(pct).sub }}</div>
<div class="review">
<div class="review-head">答题回顾</div>
<div v-for="(q, i) in questions" :key="q.id" class="review-item">
<div class="review-head">答题回顾 · 点击题目查看解析</div>
<button v-for="(q, i) in questions" :key="q.id" class="review-item" @click="reviewQuestion(i)">
<b style="color:var(--ink-faint);margin-right:6px;">{{ i + 1 }}.</b>
<span class="review-text">{{ stripHtmlAndDecode(q.q).slice(0, 40) }}</span>
<span v-if="userAnswers[i] === q.ans" class="review-verdict ok"> </span>
<span v-else class="review-verdict no">
{{ userAnswers[i] == null ? '未答' : keyOf(userAnswers[i]) }}{{ keyOf(q.ans) }}
{{ userAnswers[i] == null ? '未答' : keyOf(userAnswers[i]) }}{{ keyOf(q.ans) }}
</span>
</div>
</button>
</div>
<div style="text-align:center;margin-top:20px;">
<button class="btn btn-filled" @click="restart">再答一场</button>
<button class="btn btn-filled" @click="restart(false)">再答一场</button>
</div>
</div>
</transition>
@@ -301,6 +474,36 @@ onMounted(loadSets)
</template>
<style scoped>
.set-picker { padding: 18px 24px; }
.set-picker-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.set-picker-label { font-size: 12.5px; color: var(--ink-secondary); font-weight: 700; letter-spacing: 1px; }
.set-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--hairline);
}
.set-meta > div:first-child { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.set-meta b { color: var(--ink); font-size: 14px; }
.set-meta span { color: var(--ink-faint); font-size: 12px; line-height: 1.5; }
.set-tools { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }
.set-options { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.saved-tag { color: var(--success) !important; font-weight: 700; white-space: nowrap; }
.text-action {
border: 0;
padding: 2px;
background: transparent;
color: var(--primary);
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.text-action:hover { text-decoration: underline; }
.chip {
border: 1px solid var(--hairline);
background: var(--surface-alt);
@@ -318,6 +521,7 @@ onMounted(loadSets)
}
.chip:hover { border-color: var(--ink); color: var(--ink); }
.chip.active { background: var(--ink); border-color: var(--ink); color: var(--surface); }
.chip:disabled { cursor: wait; opacity: .7; }
.chip-count { font-size: 11px; opacity: .75; }
.shuffle-toggle {
@@ -328,7 +532,6 @@ onMounted(loadSets)
color: var(--ink-secondary);
cursor: pointer;
user-select: none;
margin-left: 6px;
}
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
@@ -346,6 +549,32 @@ onMounted(loadSets)
white-space: nowrap;
}
.resumed-tag {
color: var(--on-success-container);
background: var(--success-container);
border-radius: 999px;
padding: 4px 11px;
font-size: 11.5px;
font-weight: 700;
}
.loading-card { text-align: center; color: var(--ink-secondary); padding: 34px; }
.error-card { display: flex; align-items: flex-start; gap: 16px; }
.error-card h2 { font-family: var(--serif); font-size: 18px; margin-bottom: 5px; }
.error-card p { color: var(--ink-secondary); font-size: 13px; line-height: 1.7; margin-bottom: 14px; }
.error-mark {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
background: var(--error-container);
color: var(--error);
font-weight: 900;
}
.quiz-card { margin-top: 20px; }
.q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; }
.score-pill {
@@ -417,8 +646,10 @@ onMounted(loadSets)
.explain-body { opacity: .9; }
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
.answer-hint { text-align: right; color: var(--ink-faint); font-size: 11.5px; margin-top: 7px; }
.result-card { margin-top: 20px; text-align: center; }
.result-set-name { color: var(--ink-faint); font-size: 12px; font-weight: 700; letter-spacing: 1px; margin-bottom: 10px; }
.circle {
width: 150px; height: 150px;
border-radius: 50%;
@@ -439,9 +670,30 @@ onMounted(loadSets)
.review { text-align: left; border-top: 1px solid var(--hairline); padding-top: 14px; max-height: 300px; overflow-y: auto; }
.review-head { font-weight: 800; color: var(--primary); font-size: 13px; margin-bottom: 8px; letter-spacing: 1px; }
.review-item { font-size: 13px; padding: 9px 4px; border-bottom: 1px dashed var(--hairline); display: flex; align-items: center; gap: 6px; }
.review-item {
width: 100%;
border: 0;
border-bottom: 1px dashed var(--hairline);
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
font-size: 13px;
padding: 9px 4px;
display: flex;
align-items: center;
gap: 6px;
}
.review-item:hover { background: var(--surface-alt); }
.review-text { color: var(--ink-secondary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-verdict { font-weight: 800; font-size: 12px; flex-shrink: 0; }
.review-verdict.ok { color: var(--success); }
.review-verdict.no { color: var(--error); }
@media (max-width: 640px) {
.set-meta { align-items: flex-start; flex-direction: column; }
.set-tools { width: 100%; justify-content: space-between; }
.btns { flex-wrap: wrap; }
}
</style>