chore: update project
This commit is contained in:
+5
-1
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user