+
+
+ {{ notice.text }}
+ ×
+
+
+
+
{{ sets.length }} 套题总数
+
{{ publishedCount }} 已发布
+
{{ totalQuestions }} 题目总数
+
+
{{ setForm.title }}
- 套题名称
-
+ 套题名称
+
+
+
+
套题简介(可选,答题端会展示)
+
+
{{ setForm.description.length }} / 240
{{ setForm.msg }}
- 保存
- 取消
+ {{ busy ? '保存中…' : '保存' }}
+ 取消
@@ -410,7 +619,7 @@ onMounted(async () => {
- 📁 选择 JSON 文件
+ 📁 选择 JSON 文件
(或直接粘贴下方 JSON)
@@ -418,16 +627,16 @@ onMounted(async () => {
① {"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}
② 仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}](名称在下方填写)
-
+
- 套题名称(格式②或覆盖默认名时填写)
-
+ 套题名称(格式②或覆盖默认名时填写)
+
{{ importForm.msg }}
- 开始导入
- 取消
+ {{ busy ? '导入中…' : '开始导入' }}
+ 取消
@@ -435,16 +644,23 @@ onMounted(async () => {
暂无套题,点击「新增套题」创建第一套
-
{{ s.questions.length }}
+
{{ s.count }}
-
{{ s.name }}
-
共 {{ s.questions.length }} 题
+
+
{{ s.name }}
+
{{ s.published ? '已发布' : '草稿' }}
+
+
共 {{ s.count }} 题 · {{ s.description }}
- 编辑题目
- 重命名
- 导出
- 删除
+ 编辑题目
+ 编辑信息
+
+ {{ s.published ? '下架' : '发布' }}
+
+ 复制
+ 导出
+ 删除
@@ -462,8 +678,8 @@ onMounted(async () => {
Lv{{ q.lv || 1 }}
{{ stripHtmlAndDecode(q.q).slice(0, 60) }}
- 编辑
- 删除
+ 编辑
+ 删除
@@ -475,17 +691,17 @@ onMounted(async () => {
{{ qForm.title }}
- 难度
-
+ 难度
+
{{ i + 1 }} - {{ n }}
-
题干(支持 HTML;代码里的 < > 会自动转义 ,不用手写 <)
+
题干(支持 HTML;代码里的 < > 会自动转义 ,不用手写 <)
<code> 插入代码块
-
+
- 正确答案
-
+ 正确答案
+
+ 请选择正确答案
{{ o.key }} - {{ stripHtmlAndDecode(o.val) || '(空)' }}
-
解析(答题后展示,支持 HTML;< & > 自动转义)
+
解析(答题后展示,支持 HTML;< & > 自动转义)
<code> 插入代码块
-
+
实时预览
@@ -519,8 +736,8 @@ onMounted(async () => {
{{ qForm.msg }}
- 保存
- 取消
+ {{ busy ? '保存中…' : '保存' }}
+ 取消
@@ -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; }
+}
diff --git a/web/src/views/QuizView.vue b/web/src/views/QuizView.vue
index ef2c60b..d341062 100644
--- a/web/src/views/QuizView.vue
+++ b/web/src/views/QuizView.vue
@@ -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)
-
墨已研好,正在取题…
-
-
{{ error }}
+
墨已研好,正在取题…
-
-
-
选一套题
+
+
+ 选一套题
{{ s.name }}{{ s.count }}
+
+
+
- 打乱题目顺序
+ 重新开始时打乱题序
离线 · 使用本地缓存
+ 已恢复上次进度
+
+
+
+
正在加载「{{ activeSet?.name || '套题' }}」…
+
+
+
!
+
+
暂时无法打开套题
+
{{ error }}
+
重新加载
-
+
第 {{ cur + 1 }} 题 · 共 {{ total }} 题
-
{{ score }} 分
+
答对 {{ score }} 题
+
+
-
- {{ lvNames[current.lv] || '基础' }}
+
{{ lvNames[current.lv] || '基础' }}
+
@@ -261,15 +431,18 @@ onMounted(loadSets)
上一题
-
+ 返回成绩
+
{{ isLast ? '看成绩' : '下一题' }}
+
选择答案后即可继续,进度会自动保存
-
+
+
{{ activeSet?.name }}
{{ score }}
@@ -280,19 +453,19 @@ onMounted(loadSets)
{{ gradeOf(pct).sub }}
-
答题回顾
-
+
答题回顾 · 点击题目查看解析
+
{{ i + 1 }}.
{{ stripHtmlAndDecode(q.q).slice(0, 40) }}
✓ 对
- ✗ 错(你:{{ userAnswers[i] == null ? '未答' : keyOf(userAnswers[i]) }},正:{{ keyOf(q.ans) }})
+ ✗ 错(你:{{ userAnswers[i] == null ? '未答' : keyOf(userAnswers[i]) }},正:{{ keyOf(q.ans) }})
-
+
- 再答一场
+ 再答一场
@@ -301,6 +474,36 @@ onMounted(loadSets)