chore: update project

This commit is contained in:
jocay
2026-08-09 22:10:19 +08:00
parent 22b5d75d2d
commit 4ef6b8af05
10 changed files with 998 additions and 107 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
<title>砚 · Dart 测验</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<!-- 非阻塞加载:先用 media="print" 让浏览器不把它当渲染阻塞资源,下载完成后再切回 all。
国内常连不上 fonts.googleapis.com,若按默认方式引入会把首屏一直卡到请求超时;
这样写则首屏立即绘制,先用 theme.css 里的本地回退字体(宋体/苹方/雅黑),字体到了再换上。 -->
<link rel="stylesheet" media="print" onload="this.media='all'"
href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap">
</head>
<body>
<div id="app"></div>
+11 -3
View File
@@ -256,14 +256,22 @@ body {
.table tbody tr:hover { background: var(--surface-alt); }
/* ---------- 过渡动画 ---------- */
/* 每组都必须 enter / leave 成对定义。缺了 leave 时,Vue 会去读元素上
残留的 animation 时长来决定等多久,而那个 animation 早已播完、不会再触发
animationend,于是只能干等满兜底 timeout —— 表现就是切换时“卡一下”。
同理,元素自身不要再挂 animation,入场交给这里的 transition 负责。 */
.fade-enter-active, .fade-leave-active { transition: opacity .25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active { transition: all .3s ease; }
.slide-enter-from { opacity: 0; transform: translateY(14px); }
.slide-enter-active { transition: opacity .2s ease, transform .2s ease; }
.slide-leave-active { transition: opacity .12s ease, transform .12s ease; }
.slide-enter-from { opacity: 0; transform: translateY(12px); }
.slide-leave-to { opacity: 0; transform: translateY(-8px); }
.pop-enter-active { transition: all .35s cubic-bezier(.2, .9, .3, 1.3); }
.pop-enter-active { transition: opacity .28s ease, transform .28s cubic-bezier(.2, .9, .3, 1.3); }
.pop-leave-active { transition: opacity .12s ease, transform .12s ease; }
.pop-enter-from { opacity: 0; transform: scale(.92); }
.pop-leave-to { opacity: 0; transform: scale(.96); }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
+5 -2
View File
@@ -20,6 +20,7 @@ const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面
const setForm = reactive({ show: false, editingId: null, title: '', name: '', 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: ''
@@ -164,6 +165,8 @@ function onImportFile(e) {
importForm.msg = ''
}
reader.readAsText(file, 'utf-8')
// 清空 value:否则再次选择同一个文件时 value 未变、不会触发 change,按钮看起来像失灵
e.target.value = ''
}
async function doImport() {
@@ -406,8 +409,8 @@ onMounted(async () => {
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</div>
<div class="import-zone">
<div>
<input type="file" accept=".json,application/json" style="display:none;" :id="'importFile'" @change="onImportFile">
<button class="btn btn-outline btn-sm" @click="document.getElementById('importFile').click()">📁 选择 JSON 文件</button>
<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>
<span style="font-size:12px;color:var(--ink-faint);">或直接粘贴下方 JSON</span>
</div>
<div class="hint">
+104 -55
View File
@@ -15,7 +15,7 @@ const cur = ref(0)
const score = ref(0)
const userAnswers = ref([])
const answered = ref([])
const checking = ref(false)
const offline = ref(false)
const showResult = ref(false)
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
@@ -28,6 +28,29 @@ function shuffleList(arr) {
return a
}
/* ---------- 离线缓存 ---------- */
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
const CACHE_VER = 'v1'
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
function readCache(key) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
function writeCache(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* 配额满或隐私模式,缓存失败不影响在线答题 */
}
}
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
const lvNames = ref(DEFAULT_LV_NAMES)
@@ -56,48 +79,75 @@ function optClass(i) {
return {}
}
// 网络优先、缓存兜底:只要在线就一定拿到最新题目,
// 管理后台改完题不会被旧缓存挡住,因此不需要额外的缓存失效机制。
async function loadSets() {
loading.value = true
error.value = ''
let data = null
try {
const data = await apiPublic('/sets')
sets.value = data.sets
if (!sets.value.length) {
error.value = '暂无套题,请联系管理员添加'
data = await apiPublic('/sets')
writeCache(setsCacheKey(), data)
offline.value = false
} catch (e) {
data = readCache(setsCacheKey())
if (!data) {
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)
} catch (e) {
error.value = '题目加载失败,请检查网络后刷新'
loading.value = false
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)
}
async function loadQuestions(setId) {
loading.value = true
error.value = ''
const key = setCacheKey(setId)
let data = null
try {
const data = await apiPublic('/questions?set=' + setId)
const list = shuffle.value ? shuffleList(data.questions) : data.questions
questions.value = list
activeSetId.value = setId
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
? ['', ...data.set.lvNames]
: DEFAULT_LV_NAMES
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 = '该套题暂无题目,请联系管理员添加'
data = await apiPublic('/questions?set=' + setId)
writeCache(key, data)
offline.value = false
} catch (e) {
error.value = '题目加载失败,请检查网络后刷新'
} finally {
loading.value = false
data = readCache(key)
if (!data) {
error.value = '该套题还没有缓存过,请联网后再打开一次'
loading.value = false
return
}
offline.value = true
}
// 本地判分依赖题目自带 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
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 = '该套题暂无题目,请联系管理员添加'
loading.value = false
}
function pickSet(id) {
@@ -108,32 +158,16 @@ function pickSet(id) {
router.replace({ path: '/quiz', query: { set: id } })
}
async function choose(i) {
if (answeredHere.value || checking.value) return
checking.value = true
answered.value[cur.value] = true
// 本地同步判分:赋值与判分落在同一个渲染周期,不存在 ans 未知的中间态,
// 因此没有"先红后绿"的闪烁,断网也照样能答。
function choose(i) {
if (answeredHere.value) return
userAnswers.value[cur.value] = i
const q = current.value
try {
const data = await apiPublic('/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: q.id, answer: i })
})
q.ans = data.answer
q.exp = data.exp
if (data.correct) score.value++
} catch (e) {
answered.value[cur.value] = false
userAnswers.value[cur.value] = null
alert('判分失败,请重试:' + e.message)
} finally {
checking.value = false
}
answered.value[cur.value] = true
if (i === current.value.ans) score.value++
}
function next() {
if (checking.value) return
if (isLast.value) showResult.value = true
else if (cur.value < total.value - 1) cur.value++
}
@@ -185,6 +219,7 @@ onMounted(loadSets)
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
<span>打乱题目顺序</span>
</label>
<span v-if="offline" class="offline-tag" title="当前使用本地缓存的题目,联网后会自动取最新">离线 · 使用本地缓存</span>
</div>
</div>
@@ -205,7 +240,7 @@ onMounted(loadSets)
<button
v-for="(o, i) in current.opts" :key="i"
class="opt" :class="optClass(i)"
:disabled="answeredHere || checking"
:disabled="answeredHere"
@click="choose(i)">
<span class="opt-key">{{ keyOf(i) }}</span>
<span class="opt-text">{{ stripHtmlAndDecode(o) }}</span>
@@ -225,15 +260,15 @@ onMounted(loadSets)
</transition>
<div class="btns">
<button class="btn btn-outline" :disabled="cur === 0 || checking" @click="prev">上一题</button>
<button class="btn btn-filled" :disabled="checking" @click="next">
<button class="btn btn-outline" :disabled="cur === 0" @click="prev">上一题</button>
<button class="btn btn-filled" @click="next">
{{ isLast ? '看成绩' : '下一题' }}
</button>
</div>
</div>
</transition>
<transition name="slide">
<transition name="pop">
<div v-if="showResult" class="card result-card">
<div class="circle" :style="{ '--p': pct }">
<div class="circle-inner">
@@ -297,7 +332,21 @@ onMounted(loadSets)
}
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
.quiz-card { margin-top: 20px; animation: fadeUp .4s ease; }
.offline-tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--hairline-strong);
background: var(--surface-high);
color: var(--ink-secondary);
border-radius: 999px;
padding: 4px 11px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .5px;
white-space: nowrap;
}
.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 {
background: var(--primary-container);
@@ -369,7 +418,7 @@ onMounted(loadSets)
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
.result-card { margin-top: 20px; text-align: center; animation: popIn .4s ease; }
.result-card { margin-top: 20px; text-align: center; }
.circle {
width: 150px; height: 150px;
border-radius: 50%;