update
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This is a Vue 3 and Express quiz application about Dart and Flutter; it is not a Flutter SDK project. The Express API and production static server live in `server.js`. The Vue client is under `web/`: views belong in `web/src/views/`, shared browser utilities in `web/src/`, and global styles in `web/src/styles/theme.css`. `questions.json` is the application database, while `scripts/data/` contains import sources and `scripts/` contains maintenance utilities. Vite writes production assets to the generated, ignored `dist/` directory. Root `index.html` is a legacy artifact; the active entry is `web/index.html`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
- `npm install` installs the pinned dependencies from `package-lock.json`.
|
||||||
|
- `npm run dev` starts Express and Vite together; browse via Vite at `http://localhost:5173`.
|
||||||
|
- `npm run build` bundles `web/` into `dist/`.
|
||||||
|
- `npm start` serves the built client and API on `PORT` (default `3031`).
|
||||||
|
- `node scripts/verify-questions.js` validates question fields, answer indexes, explanations, and difficulty values.
|
||||||
|
- `node scripts/fix-escaping.js` normalizes stored HTML escaping in place; restart the server afterward.
|
||||||
|
|
||||||
|
Keep `vite.config.js`'s `/api` proxy aligned with the Express `PORT` when changing local ports.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use two-space indentation. Follow existing JavaScript style: CommonJS in `server.js` and `scripts/`, ES modules and Vue `<script setup>` in `web/`. Name Vue components and views in PascalCase (`QuizView.vue`), functions and variables in camelCase, and CSS classes in kebab-case. Reuse tokens and shared classes from `theme.css`; keep view-only CSS scoped. UI text, comments, and API errors should remain Simplified Chinese. Preserve the escaping contract in `web/src/htmlUtil.mjs` for authored HTML.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
No unit-test framework or coverage threshold is configured. Before submitting, run `npm run build` and `node scripts/verify-questions.js`. Manually exercise quiz selection, offline answer grading, admin login, and question CRUD when those paths change. Never edit `questions.json` while the server is running: the next API write can overwrite external changes.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Recent history favors short, imperative Conventional Commit subjects such as `chore: change default port to 3031`; use `feat:`, `fix:`, `chore:`, or `docs:` with a focused description. Keep commits single-purpose. Pull requests should explain behavior and data-shape changes, list verification commands, link relevant issues, and include screenshots for visible UI changes. Document new environment variables and avoid committing `.env`, credentials, `node_modules/`, or `dist/`. Replace the default admin credentials in deployed environments.
|
||||||
@@ -12,12 +12,12 @@ All UI copy, comments, and API error messages are Simplified Chinese. Keep new s
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # concurrently: Express on :3030 + Vite dev server on :5173 — develop at http://localhost:5173
|
npm run dev # concurrently: Express on :3031 + Vite dev server on :5173 — develop at http://localhost:5173
|
||||||
npm run build # Vite builds web/ → dist/ at the repo root
|
npm run build # Vite builds web/ → dist/ at the repo root
|
||||||
npm start # Express only on :3030, serving dist/ + API (requires a prior build)
|
npm start # Express only on :3031, serving dist/ + API (requires a prior build)
|
||||||
```
|
```
|
||||||
|
|
||||||
Server env vars (defaults in parens): `PORT` (3030), `ADMIN_USER` (admin), `ADMIN_PASS` (admin123).
|
Server env vars (defaults in parens): `PORT` (3031), `ADMIN_USER` (admin), `ADMIN_PASS` (admin123).
|
||||||
|
|
||||||
### Data scripts
|
### Data scripts
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ No test runner is configured. `verify-questions.js` is the closest thing to a te
|
|||||||
```bash
|
```bash
|
||||||
node scripts/verify-questions.js # structural validation of every question + hardcoded answer-index spot checks
|
node scripts/verify-questions.js # structural validation of every question + hardcoded answer-index spot checks
|
||||||
node scripts/fix-escaping.js # re-normalizes HTML escaping across questions.json in place; also asserts the transform is idempotent
|
node scripts/fix-escaping.js # re-normalizes HTML escaping across questions.json in place; also asserts the transform is idempotent
|
||||||
node scripts/import-sets.js [file...] # POSTs scripts/data/<file> into an ALREADY-RUNNING server on :3030 (logs in as admin/admin123); with no args, re-imports the two original dev sets
|
node scripts/import-sets.js [file...] # POSTs scripts/data/<file> into an ALREADY-RUNNING server on :3031; BASE_URL/ADMIN_USER/ADMIN_PASS can override connection settings
|
||||||
node scripts/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "Legacy files" below
|
node scripts/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "Legacy files" below
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,11 +36,11 @@ node scripts/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "
|
|||||||
|
|
||||||
### Two processes, one origin
|
### Two processes, one origin
|
||||||
|
|
||||||
The frontend never hardcodes an API host — `web/src/api.js` always fetches `/api/...`. In dev, Vite's proxy (`vite.config.js`) forwards `/api` to `:3030`. In production, Express serves `dist/` and the API from the same port, with a catch-all (`/^\/(?!api\/).*/`) sending everything non-`/api` to `index.html`. Preserve that same-origin assumption when adding endpoints.
|
The frontend never hardcodes an API host — `web/src/api.js` always fetches `/api/...`. In dev, Vite's proxy (`vite.config.js`) forwards `/api` to `:3031`. In production, Express serves `dist/` and the API from the same port, with a catch-all (`/^\/(?!api\/).*/`) sending everything non-`/api` to `index.html`. Preserve that same-origin assumption when adding endpoints.
|
||||||
|
|
||||||
### Data store: one JSON file, held in memory
|
### Data store: one JSON file, held in memory
|
||||||
|
|
||||||
`questions.json` at the repo root is the entire database. `server.js` reads it **once at boot** into a module-level `data` object; every mutating route calls `saveData()`, which rewrites the whole file synchronously.
|
`questions.json` at the repo root is the entire database. `server.js` reads and validates it **once at boot** into a module-level `data` object; every mutating route calls `saveData()`, which writes a temporary file and atomically replaces the database.
|
||||||
|
|
||||||
Consequences to keep in mind:
|
Consequences to keep in mind:
|
||||||
- Hand-editing `questions.json` while the server is running will be silently clobbered by the next write. Restart after external edits (including after running `fix-escaping.js`).
|
- Hand-editing `questions.json` while the server is running will be silently clobbered by the next write. Restart after external edits (including after running `fix-escaping.js`).
|
||||||
@@ -54,21 +54,23 @@ Consequences to keep in mind:
|
|||||||
|
|
||||||
`GET /api/questions` returns whole questions through `toPublic()`, **including `ans` and `exp`**. That is deliberate: `QuizView` caches the full set in `localStorage` and grades locally, so answering keeps working with the network down — and because grading is synchronous, a correct pick renders green immediately instead of flashing red while a round trip completes. The trade-off is that answers are visible in the network payload; don't reintroduce answer-stripping without also rethinking offline grading. `toPublic()` is still an explicit whitelist, so new internal fields (authoring notes, review flags) stay server-side unless you add them there.
|
`GET /api/questions` returns whole questions through `toPublic()`, **including `ans` and `exp`**. That is deliberate: `QuizView` caches the full set in `localStorage` and grades locally, so answering keeps working with the network down — and because grading is synchronous, a correct pick renders green immediately instead of flashing red while a round trip completes. The trade-off is that answers are visible in the network payload; don't reintroduce answer-stripping without also rethinking offline grading. `toPublic()` is still an explicit whitelist, so new internal fields (authoring notes, review flags) stay server-side unless you add them there.
|
||||||
|
|
||||||
Caching is **network-first, cache-fallback** in both `loadSets()` and `loadQuestions()`: an online client always gets fresh questions, so edits made in the admin UI are never masked by a stale cache and no invalidation mechanism is needed. Keys are versioned (`quiz-cache-v1-sets`, `quiz-cache-v1-set-<id>`) — bump `CACHE_VER` in `QuizView.vue` when the payload shape changes. Answer progress is intentionally *not* persisted: a reload restarts the round.
|
Caching is **network-first, cache-fallback** in both `loadSets()` and `loadQuestions()`: an online client always gets fresh questions, so edits made in the admin UI are never masked by a stale cache. Keys are versioned (`quiz-cache-v2-sets`, `quiz-cache-v2-set-<id>`) — bump `CACHE_VER` in `QuizView.vue` when the payload shape changes. Answer progress is persisted per set and restored only when the server-provided content `version` still matches.
|
||||||
|
|
||||||
`loadQuestions()` bails out with a visible error if the first question has no `ans`, which catches the dev-mode trap of editing `server.js` without restarting it (`npm run dev` runs Express and Vite as separate processes) — otherwise local grading would silently mark every answer wrong.
|
`loadQuestions()` bails out with a visible error if the first question has no `ans`, which catches the dev-mode trap of editing `server.js` without restarting it (`npm run dev` runs Express and Vite as separate processes) — otherwise local grading would silently mark every answer wrong.
|
||||||
|
|
||||||
`POST /api/check` still exists and still grades server-side, but the quiz page no longer calls it. `GET /api/admin/sets` returns full questions and stays behind auth.
|
`POST /api/check` still exists and still grades server-side, but the quiz page no longer calls it. `GET /api/admin/sets` returns lightweight summaries; fetch one set and its questions from `GET /api/admin/sets/:id`.
|
||||||
|
|
||||||
|
New and duplicated sets are drafts (`published: false`). Public endpoints only expose sets that are published and contain at least one question. Use `PATCH /api/admin/sets/:id/publish` to publish or unpublish; deleting the last question automatically returns a set to draft state.
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
`POST /api/admin/login` compares against the env credentials and returns a random hex token stored in an in-memory `Set`. Tokens have no expiry but are lost on restart, so a server restart logs every admin out. The client keeps the token in `localStorage` under `quiz_token` and sends it as `Authorization: Bearer`; `AdminView.checkAuth()` validates it by probing `/admin/sets` on mount.
|
`POST /api/admin/login` compares against the env credentials and returns a random hex token stored in an in-memory `Set`. Tokens have no expiry but are lost on restart, so a server restart logs every admin out. The client keeps the token in `localStorage` under `quiz_token` and sends it as `Authorization: Bearer`; `AdminView` loads `/admin/sets` once on mount to validate the token and bootstrap the list.
|
||||||
|
|
||||||
### HTML escaping contract
|
### HTML escaping contract
|
||||||
|
|
||||||
`web/src/htmlUtil.mjs` is shared by the browser bundle and the Node scripts — that is why it is `.mjs` (CommonJS scripts `await import()` it). Don't rename it to `.js`.
|
`web/src/htmlUtil.mjs` is shared by the browser bundle and the Node scripts — that is why it is `.mjs` (CommonJS scripts `await import()` it). Don't rename it to `.js`.
|
||||||
|
|
||||||
`escapeSmartHtml()` preserves an allowlist of formatting tags (`b`, `code`, `pre`, lists, headings, …) and escapes everything else, so question authors can paste raw `<` and `>` from code samples without hand-writing `<`. Stored content in `questions.json` is therefore already-escaped HTML.
|
`escapeSmartHtml()` preserves an allowlist of formatting tags (`b`, `code`, `pre`, lists, headings, …), strips all tag attributes, and escapes everything else, so question authors can paste raw `<` and `>` from code samples without hand-writing `<`. The server applies the same attribute-free allowlist when questions are created, imported, or updated. Stored content in `questions.json` is therefore already-escaped HTML.
|
||||||
|
|
||||||
The round trip in `AdminView` is: `decodeEntities()` when loading into the editor → edit as plain text → `escapeSmartHtml(decodeEntities(...))` on save. This is deliberately idempotent, and `fix-escaping.js` verifies that property. Keep any new field that accepts authored markup on the same path.
|
The round trip in `AdminView` is: `decodeEntities()` when loading into the editor → edit as plain text → `escapeSmartHtml(decodeEntities(...))` on save. This is deliberately idempotent, and `fix-escaping.js` verifies that property. Keep any new field that accepts authored markup on the same path.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const BASE = 'http://localhost:3030';
|
const BASE = (process.env.BASE_URL || 'http://localhost:3031').replace(/\/$/, '');
|
||||||
|
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
||||||
|
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin123';
|
||||||
const ARGS = process.argv.slice(2);
|
const ARGS = process.argv.slice(2);
|
||||||
const FILES = ARGS.length ? ARGS : ['set-dart-dev.json', 'set-flutter-dev.json'];
|
const FILES = ARGS.length ? ARGS : ['set-dart-dev.json', 'set-flutter-dev.json'];
|
||||||
|
|
||||||
@@ -9,7 +11,7 @@ async function main() {
|
|||||||
const loginRes = await fetch(BASE + '/api/admin/login', {
|
const loginRes = await fetch(BASE + '/api/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: 'admin', password: 'admin123' })
|
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS })
|
||||||
});
|
});
|
||||||
const login = await loginRes.json();
|
const login = await loginRes.json();
|
||||||
if (!loginRes.ok) throw new Error('登录失败: ' + login.error);
|
if (!loginRes.ok) throw new Error('登录失败: ' + login.error);
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
const d = require('../questions.json');
|
const d = require('../questions.json');
|
||||||
let total = 0, bad = [];
|
let total = 0, bad = [];
|
||||||
|
const setIds = new Set(), qIds = new Set(), setNames = new Set();
|
||||||
|
let maxSetId = 0, maxQId = 0;
|
||||||
|
if (!d || !Array.isArray(d.sets)) {
|
||||||
|
console.error('questions.json 根节点必须包含 sets 数组');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
for (const set of d.sets) {
|
for (const set of d.sets) {
|
||||||
|
if (!Number.isInteger(set.id) || set.id < 1 || setIds.has(set.id)) bad.push(`[套题] ID 非法或重复: ${set.id}`);
|
||||||
|
else { setIds.add(set.id); maxSetId = Math.max(maxSetId, set.id); }
|
||||||
|
const normalizedName = typeof set.name === 'string' ? set.name.trim().toLowerCase() : '';
|
||||||
|
if (!normalizedName || setNames.has(normalizedName)) bad.push(`[套题 #${set.id}] 名称为空或重复`);
|
||||||
|
else setNames.add(normalizedName);
|
||||||
|
if (typeof set.name === 'string' && set.name.trim().length > 80) bad.push(`[套题 #${set.id}] 名称超过 80 个字符`);
|
||||||
|
if (set.description !== undefined && (typeof set.description !== 'string' || set.description.trim().length > 240)) {
|
||||||
|
bad.push(`[${set.name || set.id}] description 必须为不超过 240 字的字符串`);
|
||||||
|
}
|
||||||
|
if (set.published !== undefined && typeof set.published !== 'boolean') bad.push(`[${set.name || set.id}] published 必须为布尔值`);
|
||||||
|
if (!Array.isArray(set.questions)) { bad.push(`[${set.name || set.id}] questions 不是数组`); continue; }
|
||||||
|
if (set.lvNames != null && (!Array.isArray(set.lvNames) || set.lvNames.length !== 6 || set.lvNames.some(n => typeof n !== 'string' || !n.trim()))) {
|
||||||
|
bad.push(`[${set.name}] lvNames 必须为 6 个非空名称`);
|
||||||
|
}
|
||||||
for (const q of set.questions) {
|
for (const q of set.questions) {
|
||||||
total++;
|
total++;
|
||||||
if (!q.q || !Array.isArray(q.opts) || q.opts.length < 2) { bad.push(`[${set.name} #${q.id}] 选项不足`); continue; }
|
if (!Number.isInteger(q.id) || q.id < 1 || qIds.has(q.id)) { bad.push(`[${set.name}] 题目 ID 非法或重复: ${q.id}`); continue; }
|
||||||
|
qIds.add(q.id); maxQId = Math.max(maxQId, q.id);
|
||||||
|
if (typeof q.q !== 'string' || !q.q.trim()) { bad.push(`[${set.name} #${q.id}] 题干必须为非空字符串`); continue; }
|
||||||
|
if (!Array.isArray(q.opts) || q.opts.length < 2 || q.opts.some(o => typeof o !== 'string' || !o.trim())) {
|
||||||
|
bad.push(`[${set.name} #${q.id}] 至少需要 2 个非空字符串选项`); continue;
|
||||||
|
}
|
||||||
if (!Number.isInteger(q.ans) || q.ans < 0 || q.ans >= q.opts.length) { bad.push(`[${set.name} #${q.id}] ans 越界`); continue; }
|
if (!Number.isInteger(q.ans) || q.ans < 0 || q.ans >= q.opts.length) { bad.push(`[${set.name} #${q.id}] ans 越界`); continue; }
|
||||||
if (!q.exp) { bad.push(`[${set.name} #${q.id}] 缺解析`); continue; }
|
if (typeof q.exp !== 'string' || !q.exp.trim()) { bad.push(`[${set.name} #${q.id}] 解析必须为非空字符串`); continue; }
|
||||||
if (!Number.isInteger(q.lv) || q.lv < 1 || q.lv > 6) { bad.push(`[${set.name} #${q.id}] lv 非法`); continue; }
|
if (!Number.isInteger(q.lv) || q.lv < 1 || q.lv > 6) { bad.push(`[${set.name} #${q.id}] lv 非法`); continue; }
|
||||||
const dup = q.opts.findIndex((o, i) => q.opts.indexOf(o) !== i);
|
const normalizedOpts = q.opts.map(o => o.trim());
|
||||||
|
const dup = normalizedOpts.findIndex((o, i) => normalizedOpts.indexOf(o) !== i);
|
||||||
if (dup !== -1) { bad.push(`[${set.name} #${q.id}] 选项重复: "${q.opts[dup]}"`); }
|
if (dup !== -1) { bad.push(`[${set.name} #${q.id}] 选项重复: "${q.opts[dup]}"`); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(d.nextSetId) || d.nextSetId <= maxSetId) bad.push(`nextSetId 应大于 ${maxSetId}`);
|
||||||
|
if (!Number.isInteger(d.nextQId) || d.nextQId <= maxQId) bad.push(`nextQId 应大于 ${maxQId}`);
|
||||||
console.log('总题数:', total);
|
console.log('总题数:', total);
|
||||||
if (bad.length) { console.log('问题:'); bad.forEach(b => console.log(' -', b)); }
|
if (bad.length) { console.log('问题:'); bad.forEach(b => console.log(' -', b)); process.exitCode = 1; }
|
||||||
else console.log('结构校验全部通过');
|
else console.log('结构校验全部通过');
|
||||||
|
|
||||||
// 抽查逻辑已移除,因为选项顺序已被随机打乱
|
// 抽查逻辑已移除,因为选项顺序已被随机打乱
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const PORT = process.env.PORT || 3031;
|
|||||||
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
||||||
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin123';
|
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin123';
|
||||||
const DATA_FILE = path.join(__dirname, 'questions.json');
|
const DATA_FILE = path.join(__dirname, 'questions.json');
|
||||||
|
const MAX_SET_NAME = 80;
|
||||||
|
const MAX_SET_DESCRIPTION = 240;
|
||||||
|
const SAFE_HTML_TAGS = new Set([
|
||||||
|
'b', 'strong', 'i', 'em', 'code', 'br', 'span', 'div', 'p',
|
||||||
|
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'blockquote', 'pre', 'u', 's', 'mark', 'small', 'sub', 'sup'
|
||||||
|
]);
|
||||||
|
const SAFE_TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)>/g;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
@@ -24,28 +32,87 @@ if (fs.existsSync(DIST)) {
|
|||||||
function loadData() {
|
function loadData() {
|
||||||
if (!fs.existsSync(DATA_FILE)) return { sets: [], nextSetId: 1, nextQId: 1 };
|
if (!fs.existsSync(DATA_FILE)) return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
let raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||||
|
let migratedFromArray = false;
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
const qs = raw;
|
raw = {
|
||||||
const migrated = {
|
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: raw }],
|
||||||
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: qs }],
|
|
||||||
nextSetId: 2,
|
nextSetId: 2,
|
||||||
nextQId: qs.length ? Math.max(...qs.map(q => q.id)) + 1 : 1
|
nextQId: 1
|
||||||
};
|
};
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(migrated, null, 2), 'utf8');
|
migratedFromArray = true;
|
||||||
return migrated;
|
|
||||||
}
|
}
|
||||||
if (raw && Array.isArray(raw.sets)) return raw;
|
if (!raw || !Array.isArray(raw.sets)) throw new Error('根节点必须包含 sets 数组');
|
||||||
return { sets: [], nextSetId: 1, nextQId: 1 };
|
|
||||||
} catch {
|
const setIds = new Set();
|
||||||
return { sets: [], nextSetId: 1, nextQId: 1 };
|
const questionIds = new Set();
|
||||||
|
const setNames = new Set();
|
||||||
|
let maxSetId = 0;
|
||||||
|
let maxQId = 0;
|
||||||
|
for (const set of raw.sets) {
|
||||||
|
if (!Number.isInteger(set.id) || set.id < 1 || setIds.has(set.id)) throw new Error('套题 ID 缺失、非法或重复');
|
||||||
|
if (!set.name || typeof set.name !== 'string' || !Array.isArray(set.questions)) throw new Error(`套题 #${set.id} 结构不完整`);
|
||||||
|
const normalizedName = set.name.trim().toLowerCase();
|
||||||
|
if (!normalizedName) throw new Error(`套题 #${set.id} 的名称不能为空`);
|
||||||
|
if (setNames.has(normalizedName)) throw new Error(`套题名称重复:${set.name}`);
|
||||||
|
if (set.description !== undefined && typeof set.description !== 'string') throw new Error(`套题 #${set.id} 的简介必须为字符串`);
|
||||||
|
if (set.published !== undefined && typeof set.published !== 'boolean') throw new Error(`套题 #${set.id} 的 published 必须为布尔值`);
|
||||||
|
if (set.published === undefined) set.published = set.questions.length > 0;
|
||||||
|
if (!set.questions.length) set.published = false;
|
||||||
|
const lvErr = validateLvNames(set.lvNames);
|
||||||
|
if (lvErr) throw new Error(`套题 #${set.id}:${lvErr}`);
|
||||||
|
setIds.add(set.id);
|
||||||
|
setNames.add(normalizedName);
|
||||||
|
maxSetId = Math.max(maxSetId, set.id);
|
||||||
|
for (const q of set.questions) {
|
||||||
|
if (!Number.isInteger(q.id) || q.id < 1 || questionIds.has(q.id)) throw new Error('题目 ID 缺失、非法或重复');
|
||||||
|
const questionErr = validateBody(q);
|
||||||
|
if (questionErr) throw new Error(`套题 #${set.id} 的题目 #${q.id}:${questionErr}`);
|
||||||
|
questionIds.add(q.id);
|
||||||
|
maxQId = Math.max(maxQId, q.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw.nextSetId = Math.max(Number.isInteger(raw.nextSetId) ? raw.nextSetId : 1, maxSetId + 1);
|
||||||
|
raw.nextQId = Math.max(Number.isInteger(raw.nextQId) ? raw.nextQId : 1, maxQId + 1);
|
||||||
|
if (migratedFromArray) {
|
||||||
|
const tempFile = DATA_FILE + '.tmp';
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempFile, JSON.stringify(raw, null, 2), 'utf8');
|
||||||
|
fs.renameSync(tempFile, DATA_FILE);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`无法加载 questions.json:${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = loadData();
|
const data = loadData();
|
||||||
|
let savedDataSnapshot = JSON.stringify(data);
|
||||||
|
|
||||||
function saveData() {
|
function saveData() {
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');
|
const tempFile = DATA_FILE + '.tmp';
|
||||||
|
const serialized = JSON.stringify(data, null, 2);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempFile, serialized, 'utf8');
|
||||||
|
fs.renameSync(tempFile, DATA_FILE);
|
||||||
|
savedDataSnapshot = serialized;
|
||||||
|
} catch (err) {
|
||||||
|
// 路由会先修改内存再落盘。使用最近一次成功保存的内存快照回滚,
|
||||||
|
// 即使磁盘此时也不可读,失败操作也不会泄漏到后续请求。
|
||||||
|
const restored = JSON.parse(savedDataSnapshot);
|
||||||
|
for (const key of Object.keys(data)) delete data[key];
|
||||||
|
Object.assign(data, restored);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error('清理题库临时文件失败:', cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = new Set();
|
const tokens = new Set();
|
||||||
@@ -58,6 +125,73 @@ function findQuestion(qid) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findSet(id) {
|
||||||
|
return data.sets.find(s => s.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublished(set) {
|
||||||
|
return set.published !== false && set.questions.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersion(set) {
|
||||||
|
return crypto.createHash('sha1')
|
||||||
|
.update(JSON.stringify({ lvNames: set.lvNames || null, questions: set.questions }))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelCounts(set) {
|
||||||
|
const counts = [0, 0, 0, 0, 0, 0];
|
||||||
|
for (const q of set.questions) {
|
||||||
|
if (Number.isInteger(q.lv) && q.lv >= 1 && q.lv <= 6) counts[q.lv - 1]++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSetSummary(set, admin = false) {
|
||||||
|
const summary = {
|
||||||
|
id: set.id,
|
||||||
|
name: set.name,
|
||||||
|
description: set.description || '',
|
||||||
|
count: set.questions.length,
|
||||||
|
lvNames: set.lvNames || null,
|
||||||
|
levelCounts: levelCounts(set),
|
||||||
|
version: setVersion(set)
|
||||||
|
};
|
||||||
|
if (admin) summary.published = isPublished(set);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSetFields(body, excludeId = null) {
|
||||||
|
const { name, description } = body || {};
|
||||||
|
if (!name || typeof name !== 'string' || name.trim() === '') return '套题名称不能为空';
|
||||||
|
if (name.trim().length > MAX_SET_NAME) return `套题名称不能超过 ${MAX_SET_NAME} 个字符`;
|
||||||
|
if (description !== undefined && typeof description !== 'string') return '套题简介必须为字符串';
|
||||||
|
if ((description || '').trim().length > MAX_SET_DESCRIPTION) return `套题简介不能超过 ${MAX_SET_DESCRIPTION} 个字符`;
|
||||||
|
if (data.sets.some(s => s.id !== excludeId && s.name.trim().toLowerCase() === name.trim().toLowerCase())) {
|
||||||
|
return '已存在同名套题';
|
||||||
|
}
|
||||||
|
return validateLvNames(body.lvNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySetFields(set, body) {
|
||||||
|
set.name = body.name.trim();
|
||||||
|
set.description = (body.description || '').trim();
|
||||||
|
if (body.lvNames === null) delete set.lvNames;
|
||||||
|
else if (body.lvNames !== undefined) set.lvNames = body.lvNames.map(x => x.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueCopyName(name) {
|
||||||
|
const names = new Set(data.sets.map(s => s.name.trim().toLowerCase()));
|
||||||
|
let index = 1;
|
||||||
|
while (true) {
|
||||||
|
const suffix = index === 1 ? '(副本)' : `(副本 ${index})`;
|
||||||
|
const candidate = name.slice(0, MAX_SET_NAME - suffix.length).trimEnd() + suffix;
|
||||||
|
if (!names.has(candidate.toLowerCase())) return candidate;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 答题页需要整套缓存以支持离线判分,因此下发答案与解析。
|
// 答题页需要整套缓存以支持离线判分,因此下发答案与解析。
|
||||||
// 保留显式白名单:以后给题目加内部字段(如出题备注)不会顺手漏出去。
|
// 保留显式白名单:以后给题目加内部字段(如出题备注)不会顺手漏出去。
|
||||||
function toPublic(q) {
|
function toPublic(q) {
|
||||||
@@ -69,14 +203,38 @@ function validateBody(body) {
|
|||||||
if (!q || typeof q !== 'string' || q.trim() === '') return '题目内容不能为空';
|
if (!q || typeof q !== 'string' || q.trim() === '') return '题目内容不能为空';
|
||||||
if (!Array.isArray(opts) || opts.length < 2) return '至少需要 2 个选项';
|
if (!Array.isArray(opts) || opts.length < 2) return '至少需要 2 个选项';
|
||||||
if (opts.some(o => typeof o !== 'string' || o.trim() === '')) return '选项不能为空';
|
if (opts.some(o => typeof o !== 'string' || o.trim() === '')) return '选项不能为空';
|
||||||
|
const normalizedOpts = opts.map(o => o.trim());
|
||||||
|
if (new Set(normalizedOpts).size !== normalizedOpts.length) return '选项不能重复';
|
||||||
if (typeof ans !== 'number' || !Number.isInteger(ans) || ans < 0 || ans >= opts.length) return '答案序号不正确';
|
if (typeof ans !== 'number' || !Number.isInteger(ans) || ans < 0 || ans >= opts.length) return '答案序号不正确';
|
||||||
if (lv !== undefined && (!Number.isInteger(lv) || lv < 1 || lv > 6)) return '难度需为 1-6 的整数';
|
if (lv !== undefined && (!Number.isInteger(lv) || lv < 1 || lv > 6)) return '难度需为 1-6 的整数';
|
||||||
|
if (typeof exp !== 'string' || exp.trim() === '') return '答案解析不能为空';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAuthoredHtml(html) {
|
||||||
|
const placeholders = [];
|
||||||
|
const text = String(html).replace(SAFE_TAG_RE, (match, tag) => {
|
||||||
|
const normalizedTag = tag.toLowerCase();
|
||||||
|
if (!SAFE_HTML_TAGS.has(normalizedTag)) return match;
|
||||||
|
placeholders.push(match.startsWith('</') ? `</${normalizedTag}>` : `<${normalizedTag}>`);
|
||||||
|
return `\u0000${placeholders.length - 1}\u0000`;
|
||||||
|
});
|
||||||
|
return text
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\u0000(\d+)\u0000/g, (_, i) => placeholders[Number(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
function buildQuestion(body) {
|
function buildQuestion(body) {
|
||||||
const { lv, q, opts, ans, exp } = body;
|
const { lv, q, opts, ans, exp } = body;
|
||||||
return { id: data.nextQId++, lv: lv === undefined ? 1 : lv, q: q.trim(), opts, ans, exp: (exp || '').trim() };
|
return {
|
||||||
|
id: data.nextQId++,
|
||||||
|
lv: lv === undefined ? 1 : lv,
|
||||||
|
q: sanitizeAuthoredHtml(q.trim()),
|
||||||
|
opts: opts.map(o => o.trim()),
|
||||||
|
ans,
|
||||||
|
exp: sanitizeAuthoredHtml(exp.trim())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLvNames(v) {
|
function validateLvNames(v) {
|
||||||
@@ -88,15 +246,18 @@ function validateLvNames(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/sets', (req, res) => {
|
app.get('/api/sets', (req, res) => {
|
||||||
res.json({ sets: data.sets.map(s => ({ id: s.id, name: s.name, count: s.questions.length, lvNames: s.lvNames || null })) });
|
const sets = data.sets.filter(isPublished).map(s => toSetSummary(s));
|
||||||
|
res.json({ sets, total: sets.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/questions', (req, res) => {
|
app.get('/api/questions', (req, res) => {
|
||||||
const setId = req.query.set ? Number(req.query.set) : (data.sets[0] ? data.sets[0].id : null);
|
const publishedSets = data.sets.filter(isPublished);
|
||||||
const set = data.sets.find(s => s.id === setId);
|
const setId = req.query.set ? Number(req.query.set) : (publishedSets[0] ? publishedSets[0].id : null);
|
||||||
if (!set) return res.status(404).json({ error: '套题不存在' });
|
if (req.query.set && !Number.isInteger(setId)) return res.status(400).json({ error: '套题 ID 格式不正确' });
|
||||||
|
const set = publishedSets.find(s => s.id === setId);
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在、尚未发布或暂无题目' });
|
||||||
res.json({
|
res.json({
|
||||||
set: { id: set.id, name: set.name, lvNames: set.lvNames || null },
|
set: toSetSummary(set),
|
||||||
count: set.questions.length,
|
count: set.questions.length,
|
||||||
questions: set.questions.map(toPublic)
|
questions: set.questions.map(toPublic)
|
||||||
});
|
});
|
||||||
@@ -105,7 +266,7 @@ app.get('/api/questions', (req, res) => {
|
|||||||
app.post('/api/check', (req, res) => {
|
app.post('/api/check', (req, res) => {
|
||||||
const { id, answer } = req.body || {};
|
const { id, answer } = req.body || {};
|
||||||
const hit = findQuestion(id);
|
const hit = findQuestion(id);
|
||||||
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
if (!hit || !isPublished(hit.set)) return res.status(404).json({ error: '题目不存在' });
|
||||||
if (!Number.isInteger(answer)) return res.status(400).json({ error: 'answer 必须为数字' });
|
if (!Number.isInteger(answer)) return res.status(400).json({ error: 'answer 必须为数字' });
|
||||||
res.json({ correct: hit.q.ans === answer, answer: hit.q.ans, exp: hit.q.exp });
|
res.json({ correct: hit.q.ans === answer, answer: hit.q.ans, exp: hit.q.exp });
|
||||||
});
|
});
|
||||||
@@ -128,59 +289,92 @@ function requireAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/admin/sets', requireAuth, (req, res) => {
|
app.get('/api/admin/sets', requireAuth, (req, res) => {
|
||||||
res.json({ sets: data.sets });
|
res.json({ sets: data.sets.map(s => toSetSummary(s, true)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/sets/:id', requireAuth, (req, res) => {
|
||||||
|
const set = findSet(Number(req.params.id));
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
res.json({ set: { ...toSetSummary(set, true), questions: set.questions } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/sets', requireAuth, (req, res) => {
|
app.post('/api/admin/sets', requireAuth, (req, res) => {
|
||||||
const { name } = req.body || {};
|
const err = validateSetFields(req.body);
|
||||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
if (err) return res.status(400).json({ error: err });
|
||||||
const lvErr = validateLvNames(req.body.lvNames);
|
const set = { id: data.nextSetId++, name: '', description: '', published: false, questions: [] };
|
||||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
applySetFields(set, req.body);
|
||||||
const set = { id: data.nextSetId++, name: name.trim(), questions: [] };
|
|
||||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
|
||||||
data.sets.push(set);
|
data.sets.push(set);
|
||||||
saveData();
|
saveData();
|
||||||
res.json(set);
|
res.status(201).json({ set: toSetSummary(set, true) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/sets/:id', requireAuth, (req, res) => {
|
app.put('/api/admin/sets/:id', requireAuth, (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const set = data.sets.find(s => s.id === id);
|
const set = findSet(id);
|
||||||
if (!set) return res.status(404).json({ error: '套题不存在' });
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
const { name } = req.body || {};
|
const err = validateSetFields(req.body, id);
|
||||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
if (err) return res.status(400).json({ error: err });
|
||||||
const lvErr = validateLvNames(req.body.lvNames);
|
applySetFields(set, req.body);
|
||||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
|
||||||
set.name = name.trim();
|
|
||||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
|
||||||
saveData();
|
saveData();
|
||||||
res.json(set);
|
res.json({ set: toSetSummary(set, true) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/sets/:id/publish', requireAuth, (req, res) => {
|
||||||
|
const set = findSet(Number(req.params.id));
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
if (typeof req.body?.published !== 'boolean') return res.status(400).json({ error: 'published 必须为布尔值' });
|
||||||
|
if (req.body.published && !set.questions.length) return res.status(400).json({ error: '空套题不能发布,请先添加题目' });
|
||||||
|
set.published = req.body.published;
|
||||||
|
saveData();
|
||||||
|
res.json({ set: toSetSummary(set, true) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/sets/:id/duplicate', requireAuth, (req, res) => {
|
||||||
|
const source = findSet(Number(req.params.id));
|
||||||
|
if (!source) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
const copy = {
|
||||||
|
id: data.nextSetId++,
|
||||||
|
name: uniqueCopyName(source.name),
|
||||||
|
description: source.description || '',
|
||||||
|
published: false,
|
||||||
|
questions: source.questions.map(q => ({ ...q, id: data.nextQId++, opts: [...q.opts] }))
|
||||||
|
};
|
||||||
|
if (source.lvNames) copy.lvNames = [...source.lvNames];
|
||||||
|
data.sets.push(copy);
|
||||||
|
saveData();
|
||||||
|
res.status(201).json({ set: toSetSummary(copy, true) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/sets/:id', requireAuth, (req, res) => {
|
app.delete('/api/admin/sets/:id', requireAuth, (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const idx = data.sets.findIndex(s => s.id === id);
|
const idx = data.sets.findIndex(s => s.id === id);
|
||||||
if (idx === -1) return res.status(404).json({ error: '套题不存在' });
|
if (idx === -1) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
const deleted = data.sets[idx];
|
||||||
data.sets.splice(idx, 1);
|
data.sets.splice(idx, 1);
|
||||||
saveData();
|
saveData();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true, deleted: { id: deleted.id, name: deleted.name } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/import', requireAuth, (req, res) => {
|
app.post('/api/admin/import', requireAuth, (req, res) => {
|
||||||
const { name, questions } = req.body || {};
|
const { questions } = req.body || {};
|
||||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
const setErr = validateSetFields(req.body);
|
||||||
|
if (setErr) return res.status(400).json({ error: setErr });
|
||||||
if (!Array.isArray(questions)) return res.status(400).json({ error: 'questions 必须为数组' });
|
if (!Array.isArray(questions)) return res.status(400).json({ error: 'questions 必须为数组' });
|
||||||
const lvErr = validateLvNames(req.body.lvNames);
|
|
||||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
for (let i = 0; i < questions.length; i++) {
|
||||||
const err = validateBody(questions[i]);
|
const err = validateBody(questions[i]);
|
||||||
if (err) return res.status(400).json({ error: '第 ' + (i + 1) + ' 题格式错误:' + err });
|
if (err) return res.status(400).json({ error: '第 ' + (i + 1) + ' 题格式错误:' + err });
|
||||||
}
|
}
|
||||||
const set = { id: data.nextSetId++, name: name.trim(), questions: questions.map(buildQuestion) };
|
const set = {
|
||||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
id: data.nextSetId++,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
published: questions.length > 0,
|
||||||
|
questions: questions.map(buildQuestion)
|
||||||
|
};
|
||||||
|
applySetFields(set, req.body);
|
||||||
data.sets.push(set);
|
data.sets.push(set);
|
||||||
saveData();
|
saveData();
|
||||||
res.json({ set: { id: set.id, name: set.name, count: set.questions.length }, imported: set.questions.length });
|
res.status(201).json({ set: toSetSummary(set, true), imported: set.questions.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
||||||
@@ -189,6 +383,7 @@ app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
|||||||
if (!set) return res.status(404).json({ error: '套题不存在' });
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
res.json({
|
res.json({
|
||||||
name: set.name,
|
name: set.name,
|
||||||
|
description: set.description || '',
|
||||||
lvNames: set.lvNames || null,
|
lvNames: set.lvNames || null,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
questions: set.questions.map(q => ({ lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp }))
|
questions: set.questions.map(q => ({ lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp }))
|
||||||
@@ -204,7 +399,7 @@ app.post('/api/admin/sets/:id/questions', requireAuth, (req, res) => {
|
|||||||
const item = buildQuestion(req.body);
|
const item = buildQuestion(req.body);
|
||||||
set.questions.push(item);
|
set.questions.push(item);
|
||||||
saveData();
|
saveData();
|
||||||
res.json(item);
|
res.status(201).json({ question: item, set: toSetSummary(set, true) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||||
@@ -215,12 +410,12 @@ app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
|||||||
const { lv, q, opts, ans, exp } = req.body;
|
const { lv, q, opts, ans, exp } = req.body;
|
||||||
const item = hit.q;
|
const item = hit.q;
|
||||||
item.lv = lv === undefined ? item.lv : lv;
|
item.lv = lv === undefined ? item.lv : lv;
|
||||||
item.q = q.trim();
|
item.q = sanitizeAuthoredHtml(q.trim());
|
||||||
item.opts = opts;
|
item.opts = opts.map(o => o.trim());
|
||||||
item.ans = ans;
|
item.ans = ans;
|
||||||
item.exp = (exp || '').trim();
|
item.exp = sanitizeAuthoredHtml(exp.trim());
|
||||||
saveData();
|
saveData();
|
||||||
res.json(item);
|
res.json({ question: item, set: toSetSummary(hit.set, true) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||||
@@ -228,13 +423,36 @@ app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
|||||||
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
||||||
const idx = hit.set.questions.indexOf(hit.q);
|
const idx = hit.set.questions.indexOf(hit.q);
|
||||||
hit.set.questions.splice(idx, 1);
|
hit.set.questions.splice(idx, 1);
|
||||||
|
if (!hit.set.questions.length) hit.set.published = false;
|
||||||
saveData();
|
saveData();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true, set: toSetSummary(hit.set, true) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.use('/api', (req, res) => {
|
||||||
console.log('测验系统已启动: http://localhost:' + PORT);
|
res.status(404).json({ error: '接口不存在', code: 'NOT_FOUND' });
|
||||||
console.log('答题页: http://localhost:' + PORT + '/');
|
});
|
||||||
console.log('管理后台: http://localhost:' + PORT + '/admin.html');
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
if (err.type === 'entity.parse.failed') {
|
||||||
|
return res.status(400).json({ error: '请求 JSON 格式不正确', code: 'INVALID_JSON' });
|
||||||
|
}
|
||||||
|
if (err.type === 'entity.too.large') {
|
||||||
|
return res.status(413).json({ error: '请求内容超过 2MB 限制', code: 'PAYLOAD_TOO_LARGE' });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '服务暂时不可用,请稍后重试', code: 'INTERNAL_ERROR' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function startServer(port = PORT) {
|
||||||
|
return app.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log('测验系统已启动: http://localhost:' + port);
|
||||||
|
console.log('答题页: http://localhost:' + port + '/');
|
||||||
|
console.log('管理后台: http://localhost:' + port + '/#/admin');
|
||||||
console.log('管理员账号: ' + ADMIN_USER + ' / ' + ADMIN_PASS);
|
console.log('管理员账号: ' + ADMIN_USER + ' / ' + ADMIN_PASS);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) startServer();
|
||||||
|
|
||||||
|
module.exports = { app, startServer };
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3030'
|
'/api': 'http://localhost:3031'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+21
-8
@@ -12,23 +12,36 @@ export function clearToken() {
|
|||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api(path, options = {}) {
|
async function request(path, options = {}, withAuth = false) {
|
||||||
const headers = Object.assign({}, options.headers || {})
|
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'
|
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(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.error || '请求失败')
|
const err = new Error(data.error || '请求失败')
|
||||||
err.status = res.status
|
err.status = res.status
|
||||||
|
err.code = data.code
|
||||||
|
// 只清除这次请求实际携带、且仍是当前值的 token;避免迟到的旧请求
|
||||||
|
// 把另一个标签页或并发登录刚写入的新会话误删。
|
||||||
|
if (withAuth && res.status === 401 && requestToken && getToken() === requestToken) clearToken()
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPublic(path, options = {}) {
|
export async function api(path, options = {}) {
|
||||||
const res = await fetch('/api' + path, options)
|
return request(path, options, true)
|
||||||
const data = await res.json().catch(() => ({}))
|
}
|
||||||
if (!res.ok) throw new Error(data.error || '请求失败')
|
|
||||||
return data
|
export async function apiPublic(path, options = {}) {
|
||||||
|
return request(path, options, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^<>]*>/g
|
|||||||
export function escapeSmartHtml(html) {
|
export function escapeSmartHtml(html) {
|
||||||
const placeholders = []
|
const placeholders = []
|
||||||
const text = String(html).replace(TAG_RE, (m, tag) => {
|
const text = String(html).replace(TAG_RE, (m, tag) => {
|
||||||
if (ALLOWED_TAGS.includes(tag.toLowerCase())) {
|
const normalizedTag = tag.toLowerCase()
|
||||||
placeholders.push(m)
|
if (ALLOWED_TAGS.includes(normalizedTag)) {
|
||||||
|
// 只保留标签本身,不保留 onclick/style 等属性,避免 authored HTML 注入脚本或样式。
|
||||||
|
placeholders.push(m.startsWith('</') ? `</${normalizedTag}>` : `<${normalizedTag}>`)
|
||||||
return '\u0000' + (placeholders.length - 1) + '\u0000'
|
return '\u0000' + (placeholders.length - 1) + '\u0000'
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ body {
|
|||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: all .2s;
|
transition: all .2s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover { background: var(--surface-high); }
|
.nav-link:hover { background: var(--surface-high); }
|
||||||
@@ -159,6 +160,15 @@ body {
|
|||||||
.btn:active { transform: scale(.97); }
|
.btn:active { transform: scale(.97); }
|
||||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
.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 { background: var(--ink); color: var(--surface); }
|
||||||
.btn-filled:hover:not(:disabled) { box-shadow: var(--shadow-lift); }
|
.btn-filled:hover:not(:disabled) { box-shadow: var(--shadow-lift); }
|
||||||
|
|
||||||
@@ -297,7 +307,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@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; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+352
-83
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
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'
|
import { escapeSmartHtml, decodeEntities, stripHtmlAndDecode } from '../htmlUtil.mjs'
|
||||||
|
|
||||||
const authed = ref(false)
|
const authed = ref(false)
|
||||||
|
const booting = ref(Boolean(getToken()))
|
||||||
const loginUser = ref('')
|
const loginUser = ref('')
|
||||||
const loginPass = ref('')
|
const loginPass = ref('')
|
||||||
const loginMsg = ref('')
|
const loginMsg = ref('')
|
||||||
@@ -13,123 +14,218 @@ const view = ref('list')
|
|||||||
const currentSetId = ref(null)
|
const currentSetId = ref(null)
|
||||||
const qlist = ref([])
|
const qlist = ref([])
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
|
const notice = reactive({ text: '', type: 'ok' })
|
||||||
|
let setsRequestId = 0
|
||||||
|
|
||||||
const keyOf = i => String.fromCharCode(65 + i)
|
const keyOf = i => String.fromCharCode(65 + i)
|
||||||
|
|
||||||
const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
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 importForm = reactive({ show: false, text: '', name: '', msg: '' })
|
||||||
const importFileRef = ref(null)
|
const importFileRef = ref(null)
|
||||||
const qForm = reactive({
|
const qForm = reactive({
|
||||||
show: false, editingId: null, title: '',
|
show: false, editingId: null, title: '',
|
||||||
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
|
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
|
||||||
})
|
})
|
||||||
|
const qFormSnapshot = ref('')
|
||||||
|
|
||||||
const currentSet = computed(() => sets.value.find(s => s.id === currentSetId.value) || null)
|
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 lvNameOptions = computed(() => {
|
||||||
const names = currentSet.value?.lvNames
|
const names = currentSet.value?.lvNames
|
||||||
return (Array.isArray(names) && names.length === 6) ? names : DEFAULT_LV_NAMES
|
return (Array.isArray(names) && names.length === 6) ? names : DEFAULT_LV_NAMES
|
||||||
})
|
})
|
||||||
|
|
||||||
async function checkAuth() {
|
function showNotice(text, type = 'ok') {
|
||||||
if (!getToken()) return
|
notice.text = text
|
||||||
try {
|
notice.type = type
|
||||||
await api('/admin/sets')
|
}
|
||||||
authed.value = true
|
|
||||||
} catch (e) {
|
function resetAdminUi() {
|
||||||
clearToken()
|
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() {
|
async function doLogin() {
|
||||||
|
if (busy.value) return
|
||||||
loginMsg.value = ''
|
loginMsg.value = ''
|
||||||
if (!loginUser.value.trim() || !loginPass.value) { loginMsg.value = '请输入用户名和密码'; return }
|
if (!loginUser.value.trim() || !loginPass.value) { loginMsg.value = '请输入用户名和密码'; return }
|
||||||
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api('/admin/login', {
|
const data = await apiPublic('/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value })
|
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value })
|
||||||
})
|
})
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadSets()
|
||||||
authed.value = true
|
authed.value = true
|
||||||
|
loginPass.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginMsg.value = e.message
|
loginMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
|
if (busy.value || !canDiscardQuestionForm()) return
|
||||||
clearToken()
|
clearToken()
|
||||||
authed.value = false
|
authed.value = false
|
||||||
view.value = 'list'
|
resetAdminUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSets() {
|
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
|
sets.value = data.sets
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) {
|
async function refreshAfterMutation(message, includeDetail = false) {
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
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() {
|
function showAddSet() {
|
||||||
|
if (busy.value) return
|
||||||
|
importForm.show = false
|
||||||
setForm.show = true
|
setForm.show = true
|
||||||
setForm.editingId = null
|
setForm.editingId = null
|
||||||
setForm.title = '新增套题'
|
setForm.title = '新增套题'
|
||||||
setForm.name = ''
|
setForm.name = ''
|
||||||
|
setForm.description = ''
|
||||||
setForm.lvNames = [...DEFAULT_LV_NAMES]
|
setForm.lvNames = [...DEFAULT_LV_NAMES]
|
||||||
setForm.msg = ''
|
setForm.msg = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRenameSet(id) {
|
function showRenameSet(id) {
|
||||||
|
if (busy.value) return
|
||||||
const s = sets.value.find(x => x.id === id)
|
const s = sets.value.find(x => x.id === id)
|
||||||
if (!s) return
|
if (!s) return
|
||||||
setForm.show = true
|
setForm.show = true
|
||||||
setForm.editingId = id
|
setForm.editingId = id
|
||||||
setForm.title = '重命名套题'
|
setForm.title = '编辑套题信息'
|
||||||
setForm.name = s.name
|
setForm.name = s.name
|
||||||
|
setForm.description = s.description || ''
|
||||||
setForm.lvNames = s.lvNames ? [...s.lvNames] : [...DEFAULT_LV_NAMES]
|
setForm.lvNames = s.lvNames ? [...s.lvNames] : [...DEFAULT_LV_NAMES]
|
||||||
setForm.msg = ''
|
setForm.msg = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSet() {
|
async function saveSet() {
|
||||||
|
if (busy.value) return
|
||||||
setForm.msg = ''
|
setForm.msg = ''
|
||||||
if (!setForm.name.trim()) { setForm.msg = '请输入套题名称'; return }
|
if (!setForm.name.trim()) { setForm.msg = '请输入套题名称'; return }
|
||||||
const body = {
|
const body = {
|
||||||
name: setForm.name.trim(),
|
name: setForm.name.trim(),
|
||||||
|
description: setForm.description.trim(),
|
||||||
lvNames: setForm.lvNames.map(n => n.trim())
|
lvNames: setForm.lvNames.map(n => n.trim())
|
||||||
}
|
}
|
||||||
if (body.lvNames.some(n => !n)) { setForm.msg = '6 个难度名称都不能为空'; return }
|
if (body.lvNames.some(n => !n)) { setForm.msg = '6 个难度名称都不能为空'; return }
|
||||||
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
|
const editing = setForm.editingId !== null
|
||||||
if (setForm.editingId === null) {
|
if (setForm.editingId === null) {
|
||||||
await api('/admin/sets', { method: 'POST', body: JSON.stringify(body) })
|
await api('/admin/sets', { method: 'POST', body: JSON.stringify(body) })
|
||||||
} else {
|
} else {
|
||||||
await api('/admin/sets/' + setForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
await api('/admin/sets/' + setForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
}
|
}
|
||||||
setForm.show = false
|
setForm.show = false
|
||||||
loadSets()
|
await refreshAfterMutation(editing ? '套题信息已更新' : '套题已创建为草稿,请添加题目后发布')
|
||||||
} catch (e) {
|
} 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) {
|
async function deleteSet(id) {
|
||||||
|
if (busy.value) return
|
||||||
const s = sets.value.find(x => x.id === id)
|
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 {
|
try {
|
||||||
await api('/admin/sets/' + id, { method: 'DELETE' })
|
await api('/admin/sets/' + id, { method: 'DELETE' })
|
||||||
if (currentSetId.value === id) backToList()
|
if (currentSetId.value === id) backToList()
|
||||||
loadSets()
|
await refreshAfterMutation(`已删除套题「${s.name}」`)
|
||||||
} catch (e) {
|
} 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) {
|
async function exportSet(id) {
|
||||||
|
if (busy.value) return
|
||||||
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api('/admin/sets/' + id + '/export')
|
const data = await api('/admin/sets/' + id + '/export')
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
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.download = data.name + '.json'
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(a.href)
|
URL.revokeObjectURL(a.href)
|
||||||
|
showNotice('套题已导出')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message)
|
handleError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showImport() {
|
function showImport() {
|
||||||
|
if (busy.value) return
|
||||||
|
setForm.show = false
|
||||||
importForm.show = true
|
importForm.show = true
|
||||||
importForm.text = ''
|
importForm.text = ''
|
||||||
importForm.name = ''
|
importForm.name = ''
|
||||||
@@ -170,6 +271,7 @@ function onImportFile(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
async function doImport() {
|
||||||
|
if (busy.value) return
|
||||||
importForm.msg = ''
|
importForm.msg = ''
|
||||||
const raw = importForm.text.trim()
|
const raw = importForm.text.trim()
|
||||||
if (!raw) { importForm.msg = '请粘贴 JSON 或选择文件'; return }
|
if (!raw) { importForm.msg = '请粘贴 JSON 或选择文件'; return }
|
||||||
@@ -180,46 +282,96 @@ async function doImport() {
|
|||||||
importForm.msg = 'JSON 解析失败:' + e.message
|
importForm.msg = 'JSON 解析失败:' + e.message
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let name, questions
|
let name, description = '', lvNames, questions
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
questions = parsed
|
questions = parsed
|
||||||
name = importForm.name.trim()
|
name = importForm.name.trim()
|
||||||
} else if (parsed && Array.isArray(parsed.questions)) {
|
} else if (parsed && Array.isArray(parsed.questions)) {
|
||||||
questions = parsed.questions
|
questions = parsed.questions
|
||||||
name = importForm.name.trim() || parsed.name || ''
|
name = importForm.name.trim() || parsed.name || ''
|
||||||
|
description = typeof parsed.description === 'string' ? parsed.description : ''
|
||||||
|
lvNames = parsed.lvNames
|
||||||
} else {
|
} else {
|
||||||
importForm.msg = '格式不支持:需要题目数组,或 {"name":..., "questions":[...]}'
|
importForm.msg = '格式不支持:需要题目数组,或 {"name":..., "questions":[...]}'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!name) { importForm.msg = '请填写套题名称'; 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 {
|
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
|
importForm.show = false
|
||||||
loadSets()
|
await refreshAfterMutation(`导入成功:共 ${res.imported} 道题${res.set.published ? ',已发布' : ',已保存为草稿'}`)
|
||||||
alert('导入成功:共 ' + res.imported + ' 道题')
|
|
||||||
} catch (e) {
|
} 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) {
|
async function openSet(id) {
|
||||||
|
if (busy.value) return
|
||||||
|
resetQuestionForm()
|
||||||
currentSetId.value = id
|
currentSetId.value = id
|
||||||
view.value = 'detail'
|
view.value = 'detail'
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
await renderQuestions()
|
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'
|
view.value = 'list'
|
||||||
currentSetId.value = null
|
currentSetId.value = null
|
||||||
loadSets()
|
qlist.value = []
|
||||||
|
if (refresh) loadSets().catch(handleError)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderQuestions() {
|
async function renderQuestions() {
|
||||||
if (currentSetId.value == null) return
|
if (currentSetId.value == null) return
|
||||||
const data = await api('/admin/sets')
|
const data = await api('/admin/sets/' + currentSetId.value)
|
||||||
const set = data.sets.find(s => s.id === currentSetId.value)
|
qlist.value = data.set.questions
|
||||||
qlist.value = set ? 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() {
|
function showQuestionForm() {
|
||||||
|
if (!canDiscardQuestionForm()) return
|
||||||
qForm.show = true
|
qForm.show = true
|
||||||
qForm.editingId = null
|
qForm.editingId = null
|
||||||
qForm.title = '新增题目'
|
qForm.title = '新增题目'
|
||||||
@@ -238,6 +391,7 @@ function showQuestionForm() {
|
|||||||
qForm.msg = ''
|
qForm.msg = ''
|
||||||
resetOpts(['', '', '', ''])
|
resetOpts(['', '', '', ''])
|
||||||
syncAns()
|
syncAns()
|
||||||
|
qFormSnapshot.value = serializeQuestionForm()
|
||||||
scrollFormIntoView()
|
scrollFormIntoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,9 +401,14 @@ function addOpt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeOpt(i) {
|
function removeOpt(i) {
|
||||||
|
if (qForm.opts.length <= 2) return
|
||||||
|
const answer = Number(qForm.ans)
|
||||||
qForm.opts.splice(i, 1)
|
qForm.opts.splice(i, 1)
|
||||||
qForm.opts.forEach((o, j) => { o.key = keyOf(j) })
|
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() {
|
function syncAns() {
|
||||||
@@ -258,6 +417,7 @@ function syncAns() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editQuestion(id) {
|
function editQuestion(id) {
|
||||||
|
if (!canDiscardQuestionForm()) return
|
||||||
const item = qlist.value.find(x => x.id === id)
|
const item = qlist.value.find(x => x.id === id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
qForm.show = true
|
qForm.show = true
|
||||||
@@ -269,6 +429,7 @@ function editQuestion(id) {
|
|||||||
qForm.msg = ''
|
qForm.msg = ''
|
||||||
resetOpts(item.opts)
|
resetOpts(item.opts)
|
||||||
qForm.ans = String(item.ans)
|
qForm.ans = String(item.ans)
|
||||||
|
qFormSnapshot.value = serializeQuestionForm()
|
||||||
scrollFormIntoView()
|
scrollFormIntoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +459,7 @@ function insertCode(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveQuestion() {
|
async function saveQuestion() {
|
||||||
|
if (busy.value) return
|
||||||
qForm.msg = ''
|
qForm.msg = ''
|
||||||
const opts = qForm.opts.map(o => o.val.trim())
|
const opts = qForm.opts.map(o => o.val.trim())
|
||||||
const body = {
|
const body = {
|
||||||
@@ -309,38 +471,62 @@ async function saveQuestion() {
|
|||||||
}
|
}
|
||||||
if (!body.q) { qForm.msg = '请填写题干'; return }
|
if (!body.q) { qForm.msg = '请填写题干'; return }
|
||||||
if (opts.length < 2 || opts.some(o => !o)) { qForm.msg = '请填写所有选项(至少 2 个且不能为空)'; 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 {
|
try {
|
||||||
|
const editing = qForm.editingId !== null
|
||||||
if (qForm.editingId === null) {
|
if (qForm.editingId === null) {
|
||||||
await api('/admin/sets/' + currentSetId.value + '/questions', { method: 'POST', body: JSON.stringify(body) })
|
await api('/admin/sets/' + currentSetId.value + '/questions', { method: 'POST', body: JSON.stringify(body) })
|
||||||
} else {
|
} else {
|
||||||
await api('/admin/questions/' + qForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
await api('/admin/questions/' + qForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
}
|
}
|
||||||
qForm.show = false
|
qForm.show = false
|
||||||
renderQuestions()
|
await refreshAfterMutation(editing ? '题目已更新' : '题目已添加', true)
|
||||||
} catch (e) {
|
} 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) {
|
async function deleteQuestion(id) {
|
||||||
|
if (busy.value) return
|
||||||
if (!confirm('确定删除题目 #' + id + ' 吗?此操作不可恢复。')) return
|
if (!confirm('确定删除题目 #' + id + ' 吗?此操作不可恢复。')) return
|
||||||
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
await api('/admin/questions/' + id, { method: 'DELETE' })
|
await api('/admin/questions/' + id, { method: 'DELETE' })
|
||||||
renderQuestions()
|
if (qForm.editingId === id) resetQuestionForm()
|
||||||
|
await refreshAfterMutation('题目已删除', true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message)
|
handleError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkAuth()
|
if (!getToken()) return
|
||||||
if (authed.value) loadSets()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
|
||||||
<div class="brand-seal small">印</div>
|
<div class="brand-seal small">印</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -349,58 +535,81 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-top:20px;">
|
<div class="field" style="margin-top:20px;">
|
||||||
<label>用户名</label>
|
<label for="admin-username">用户名</label>
|
||||||
<input class="input" v-model="loginUser" placeholder="admin" @keydown.enter="doLogin">
|
<input id="admin-username" class="input" v-model="loginUser" autocomplete="username" placeholder="admin" @keydown.enter="doLogin">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>密码</label>
|
<label for="admin-password">密码</label>
|
||||||
<input class="input" type="password" v-model="loginPass" placeholder="请输入密码" @keydown.enter="doLogin">
|
<input id="admin-password" class="input" type="password" v-model="loginPass" autocomplete="current-password" placeholder="请输入密码" @keydown.enter="doLogin">
|
||||||
</div>
|
</div>
|
||||||
<div class="msg err">{{ loginMsg }}</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>
|
||||||
|
|
||||||
<!-- 管理 -->
|
<!-- 管理 -->
|
||||||
<div v-else>
|
<div v-else :inert="busy || undefined" :aria-busy="busy">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div style="display:flex;align-items:center;gap:10px;">
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
<template v-if="view === 'detail'">
|
<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-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 style="font-size:12px;color:var(--ink-faint);">({{ qlist.length }} 题)</span>
|
||||||
|
<span class="status-tag" :class="currentSet?.published ? 'published' : 'draft'">
|
||||||
|
{{ currentSet?.published ? '已发布' : '草稿' }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else style="font-family:var(--serif);font-weight:900;font-size:19px;letter-spacing:2px;">套题集</span>
|
<span v-else style="font-family:var(--serif);font-weight:900;font-size:19px;letter-spacing:2px;">套题集</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<template v-if="view === 'list'">
|
<template v-if="view === 'list'">
|
||||||
<button class="btn btn-tonal btn-sm" @click="showAddSet">+ 新增套题</button>
|
<button class="btn btn-tonal btn-sm" :disabled="busy" @click="showAddSet">+ 新增套题</button>
|
||||||
<button class="btn btn-outline btn-sm" @click="showImport">⬆ 导入套题</button>
|
<button class="btn btn-outline btn-sm" :disabled="busy" @click="showImport">⬆ 导入套题</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
</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>
|
</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 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 style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ setForm.title }}</div>
|
||||||
<div class="field" style="margin-top:12px;">
|
<div class="field" style="margin-top:12px;">
|
||||||
<label>套题名称</label>
|
<label for="set-name">套题名称</label>
|
||||||
<input class="input" v-model="setForm.name" placeholder="例如:Dart 语法测验" @keydown.enter="saveSet">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>6 个难度等级名称(答题页徽章与题目表单使用)</label>
|
<label>6 个难度等级名称(答题页徽章与题目表单使用)</label>
|
||||||
<div v-for="(n, i) in setForm.lvNames" :key="i" class="lv-row">
|
<div v-for="(n, i) in setForm.lvNames" :key="i" class="lv-row">
|
||||||
<span class="lv-row-key">Lv{{ i + 1 }}</span>
|
<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>
|
</div>
|
||||||
<div class="msg err">{{ setForm.msg }}</div>
|
<div class="msg err">{{ setForm.msg }}</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:10px;">
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
<button class="btn btn-primary" @click="saveSet">保存</button>
|
<button class="btn btn-primary" :disabled="busy" @click="saveSet">{{ busy ? '保存中…' : '保存' }}</button>
|
||||||
<button class="btn btn-outline" @click="setForm.show = false">取消</button>
|
<button class="btn btn-outline" :disabled="busy" @click="setForm.show = false">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,7 +619,7 @@ onMounted(async () => {
|
|||||||
<div class="import-zone">
|
<div class="import-zone">
|
||||||
<div>
|
<div>
|
||||||
<input ref="importFileRef" type="file" accept=".json,application/json" style="display:none;" @change="onImportFile">
|
<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>
|
<span style="font-size:12px;color:var(--ink-faint);">(或直接粘贴下方 JSON)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
@@ -418,16 +627,16 @@ onMounted(async () => {
|
|||||||
① {"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}<br>
|
① {"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}<br>
|
||||||
② 仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}](名称在下方填写)
|
② 仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}](名称在下方填写)
|
||||||
</div>
|
</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;">
|
<div class="field" style="margin-top:10px;">
|
||||||
<label>套题名称(格式②或覆盖默认名时填写)</label>
|
<label for="import-set-name">套题名称(格式②或覆盖默认名时填写)</label>
|
||||||
<input class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
|
<input id="import-set-name" class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="msg err">{{ importForm.msg }}</div>
|
<div class="msg err">{{ importForm.msg }}</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:10px;">
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
<button class="btn btn-primary" @click="doImport">开始导入</button>
|
<button class="btn btn-primary" :disabled="busy" @click="doImport">{{ busy ? '导入中…' : '开始导入' }}</button>
|
||||||
<button class="btn btn-outline" @click="importForm.show = false">取消</button>
|
<button class="btn btn-outline" :disabled="busy" @click="importForm.show = false">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -435,16 +644,23 @@ onMounted(async () => {
|
|||||||
<div v-if="view === 'list'" class="card">
|
<div v-if="view === 'list'" class="card">
|
||||||
<div v-if="!sets.length" class="empty-state">暂无套题,点击「新增套题」创建第一套</div>
|
<div v-if="!sets.length" class="empty-state">暂无套题,点击「新增套题」创建第一套</div>
|
||||||
<div v-for="s in sets" :key="s.id" class="set-item">
|
<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 style="flex:1;min-width:0;">
|
||||||
|
<div class="set-title-row">
|
||||||
<div class="set-name">{{ s.name }}</div>
|
<div class="set-name">{{ s.name }}</div>
|
||||||
<div class="set-sub">共 {{ s.questions.length }} 题</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>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;">
|
<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-tonal btn-sm" :disabled="busy" @click="openSet(s.id)">编辑题目</button>
|
||||||
<button class="btn btn-outline btn-sm" @click="showRenameSet(s.id)">重命名</button>
|
<button class="btn btn-outline btn-sm" :disabled="busy" @click="showRenameSet(s.id)">编辑信息</button>
|
||||||
<button class="btn btn-outline btn-sm" @click="exportSet(s.id)">导出</button>
|
<button class="btn btn-outline btn-sm" :disabled="busy || (!s.published && !s.count)" :title="!s.count ? '请先添加题目' : ''" @click="togglePublish(s)">
|
||||||
<button class="btn btn-danger btn-sm" @click="deleteSet(s.id)">删除</button>
|
{{ 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>
|
</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><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="color:var(--ink-secondary);line-height:1.6;">{{ stripHtmlAndDecode(q.q).slice(0, 60) }}</td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
<button class="btn btn-outline btn-sm" @click="editQuestion(q.id)">编辑</button>
|
<button class="btn btn-outline btn-sm" :disabled="busy" @click="editQuestion(q.id)">编辑</button>
|
||||||
<button class="btn btn-danger btn-sm" @click="deleteQuestion(q.id)">删除</button>
|
<button class="btn btn-danger btn-sm" :disabled="busy" @click="deleteQuestion(q.id)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -475,17 +691,17 @@ onMounted(async () => {
|
|||||||
<div v-if="qForm.show" class="qform" ref="qFormRef">
|
<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 style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ qForm.title }}</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>难度</label>
|
<label for="question-level">难度</label>
|
||||||
<select class="select" v-model="qForm.lv">
|
<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>
|
<option v-for="(n, i) in lvNameOptions" :key="i" :value="String(i + 1)">{{ i + 1 }} - {{ n }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>题干(支持 HTML;代码里的 < > 会<b>自动转义</b>,不用手写 &lt;)</label>
|
<label for="question-text">题干(支持 HTML;代码里的 < > 会<b>自动转义</b>,不用手写 &lt;)</label>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
<button class="btn btn-outline btn-sm" @click="insertCode('q')"><code> 插入代码块</button>
|
<button class="btn btn-outline btn-sm" @click="insertCode('q')"><code> 插入代码块</button>
|
||||||
</div>
|
</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 v-if="qForm.q" class="preview">
|
||||||
<div class="preview-head">实时预览</div>
|
<div class="preview-head">实时预览</div>
|
||||||
<div class="preview-body" v-html="qPreview"></div>
|
<div class="preview-body" v-html="qPreview"></div>
|
||||||
@@ -495,23 +711,24 @@ onMounted(async () => {
|
|||||||
<label>选项(至少 2 个,可增减)</label>
|
<label>选项(至少 2 个,可增减)</label>
|
||||||
<div v-for="(o, i) in qForm.opts" :key="i" class="opt-row">
|
<div v-for="(o, i) in qForm.opts" :key="i" class="opt-row">
|
||||||
<span class="opt-row-key">{{ o.key }}</span>
|
<span class="opt-row-key">{{ o.key }}</span>
|
||||||
<input class="input" v-model="o.val" :placeholder="'选项 ' + o.key">
|
<input class="input" v-model="o.val" :aria-label="`选项 ${o.key}`" :placeholder="'选项 ' + o.key">
|
||||||
<button class="btn btn-danger btn-sm" @click="removeOpt(i)">✕</button>
|
<button class="btn btn-danger btn-sm" :disabled="busy || qForm.opts.length <= 2" title="至少保留两个选项" @click="removeOpt(i)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline btn-sm" @click="addOpt">+ 添加选项</button>
|
<button class="btn btn-outline btn-sm" @click="addOpt">+ 添加选项</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>正确答案</label>
|
<label for="question-answer">正确答案</label>
|
||||||
<select class="select" v-model="qForm.ans">
|
<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>
|
<option v-for="(o, i) in qForm.opts" :key="i" :value="String(i)">{{ o.key }} - {{ stripHtmlAndDecode(o.val) || '(空)' }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>解析(答题后展示,支持 HTML;< & > 自动转义)</label>
|
<label for="question-explanation">解析(答题后展示,支持 HTML;< & > 自动转义)</label>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
<button class="btn btn-outline btn-sm" @click="insertCode('exp')"><code> 插入代码块</button>
|
<button class="btn btn-outline btn-sm" @click="insertCode('exp')"><code> 插入代码块</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 <b>main()</b>。"></textarea>
|
<textarea id="question-explanation" class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 <b>main()</b>。"></textarea>
|
||||||
<div v-if="qForm.exp" class="preview">
|
<div v-if="qForm.exp" class="preview">
|
||||||
<div class="preview-head">实时预览</div>
|
<div class="preview-head">实时预览</div>
|
||||||
<div class="preview-body" v-html="expPreview"></div>
|
<div class="preview-body" v-html="expPreview"></div>
|
||||||
@@ -519,8 +736,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="msg err">{{ qForm.msg }}</div>
|
<div class="msg err">{{ qForm.msg }}</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:10px;">
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
<button class="btn btn-primary" @click="saveQuestion">保存</button>
|
<button class="btn btn-primary" :disabled="busy" @click="saveQuestion">{{ busy ? '保存中…' : '保存' }}</button>
|
||||||
<button class="btn btn-outline" @click="qForm.show = false">取消</button>
|
<button class="btn btn-outline" :disabled="busy" @click="closeQuestionForm">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,6 +758,41 @@ onMounted(async () => {
|
|||||||
gap: 10px;
|
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 {
|
.set-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -561,7 +813,18 @@ onMounted(async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.set-name { font-weight: 800; font-size: 15px; color: var(--ink); }
|
.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 {
|
.import-zone {
|
||||||
border: 1.5px dashed var(--hairline-strong);
|
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(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(b) { color: var(--primary); }
|
||||||
.preview-body :deep(pre) { background: var(--surface-high); border-radius: 10px; padding: 12px; overflow-x: auto; }
|
.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>
|
</style>
|
||||||
|
|||||||
+338
-86
@@ -10,6 +10,7 @@ const loading = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const sets = ref([])
|
const sets = ref([])
|
||||||
const questions = ref([])
|
const questions = ref([])
|
||||||
|
const sourceQuestions = ref([])
|
||||||
const activeSetId = ref(null)
|
const activeSetId = ref(null)
|
||||||
const cur = ref(0)
|
const cur = ref(0)
|
||||||
const score = ref(0)
|
const score = ref(0)
|
||||||
@@ -17,7 +18,12 @@ const userAnswers = ref([])
|
|||||||
const answered = ref([])
|
const answered = ref([])
|
||||||
const offline = ref(false)
|
const offline = ref(false)
|
||||||
const showResult = 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')
|
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
|
||||||
|
let questionRequestId = 0
|
||||||
|
let ignoredRouteId = null
|
||||||
|
|
||||||
function shuffleList(arr) {
|
function shuffleList(arr) {
|
||||||
const a = [...arr]
|
const a = [...arr]
|
||||||
@@ -30,9 +36,10 @@ function shuffleList(arr) {
|
|||||||
|
|
||||||
/* ---------- 离线缓存 ---------- */
|
/* ---------- 离线缓存 ---------- */
|
||||||
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
|
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
|
||||||
const CACHE_VER = 'v1'
|
const CACHE_VER = 'v2'
|
||||||
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
|
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
|
||||||
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
|
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
|
||||||
|
const progressKey = id => `quiz-progress-v1-set-${id}`
|
||||||
|
|
||||||
function readCache(key) {
|
function readCache(key) {
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +58,14 @@ function writeCache(key, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCache(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
/* 无法访问本地存储时不影响答题 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
||||||
const lvNames = ref(DEFAULT_LV_NAMES)
|
const lvNames = ref(DEFAULT_LV_NAMES)
|
||||||
|
|
||||||
@@ -58,11 +73,14 @@ const keyOf = i => String.fromCharCode(65 + i)
|
|||||||
|
|
||||||
const total = computed(() => questions.value.length)
|
const total = computed(() => questions.value.length)
|
||||||
const current = computed(() => questions.value[cur.value] || null)
|
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 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 pct = computed(() => total.value ? Math.round(score.value / total.value * 100) : 0)
|
||||||
const isLast = computed(() => cur.value === total.value - 1)
|
const isLast = computed(() => cur.value === total.value - 1)
|
||||||
const answeredHere = computed(() => answered.value[cur.value] || false)
|
const answeredHere = computed(() => answered.value[cur.value] || false)
|
||||||
const isRight = computed(() => answeredHere.value && userAnswers.value[cur.value] === current.value.ans)
|
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) {
|
function gradeOf(p) {
|
||||||
if (p >= 90) return { title: '翰林之才', sub: 'Dart 已了然于心,可放心挥毫写 Flutter 了。' }
|
if (p >= 90) return { title: '翰林之才', sub: 'Dart 已了然于心,可放心挥毫写 Flutter 了。' }
|
||||||
@@ -79,85 +97,178 @@ function optClass(i) {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网络优先、缓存兜底:只要在线就一定拿到最新题目,
|
// 网络优先、缓存兜底;4xx 表示套题已下架或参数错误,不使用旧缓存“复活”它。
|
||||||
// 管理后台改完题不会被旧缓存挡住,因此不需要额外的缓存失效机制。
|
function canUseCache(e) {
|
||||||
async function loadSets() {
|
return !e.status || e.status >= 500
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQuestions(setId) {
|
function persistProgress() {
|
||||||
loading.value = true
|
if (!activeSetId.value || !questions.value.length) return
|
||||||
error.value = ''
|
const answers = {}
|
||||||
const key = setCacheKey(setId)
|
questions.value.forEach((q, i) => {
|
||||||
let data = null
|
if (answered.value[i]) answers[q.id] = userAnswers.value[i]
|
||||||
try {
|
})
|
||||||
data = await apiPublic('/questions?set=' + setId)
|
writeCache(progressKey(activeSetId.value), {
|
||||||
writeCache(key, data)
|
version: activeVersion.value,
|
||||||
offline.value = false
|
questionIds: questions.value.map(q => q.id),
|
||||||
} catch (e) {
|
answers,
|
||||||
data = readCache(key)
|
cur: cur.value,
|
||||||
if (!data) {
|
showResult: showResult.value,
|
||||||
error.value = '该套题还没有缓存过,请联网后再打开一次'
|
reviewingResult: reviewingResult.value,
|
||||||
loading.value = false
|
completed: showResult.value || reviewingResult.value,
|
||||||
return
|
updatedAt: Date.now()
|
||||||
}
|
})
|
||||||
offline.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地判分依赖题目自带 ans。开发时 server 与 vite 是两个进程,
|
function startFresh(questionList) {
|
||||||
// 改完 server.js 忘了重启就会拿到不带 ans 的老数据,那样每题都会被判错。
|
questions.value = shuffle.value ? shuffleList(questionList) : [...questionList]
|
||||||
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)
|
userAnswers.value = new Array(questions.value.length).fill(null)
|
||||||
answered.value = new Array(questions.value.length).fill(false)
|
answered.value = new Array(questions.value.length).fill(false)
|
||||||
cur.value = 0
|
cur.value = 0
|
||||||
score.value = 0
|
score.value = 0
|
||||||
showResult.value = false
|
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
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickSet(id) {
|
function pickSet(id) {
|
||||||
if (id === activeSetId.value) {
|
if (id === activeSetId.value) return
|
||||||
loadQuestions(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.replace({ path: '/quiz', query: { set: id } })
|
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 未知的中间态,
|
// 本地同步判分:赋值与判分落在同一个渲染周期,不存在 ans 未知的中间态,
|
||||||
// 因此没有"先红后绿"的闪烁,断网也照样能答。
|
// 因此没有"先红后绿"的闪烁,断网也照样能答。
|
||||||
function choose(i) {
|
function choose(i) {
|
||||||
@@ -165,24 +276,48 @@ function choose(i) {
|
|||||||
userAnswers.value[cur.value] = i
|
userAnswers.value[cur.value] = i
|
||||||
answered.value[cur.value] = true
|
answered.value[cur.value] = true
|
||||||
if (i === current.value.ans) score.value++
|
if (i === current.value.ans) score.value++
|
||||||
|
persistProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
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++
|
else if (cur.value < total.value - 1) cur.value++
|
||||||
|
persistProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (cur.value > 0) cur.value--
|
if (cur.value > 0) cur.value--
|
||||||
|
persistProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
function restart() {
|
function restart(ask = false) {
|
||||||
if (shuffle.value) questions.value = shuffleList(questions.value)
|
if (ask && hasProgress.value && !confirm('确定清除当前进度并重新开始吗?')) return
|
||||||
|
questions.value = shuffle.value ? shuffleList(sourceQuestions.value) : [...sourceQuestions.value]
|
||||||
userAnswers.value = new Array(questions.value.length).fill(null)
|
userAnswers.value = new Array(questions.value.length).fill(null)
|
||||||
answered.value = new Array(questions.value.length).fill(false)
|
answered.value = new Array(questions.value.length).fill(false)
|
||||||
cur.value = 0
|
cur.value = 0
|
||||||
score.value = 0
|
score.value = 0
|
||||||
showResult.value = false
|
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() {
|
function toggleShuffle() {
|
||||||
@@ -191,8 +326,17 @@ function toggleShuffle() {
|
|||||||
|
|
||||||
watch(() => route.query.set, v => {
|
watch(() => route.query.set, v => {
|
||||||
const id = Number(v)
|
const id = Number(v)
|
||||||
|
if (id === ignoredRouteId) {
|
||||||
|
ignoredRouteId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
if (id && id !== activeSetId.value && sets.value.some(s => s.id === id)) {
|
if (id && id !== activeSetId.value && sets.value.some(s => s.id === id)) {
|
||||||
loadQuestions(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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="loading" class="empty-state">墨已研好,正在取题…</div>
|
<div v-if="loading && !sets.length" class="empty-state" role="status" aria-live="polite">墨已研好,正在取题…</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="card empty-state">{{ error }}</div>
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="card" style="padding:18px 24px;">
|
<div v-if="sets.length" class="card set-picker">
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
<div class="set-picker-row">
|
||||||
<span style="font-size:12.5px;color:var(--ink-secondary);font-weight:700;letter-spacing:1px;">选一套题</span>
|
<span class="set-picker-label">选一套题</span>
|
||||||
<button
|
<button
|
||||||
v-for="s in sets" :key="s.id"
|
v-for="s in sets" :key="s.id"
|
||||||
class="chip" :class="{ active: s.id === activeSetId }"
|
class="chip" :class="{ active: s.id === activeSetId }"
|
||||||
|
:disabled="loading && s.id === activeSetId"
|
||||||
@click="pickSet(s.id)">
|
@click="pickSet(s.id)">
|
||||||
{{ s.name }}<span class="chip-count">{{ s.count }}</span>
|
{{ s.name }}<span class="chip-count">{{ s.count }}</span>
|
||||||
</button>
|
</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="开启后,每次进入或重开套题都会打乱题目顺序">
|
<label class="shuffle-toggle" title="开启后,每次进入或重开套题都会打乱题目顺序">
|
||||||
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
|
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
|
||||||
<span>打乱题目顺序</span>
|
<span>重新开始时打乱题序</span>
|
||||||
</label>
|
</label>
|
||||||
<span v-if="offline" class="offline-tag" title="当前使用本地缓存的题目,联网后会自动取最新">离线 · 使用本地缓存</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="slide" mode="out-in">
|
<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 style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px;">
|
||||||
<div class="q-num">第 {{ cur + 1 }} 题 · 共 {{ total }} 题</div>
|
<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>
|
||||||
<div class="bar"><div class="bar-fill" :style="{ width: progress + '%' }"></div></div>
|
|
||||||
|
|
||||||
<div class="q-head">
|
<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>
|
<h2 class="q-title" v-html="current.q"></h2>
|
||||||
|
|
||||||
@@ -261,15 +431,18 @@ onMounted(loadSets)
|
|||||||
|
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button class="btn btn-outline" :disabled="cur === 0" @click="prev">上一题</button>
|
<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 ? '看成绩' : '下一题' }}
|
{{ isLast ? '看成绩' : '下一题' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!answeredHere" class="answer-hint">选择答案后即可继续,进度会自动保存</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<transition name="pop">
|
<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" :style="{ '--p': pct }">
|
||||||
<div class="circle-inner">
|
<div class="circle-inner">
|
||||||
<div class="score-num">{{ score }}</div>
|
<div class="score-num">{{ score }}</div>
|
||||||
@@ -280,19 +453,19 @@ onMounted(loadSets)
|
|||||||
<div class="grade-sub">{{ gradeOf(pct).sub }}</div>
|
<div class="grade-sub">{{ gradeOf(pct).sub }}</div>
|
||||||
|
|
||||||
<div class="review">
|
<div class="review">
|
||||||
<div class="review-head">答题回顾</div>
|
<div class="review-head">答题回顾 · 点击题目查看解析</div>
|
||||||
<div v-for="(q, i) in questions" :key="q.id" class="review-item">
|
<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>
|
<b style="color:var(--ink-faint);margin-right:6px;">{{ i + 1 }}.</b>
|
||||||
<span class="review-text">{{ stripHtmlAndDecode(q.q).slice(0, 40) }}</span>
|
<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-if="userAnswers[i] === q.ans" class="review-verdict ok">✓ 对</span>
|
||||||
<span v-else class="review-verdict no">
|
<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>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align:center;margin-top:20px;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -301,6 +474,36 @@ onMounted(loadSets)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.chip {
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--hairline);
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
@@ -318,6 +521,7 @@ onMounted(loadSets)
|
|||||||
}
|
}
|
||||||
.chip:hover { border-color: var(--ink); color: var(--ink); }
|
.chip:hover { border-color: var(--ink); color: var(--ink); }
|
||||||
.chip.active { background: var(--ink); border-color: var(--ink); color: var(--surface); }
|
.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; }
|
.chip-count { font-size: 11px; opacity: .75; }
|
||||||
|
|
||||||
.shuffle-toggle {
|
.shuffle-toggle {
|
||||||
@@ -328,7 +532,6 @@ onMounted(loadSets)
|
|||||||
color: var(--ink-secondary);
|
color: var(--ink-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-left: 6px;
|
|
||||||
}
|
}
|
||||||
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
|
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
|
||||||
|
|
||||||
@@ -346,6 +549,32 @@ onMounted(loadSets)
|
|||||||
white-space: nowrap;
|
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; }
|
.quiz-card { margin-top: 20px; }
|
||||||
.q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; }
|
.q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; }
|
||||||
.score-pill {
|
.score-pill {
|
||||||
@@ -417,8 +646,10 @@ onMounted(loadSets)
|
|||||||
.explain-body { opacity: .9; }
|
.explain-body { opacity: .9; }
|
||||||
|
|
||||||
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
.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-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 {
|
.circle {
|
||||||
width: 150px; height: 150px;
|
width: 150px; height: 150px;
|
||||||
border-radius: 50%;
|
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 { 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-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-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 { font-weight: 800; font-size: 12px; flex-shrink: 0; }
|
||||||
.review-verdict.ok { color: var(--success); }
|
.review-verdict.ok { color: var(--success); }
|
||||||
.review-verdict.no { color: var(--error); }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user