This commit is contained in:
jocay
2026-08-17 23:57:43 +08:00
parent 005260f893
commit 29b0f03c24
11 changed files with 1085 additions and 256 deletions
+12 -10
View File
@@ -12,12 +12,12 @@ All UI copy, comments, and API error messages are Simplified Chinese. Keep new s
```bash
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 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
@@ -26,7 +26,7 @@ No test runner is configured. `verify-questions.js` is the closest thing to a te
```bash
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/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
```
@@ -36,11 +36,11 @@ node scripts/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "
### 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
`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:
- 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.
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.
`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
`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
`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 `&lt;`. 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 `&lt;`. 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.