chore: update project

This commit is contained in:
jocay
2026-08-09 22:10:19 +08:00
parent 22b5d75d2d
commit 4ef6b8af05
10 changed files with 998 additions and 107 deletions
+89
View File
@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Despite the repo name, this is **not** a Flutter project — there is no Dart toolchain here. It is a Vue 3 + Express web app that serves multiple-choice quizzes *about* Dart & Flutter, with an admin backend for managing question sets.
All UI copy, comments, and API error messages are Simplified Chinese. Keep new strings in Chinese to match.
## Commands
```bash
npm install
npm run dev # concurrently: Express on :3030 + 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)
```
Server env vars (defaults in parens): `PORT` (3030), `ADMIN_USER` (admin), `ADMIN_PASS` (admin123).
### Data scripts
No test runner is configured. `verify-questions.js` is the closest thing to a test suite — run it after any bulk edit of `questions.json`.
```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/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "Legacy files" below
```
`scripts/` and `server.js` are CommonJS (no `"type": "module"` in package.json); everything under `web/` is ESM.
## Architecture
### 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.
### 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.
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`).
- `loadData()` auto-migrates a legacy top-level array into `{sets, nextSetId, nextQId}` and rewrites the file on first read.
### IDs are global, not per-set
`nextSetId` / `nextQId` are counters in the JSON. Question IDs are unique **across all sets**, which is why `findQuestion(qid)` scans every set and why the admin routes for questions are `/api/admin/questions/:qid` with no set in the path. Don't reintroduce per-set question numbering.
### Grading is client-side, and the quiz page works offline
`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.
`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.
### 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.
### 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.
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.
Rendering is asymmetric on purpose: question text (`q`) and explanations (`exp`) render through `v-html`; **options render as plain text** via `stripHtmlAndDecode()`. Options don't support markup.
### Difficulty levels
`lv` is an integer 16, validated server-side. Each set may carry `lvNames`, which must be exactly 6 non-empty strings (`validateLvNames`); sets without it fall back to `DEFAULT_LV_NAMES`. `QuizView` prepends an empty string to the array so `lvNames[lv]` indexes directly — the constant there is 7 elements, the one in `AdminView` and the API is 6. The `.seal-lv1``.seal-lv6` badge colors live in `web/src/styles/theme.css`.
### Routing and styling
`vue-router` uses **hash history**, so real URLs are `/#/` (quiz) and `/#/admin`. Set selection is a query param on the quiz route: `/#/quiz?set=9`. The startup banner in `server.js` still advertises `/admin.html`, which no longer exists.
`theme.css` is a global design system (CSS custom properties + `.card` / `.btn-*` / `.input` / `.seal` / `.table` classes). Component `<style>` blocks are `scoped` and only add what's specific to that view — reach for the existing tokens and classes before writing new ones.
### Legacy files
`index.html` at the repo root is the original standalone single-file version, with its 31 questions inlined as a JS array. Nothing serves it (Vite's root is `web/`, and the live entry is `web/index.html`). `scripts/extract-questions.js` exists only to parse that array out of it; running it overwrites `questions.json` with a single legacy set, discarding every set added since. Treat both as historical artifacts.
-42
View File
@@ -491,9 +491,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -588,9 +585,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -604,9 +598,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -620,9 +611,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -636,9 +624,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -652,9 +637,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -668,9 +650,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -684,9 +663,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -700,9 +676,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -716,9 +689,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -732,9 +702,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -748,9 +715,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -764,9 +728,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -780,9 +741,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
+406 -2
View File
@@ -1659,8 +1659,412 @@
"exp": "这是 Dart 3 的<b>解构</b>:把 Record <code>('张三', 18)</code> 按位置拆开,name='张三'、age=18。函数返回 Record 时配合解构取值非常简洁。" "exp": "这是 Dart 3 的<b>解构</b>:把 Record <code>('张三', 18)</code> 按位置拆开,name='张三'、age=18。函数返回 Record 时配合解构取值非常简洁。"
} }
] ]
},
{
"id": 12,
"name": "Flutter 基础巩固",
"questions": [
{
"id": 191,
"lv": 1,
"q": "新建 Flutter 项目后,日常编写页面代码的入口文件是哪一个?",
"opts": [
"lib/main.dart",
"bin/main.dart",
"web/index.html",
"pubspec.yaml"
],
"ans": 0,
"exp": "Flutter 约定业务代码都放在 <code>lib/</code> 目录下,入口文件是 <code>lib/main.dart</code><code>bin/</code> 是纯 Dart 命令行项目的约定目录,<code>pubspec.yaml</code> 是项目配置而不是代码。"
},
{
"id": 192,
"lv": 1,
"q": "要做一个“顶部有标题栏、中间是内容、右下角有悬浮按钮”的标准页面,最合适的骨架组件是?",
"opts": [
"Scaffold",
"Container",
"Column",
"MaterialApp"
],
"ans": 0,
"exp": "<code>Scaffold</code> 是 Material 页面骨架,直接提供 <code>appBar</code>、<code>body</code>、<code>floatingActionButton</code>、<code>bottomNavigationBar</code> 等插槽;<code>MaterialApp</code> 是整个 App 的根配置,一个 App 通常只需要一个。"
},
{
"id": 193,
"lv": 1,
"q": "<code>MaterialApp(home: LoginPage())</code> 中 <b>home</b> 参数的作用是?",
"opts": [
"指定 App 启动后显示的第一个页面",
"指定 App 在手机桌面上的图标",
"注册全部命名路由的映射表",
"设置按下返回键后回到的页面"
],
"ans": 0,
"exp": "<code>home</code> 就是应用的首页 Widget;注册命名路由用的是 <code>routes</code> 参数;App 图标要在各平台的原生工程里配置。"
},
{
"id": 194,
"lv": 1,
"q": "编辑器提示你把 <code>Text('登录')</code> 改写成 <code>const Text('登录')</code>,这样做的主要好处是?",
"opts": [
"该 Widget 在父组件重建时可复用同一个实例,减少重建开销",
"让这段文字变成只读,用户无法选中复制",
"只是编译更快,运行时没有任何区别",
"保证这个组件在页面中只会出现一次"
],
"ans": 0,
"exp": "<code>const</code> Widget 在编译期就确定下来,父组件的 <code>build</code> 重新执行时会直接复用同一个常量实例,不必重新创建,是 Flutter 里最廉价的性能优化,所以 lint 会主动提示你加上。"
},
{
"id": 195,
"lv": 1,
"q": "改完页面样式想立刻在模拟器上看到效果,并且保留当前页面已经填好的表单内容,应该用?",
"opts": [
"热重载 Hot Reload(按 r",
"热重启 Hot Restart(按 R",
"停止调试后重新执行 flutter run",
"先 flutter clean 再重新编译"
],
"ans": 0,
"exp": "Hot Reload 只把改动注入正在运行的应用并重建 Widget 树,<b>会保留 State</b>Hot Restart 会重新执行 <code>main()</code>,所有状态清零。改 UI 用前者,改 <code>main()</code>、全局变量或依赖时才需要后者。"
},
{
"id": 196,
"lv": 2,
"q": "想让一段文字四周都留出 16 像素的<b>内</b>边距,正确写法是?",
"opts": [
"Padding(padding: EdgeInsets.all(16), child: Text('内容'))",
"Container(margin: EdgeInsets.all(16), child: Text('内容'))",
"SizedBox(width: 16, height: 16, child: Text('内容'))",
"Text('内容', textAlign: TextAlign.center)"
],
"ans": 0,
"exp": "内边距用 <code>Padding</code> 搭配 <code>EdgeInsets</code>。<code>EdgeInsets.all(16)</code> 四边相同,<code>EdgeInsets.symmetric(horizontal: 16, vertical: 8)</code> 分别设置左右和上下,<code>EdgeInsets.only(left: 16)</code> 只设某一边。"
},
{
"id": 197,
"lv": 2,
"q": "<code>Container</code> 的 <b>padding</b> 和 <b>margin</b> 有什么区别?",
"opts": [
"padding 是内容到自身边界的内边距,margin 是自身到外部的外边距",
"两者完全等价,只是写法不同",
"padding 只对文字生效,margin 只对图片生效",
"padding 会撑大父组件,margin 会撑大子组件"
],
"ans": 0,
"exp": "记忆方式:<b>padding 撑开里面,margin 推开外面</b>。给卡片内容留白用 padding,让卡片之间拉开距离用 margin。"
},
{
"id": 198,
"lv": 2,
"q": "只是想在两个按钮之间加 12 像素的垂直间距,最轻量的写法是?",
"opts": [
"SizedBox(height: 12)",
"Container(height: 12, color: Colors.transparent)",
"Padding(padding: EdgeInsets.all(12))",
"Divider(height: 12)"
],
"ans": 0,
"exp": "<code>SizedBox</code> 只占位、不绘制,是做间距的首选;<code>Container</code> 功能多也更重;<code>Divider</code> 会额外画出一条分割线,语义不同。"
},
{
"id": 199,
"lv": 2,
"q": "页面上要展示一排数量不固定的标签,希望一行放不下时自动折到下一行,应该用?",
"opts": [
"Wrap",
"Row",
"Column",
"Expanded"
],
"ans": 0,
"exp": "<code>Row</code> 是单行布局,放不下就会溢出报警告;<code>Wrap</code> 在主轴空间不足时自动换行,再配 <code>spacing</code>(同行间距)和 <code>runSpacing</code>(行与行间距)即可。"
},
{
"id": 200,
"lv": 2,
"q": "页面顶部内容被状态栏/刘海挡住、底部被手势条挡住,最稳妥的处理是?",
"opts": [
"在最外层套一个 SafeArea",
"给最外层写死 EdgeInsets.only(top: 44)",
"把页面改成 StatefulWidget",
"用 Center 把内容整体居中"
],
"ans": 0,
"exp": "<code>SafeArea</code> 会读取系统的安全区信息,自动补上合适的内边距,各机型自适应;写死 44 像素换一台设备就会错位。"
},
{
"id": 201,
"lv": 3,
"q": "页面上放一个“提交”按钮并响应点击,标准写法是?",
"opts": [
"ElevatedButton(onPressed: () { ... }, child: Text('提交'))",
"ElevatedButton(child: Text('提交'), onClick: () { ... })",
"Button(text: '提交', onTap: () { ... })",
"ElevatedButton(onPressed: '提交')"
],
"ans": 0,
"exp": "Flutter 按钮的点击回调统一叫 <code>onPressed</code>,接收一个无参函数。常用的三种:<code>ElevatedButton</code>(填充)、<code>TextButton</code>(纯文字)、<code>OutlinedButton</code>(描边)。"
},
{
"id": 202,
"lv": 3,
"q": "表单没填完时,希望“提交”按钮自动变灰且点不动,最简单的做法是?",
"opts": [
"把 onPressed 设为 null",
"把 child 设为 null",
"用 Visibility 把按钮整个隐藏",
"外面包一层 IgnorePointer 并手动改颜色"
],
"ans": 0,
"exp": "Flutter 按钮的约定是:<code>onPressed == null</code> 即为禁用状态,会自动切换成灰色的 disabled 样式。所以常写成 <code>onPressed: canSubmit ? _submit : null</code>。"
},
{
"id": 203,
"lv": 3,
"q": "要做“左边头像、中间标题加副标题、右边一个箭头”的列表行,最省事的组件是?",
"opts": [
"ListTile",
"Row",
"Card",
"Column"
],
"ans": 0,
"exp": "<code>ListTile</code> 直接提供 <code>leading</code>、<code>title</code>、<code>subtitle</code>、<code>trailing</code> 四个插槽,还内置了 <code>onTap</code> 点击和标准的高度与间距,省去手写 Row/Column 对齐。"
},
{
"id": 204,
"lv": 3,
"q": "想在搜索框前面放一个放大镜图标,写法是?",
"opts": [
"Icon(Icons.search)",
"Image(Icons.search)",
"Text(Icons.search)",
"IconData('search')"
],
"ans": 0,
"exp": "<code>Icon</code> 负责渲染,<code>Icons</code> 是 Material 内置的图标常量集合(<code>Icons.search</code>、<code>Icons.add</code>…)。如果图标本身要能点击,用 <code>IconButton</code>。"
},
{
"id": 205,
"lv": 3,
"q": "商品封面图要显示成圆角矩形,标准做法是?",
"opts": [
"用 ClipRRect(borderRadius: BorderRadius.circular(8)) 把图片包起来",
"给 Image 设置 borderRadius 参数",
"把 Image 的 fit 设为 BoxFit.cover",
"用 Opacity 把四个角变透明"
],
"ans": 0,
"exp": "<code>Image</code> 本身没有圆角参数,需要靠外层裁剪:圆角矩形用 <code>ClipRRect</code>,正圆用 <code>ClipOval</code> 或 <code>CircleAvatar</code>;也可以用 <code>Container</code> 的 <code>decoration: BoxDecoration(borderRadius: ...)</code> 实现。"
},
{
"id": 206,
"lv": 4,
"q": "把设计给的 logo.png 放进项目,想用 <code>Image.asset('assets/logo.png')</code> 显示,还必须做的一步是?",
"opts": [
"在 pubspec.yaml 的 flutter -&gt; assets 下声明这个路径",
"在 main.dart 里 import 这张图片",
"把图片改名成 logo.dart",
"在 AndroidManifest.xml 里注册这张图片"
],
"ans": 0,
"exp": "本地资源必须先在 <code>pubspec.yaml</code> 里声明才会被打包,在 <code>flutter:</code> 节点下写 <code>assets:</code> 再列出 <code>- assets/logo.png</code>;只写目录 <code>- assets/</code> 可以一次声明整个文件夹。注意改完 pubspec 要重新运行,Hot Reload 不会重新打包资源。"
},
{
"id": 207,
"lv": 4,
"q": "<code>Image.asset</code> 和 <code>Image.network</code> 的区别是?",
"opts": [
"asset 加载随 App 打包的本地资源,network 通过 URL 加载网络图片",
"asset 加载网络图片,network 加载本地图片",
"两者完全一样,只是命名习惯不同",
"asset 只能加载 pngnetwork 只能加载 jpg"
],
"ans": 0,
"exp": "<code>Image.asset('assets/logo.png')</code> 读取打包进 App 的本地资源;<code>Image.network('https://...')</code> 运行时才下载,因此通常还要配 <code>loadingBuilder</code> 做占位、<code>errorBuilder</code> 做兜底。"
},
{
"id": 208,
"lv": 4,
"q": "要把一段文字设置成 18 号、加粗、灰色,正确写法是?",
"opts": [
"Text('标题', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey))",
"Text('标题', fontSize: 18, bold: true, color: Colors.grey)",
"Text('标题', css: 'font-size:18px;font-weight:bold')",
"TextStyle('标题', size: 18, bold: true)"
],
"ans": 0,
"exp": "文字样式统一放在 <code>Text</code> 的 <code>style</code> 参数里,类型是 <code>TextStyle</code>。想在主题字号的基础上微调,用 <code>Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey)</code>。"
},
{
"id": 209,
"lv": 4,
"q": "项目里多个页面都要用同一个主色调,最推荐的取色方式是?",
"opts": [
"Theme.of(context).colorScheme.primary",
"每个页面各自写死 Color(0xFF3D6B5E)",
"定义一个全局可变变量,谁都能改",
"每次从设计稿图片里取色"
],
"ans": 0,
"exp": "颜色、字体等统一配置在 <code>MaterialApp(theme: ThemeData(...))</code>,页面里通过 <code>Theme.of(context)</code> 取用。这样换肤、适配深色模式只需要改一处。顺带一提,Flutter 的颜色是 8 位 ARGB<code>0xFF</code> 开头才是不透明。"
},
{
"id": 210,
"lv": 4,
"q": "要让卡片宽度占屏幕宽度的 80%,获取屏幕宽度的写法是?",
"opts": [
"MediaQuery.of(context).size.width",
"context.width",
"Scaffold.of(context).width",
"Window.screenWidth"
],
"ans": 0,
"exp": "<code>MediaQuery</code> 提供屏幕尺寸、状态栏高度、字体缩放比等信息,依赖 <code>context</code>,只能在 <code>build</code> 里或拿到 context 之后使用。如果想按<b>父容器</b>而不是屏幕的尺寸来布局,用 <code>LayoutBuilder</code>。"
},
{
"id": 211,
"lv": 5,
"q": "想让整张商品卡片都能点击,并且点下去有 Material 水波纹反馈,应该用?",
"opts": [
"InkWell",
"GestureDetector",
"Container",
"AbsorbPointer"
],
"ans": 0,
"exp": "<code>GestureDetector</code> 也能响应点击,但没有任何视觉反馈;<code>InkWell</code> 会在点击时绘制水波纹,更符合 Material 交互习惯。注意水波纹画在 <code>Material</code> 上,所以 InkWell 上层要有 Material 背景才看得见。"
},
{
"id": 212,
"lv": 5,
"q": "保存成功后,想在页面底部弹出一条“保存成功”的短提示,写法是?",
"opts": [
"ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存成功')))",
"showDialog(context: context, builder: ...)",
"print('保存成功')",
"Navigator.push 跳到一个提示页"
],
"ans": 0,
"exp": "底部短提示用 <code>SnackBar</code>,通过 <code>ScaffoldMessenger.of(context)</code> 弹出(旧写法 <code>Scaffold.of(context).showSnackBar</code> 已废弃)。它会自动消失,不打断用户当前操作。"
},
{
"id": 213,
"lv": 5,
"q": "点“删除”后要弹确认框,并根据用户点的是“确定”还是“取消”决定是否真的删除,正确写法是?",
"opts": [
"final ok = await showDialog&lt;bool&gt;(...); if (ok == true) { 执行删除 }",
"showDialog(...); 紧接着直接执行删除",
"用 SnackBar 代替确认框",
"在 build 方法里判断用户点了什么"
],
"ans": 0,
"exp": "<code>showDialog</code> 返回一个 <code>Future</code>,对话框里用 <code>Navigator.pop(context, true)</code> 把结果带回来,外面 <code>await</code> 拿到后再决定后续动作。注意用户点遮罩关闭时结果是 <code>null</code>,所以要判断 <code>== true</code> 而不是直接当布尔用。"
},
{
"id": 214,
"lv": 5,
"q": "点“选择城市”后,希望从屏幕底部滑出一个选项面板,应该用?",
"opts": [
"showModalBottomSheet",
"showDialog",
"Navigator.pop",
"BottomNavigationBar"
],
"ans": 0,
"exp": "<code>showModalBottomSheet</code> 从底部滑出模态面板,同样返回 <code>Future</code>,面板里用 <code>Navigator.pop(context, value)</code> 回传用户的选择;<code>BottomNavigationBar</code> 是常驻的底部标签栏,用途完全不同。"
},
{
"id": 215,
"lv": 5,
"q": "App 底部要有“首页 / 分类 / 我的”三个标签来回切换,正确做法是?",
"opts": [
"Scaffold 的 bottomNavigationBar 放 BottomNavigationBar,用 currentIndex 记录选中项,onTap 里 setState",
"每点一个标签就 Navigator.push 一个新页面",
"用三个 Column 上下排列,点谁显示谁",
"在 MaterialApp 里配置三个 home"
],
"ans": 0,
"exp": "标签栏切换的是同一个页面里的内容,用 <code>currentIndex</code> + <code>onTap</code> 改状态即可;如果每次都 <code>push</code> 新页面,返回栈会越堆越深。希望切回来时保留各标签的滚动位置,可以配合 <code>IndexedStack</code>。"
},
{
"id": 216,
"lv": 6,
"q": "登录表单要一次性校验“手机号”和“密码”两个输入框,官方推荐做法是?",
"opts": [
"用 Form 包住输入框,配一个 GlobalKey&lt;FormState&gt;,提交时调用 _formKey.currentState!.validate()",
"在每个 TextField 的 onChanged 里手写 if 判断",
"提交时挨个读 controller.text 用 if 判断",
"前端不校验,全部交给服务端返回错误"
],
"ans": 0,
"exp": "<code>Form</code> + <code>GlobalKey&lt;FormState&gt;</code> 是官方表单方案:<code>validate()</code> 会依次触发每个 <code>TextFormField</code> 的 <code>validator</code><b>全部通过才返回 true</b>,并自动把错误文案显示在对应输入框下方。"
},
{
"id": 217,
"lv": 6,
"q": "<code>TextFormField</code> 的 <code>validator</code> 回调中,返回 <code>null</code> 表示什么?",
"opts": [
"校验通过,不显示任何错误提示",
"校验失败,但不提示用户",
"清空输入框里的内容",
"该字段不参与校验"
],
"ans": 0,
"exp": "<code>validator</code> 的约定是:返回 <code>null</code> 代表通过;返回一个字符串代表失败,且这个字符串就是显示在输入框下方的错误文案。例如 <code>validator: (v) =&gt; (v == null || v.isEmpty) ? '请输入手机号' : null</code>。"
},
{
"id": 218,
"lv": 6,
"q": "进入页面时要立刻发一次网络请求拿数据,这段代码应该写在 State 的哪个方法里?",
"opts": [
"initState()",
"build()",
"dispose()",
"setState()"
],
"ans": 0,
"exp": "<code>initState()</code> 在 State 创建后只执行一次,适合做初始化:发首次请求、创建 Controller、注册监听。<code>build()</code> 可能被调用很多次,把请求写在里面会反复触发;<code>dispose()</code> 是销毁时做清理的地方。"
},
{
"id": 219,
"lv": 6,
"q": "页面数据来自一次异步请求,要根据“加载中 / 成功 / 失败”分别渲染不同界面,最省事的组件是?",
"opts": [
"FutureBuilder",
"ListView.builder",
"StatelessWidget",
"AnimatedBuilder"
],
"ans": 0,
"exp": "<code>FutureBuilder</code> 接收一个 <code>Future</code>,在 <code>builder</code> 里根据 <code>snapshot.connectionState</code> 和 <code>snapshot.hasError</code> 分支渲染,不用自己维护一堆 isLoading 字段。<b>坑</b>:不要把 <code>future: fetchData()</code> 直接写在 build 里,那样每次重建都会重新请求,应在 <code>initState</code> 里先创建好再传进去。持续变化的数据流则用 <code>StreamBuilder</code>。"
},
{
"id": 220,
"lv": 6,
"q": "异步请求 <code>await</code> 回来后要调用 <code>setState</code>,为什么通常先判断一下 <code>mounted</code>",
"opts": [
"请求返回时页面可能已被关闭,对已销毁的 State 调用 setState 会报错",
"mounted 是用来判断网络是否连通的",
"不判断的话请求会被系统取消",
"判断 mounted 能让请求返回得更快"
],
"ans": 0,
"exp": "<code>await</code> 期间用户可能已经返回上一页,State 被销毁。此时再 <code>setState</code> 会抛出 setState() called after dispose()。标准写法是拿到结果后先 <code>if (!mounted) return;</code>,再更新状态。"
} }
], ],
"nextSetId": 12, "lvNames": [
"nextQId": 191 "项目与骨架",
"布局与间距",
"常用组件",
"资源与样式",
"交互与反馈",
"表单与异步"
]
}
],
"nextSetId": 13,
"nextQId": 221
} }
+373
View File
@@ -0,0 +1,373 @@
{
"name": "Flutter 基础巩固",
"lvNames": [
"项目与骨架",
"布局与间距",
"常用组件",
"资源与样式",
"交互与反馈",
"表单与异步"
],
"questions": [
{
"lv": 1,
"q": "新建 Flutter 项目后,日常编写页面代码的入口文件是哪一个?",
"opts": [
"lib/main.dart",
"bin/main.dart",
"web/index.html",
"pubspec.yaml"
],
"ans": 0,
"exp": "Flutter 约定业务代码都放在 <code>lib/</code> 目录下,入口文件是 <code>lib/main.dart</code><code>bin/</code> 是纯 Dart 命令行项目的约定目录,<code>pubspec.yaml</code> 是项目配置而不是代码。"
},
{
"lv": 1,
"q": "要做一个“顶部有标题栏、中间是内容、右下角有悬浮按钮”的标准页面,最合适的骨架组件是?",
"opts": [
"Scaffold",
"Container",
"Column",
"MaterialApp"
],
"ans": 0,
"exp": "<code>Scaffold</code> 是 Material 页面骨架,直接提供 <code>appBar</code>、<code>body</code>、<code>floatingActionButton</code>、<code>bottomNavigationBar</code> 等插槽;<code>MaterialApp</code> 是整个 App 的根配置,一个 App 通常只需要一个。"
},
{
"lv": 1,
"q": "<code>MaterialApp(home: LoginPage())</code> 中 <b>home</b> 参数的作用是?",
"opts": [
"指定 App 启动后显示的第一个页面",
"指定 App 在手机桌面上的图标",
"注册全部命名路由的映射表",
"设置按下返回键后回到的页面"
],
"ans": 0,
"exp": "<code>home</code> 就是应用的首页 Widget;注册命名路由用的是 <code>routes</code> 参数;App 图标要在各平台的原生工程里配置。"
},
{
"lv": 1,
"q": "编辑器提示你把 <code>Text('登录')</code> 改写成 <code>const Text('登录')</code>,这样做的主要好处是?",
"opts": [
"该 Widget 在父组件重建时可复用同一个实例,减少重建开销",
"让这段文字变成只读,用户无法选中复制",
"只是编译更快,运行时没有任何区别",
"保证这个组件在页面中只会出现一次"
],
"ans": 0,
"exp": "<code>const</code> Widget 在编译期就确定下来,父组件的 <code>build</code> 重新执行时会直接复用同一个常量实例,不必重新创建,是 Flutter 里最廉价的性能优化,所以 lint 会主动提示你加上。"
},
{
"lv": 1,
"q": "改完页面样式想立刻在模拟器上看到效果,并且保留当前页面已经填好的表单内容,应该用?",
"opts": [
"热重载 Hot Reload(按 r",
"热重启 Hot Restart(按 R",
"停止调试后重新执行 flutter run",
"先 flutter clean 再重新编译"
],
"ans": 0,
"exp": "Hot Reload 只把改动注入正在运行的应用并重建 Widget 树,<b>会保留 State</b>Hot Restart 会重新执行 <code>main()</code>,所有状态清零。改 UI 用前者,改 <code>main()</code>、全局变量或依赖时才需要后者。"
},
{
"lv": 2,
"q": "想让一段文字四周都留出 16 像素的<b>内</b>边距,正确写法是?",
"opts": [
"Padding(padding: EdgeInsets.all(16), child: Text('内容'))",
"Container(margin: EdgeInsets.all(16), child: Text('内容'))",
"SizedBox(width: 16, height: 16, child: Text('内容'))",
"Text('内容', textAlign: TextAlign.center)"
],
"ans": 0,
"exp": "内边距用 <code>Padding</code> 搭配 <code>EdgeInsets</code>。<code>EdgeInsets.all(16)</code> 四边相同,<code>EdgeInsets.symmetric(horizontal: 16, vertical: 8)</code> 分别设置左右和上下,<code>EdgeInsets.only(left: 16)</code> 只设某一边。"
},
{
"lv": 2,
"q": "<code>Container</code> 的 <b>padding</b> 和 <b>margin</b> 有什么区别?",
"opts": [
"padding 是内容到自身边界的内边距,margin 是自身到外部的外边距",
"两者完全等价,只是写法不同",
"padding 只对文字生效,margin 只对图片生效",
"padding 会撑大父组件,margin 会撑大子组件"
],
"ans": 0,
"exp": "记忆方式:<b>padding 撑开里面,margin 推开外面</b>。给卡片内容留白用 padding,让卡片之间拉开距离用 margin。"
},
{
"lv": 2,
"q": "只是想在两个按钮之间加 12 像素的垂直间距,最轻量的写法是?",
"opts": [
"SizedBox(height: 12)",
"Container(height: 12, color: Colors.transparent)",
"Padding(padding: EdgeInsets.all(12))",
"Divider(height: 12)"
],
"ans": 0,
"exp": "<code>SizedBox</code> 只占位、不绘制,是做间距的首选;<code>Container</code> 功能多也更重;<code>Divider</code> 会额外画出一条分割线,语义不同。"
},
{
"lv": 2,
"q": "页面上要展示一排数量不固定的标签,希望一行放不下时自动折到下一行,应该用?",
"opts": [
"Wrap",
"Row",
"Column",
"Expanded"
],
"ans": 0,
"exp": "<code>Row</code> 是单行布局,放不下就会溢出报警告;<code>Wrap</code> 在主轴空间不足时自动换行,再配 <code>spacing</code>(同行间距)和 <code>runSpacing</code>(行与行间距)即可。"
},
{
"lv": 2,
"q": "页面顶部内容被状态栏/刘海挡住、底部被手势条挡住,最稳妥的处理是?",
"opts": [
"在最外层套一个 SafeArea",
"给最外层写死 EdgeInsets.only(top: 44)",
"把页面改成 StatefulWidget",
"用 Center 把内容整体居中"
],
"ans": 0,
"exp": "<code>SafeArea</code> 会读取系统的安全区信息,自动补上合适的内边距,各机型自适应;写死 44 像素换一台设备就会错位。"
},
{
"lv": 3,
"q": "页面上放一个“提交”按钮并响应点击,标准写法是?",
"opts": [
"ElevatedButton(onPressed: () { ... }, child: Text('提交'))",
"ElevatedButton(child: Text('提交'), onClick: () { ... })",
"Button(text: '提交', onTap: () { ... })",
"ElevatedButton(onPressed: '提交')"
],
"ans": 0,
"exp": "Flutter 按钮的点击回调统一叫 <code>onPressed</code>,接收一个无参函数。常用的三种:<code>ElevatedButton</code>(填充)、<code>TextButton</code>(纯文字)、<code>OutlinedButton</code>(描边)。"
},
{
"lv": 3,
"q": "表单没填完时,希望“提交”按钮自动变灰且点不动,最简单的做法是?",
"opts": [
"把 onPressed 设为 null",
"把 child 设为 null",
"用 Visibility 把按钮整个隐藏",
"外面包一层 IgnorePointer 并手动改颜色"
],
"ans": 0,
"exp": "Flutter 按钮的约定是:<code>onPressed == null</code> 即为禁用状态,会自动切换成灰色的 disabled 样式。所以常写成 <code>onPressed: canSubmit ? _submit : null</code>。"
},
{
"lv": 3,
"q": "要做“左边头像、中间标题加副标题、右边一个箭头”的列表行,最省事的组件是?",
"opts": [
"ListTile",
"Row",
"Card",
"Column"
],
"ans": 0,
"exp": "<code>ListTile</code> 直接提供 <code>leading</code>、<code>title</code>、<code>subtitle</code>、<code>trailing</code> 四个插槽,还内置了 <code>onTap</code> 点击和标准的高度与间距,省去手写 Row/Column 对齐。"
},
{
"lv": 3,
"q": "想在搜索框前面放一个放大镜图标,写法是?",
"opts": [
"Icon(Icons.search)",
"Image(Icons.search)",
"Text(Icons.search)",
"IconData('search')"
],
"ans": 0,
"exp": "<code>Icon</code> 负责渲染,<code>Icons</code> 是 Material 内置的图标常量集合(<code>Icons.search</code>、<code>Icons.add</code>…)。如果图标本身要能点击,用 <code>IconButton</code>。"
},
{
"lv": 3,
"q": "商品封面图要显示成圆角矩形,标准做法是?",
"opts": [
"用 ClipRRect(borderRadius: BorderRadius.circular(8)) 把图片包起来",
"给 Image 设置 borderRadius 参数",
"把 Image 的 fit 设为 BoxFit.cover",
"用 Opacity 把四个角变透明"
],
"ans": 0,
"exp": "<code>Image</code> 本身没有圆角参数,需要靠外层裁剪:圆角矩形用 <code>ClipRRect</code>,正圆用 <code>ClipOval</code> 或 <code>CircleAvatar</code>;也可以用 <code>Container</code> 的 <code>decoration: BoxDecoration(borderRadius: ...)</code> 实现。"
},
{
"lv": 4,
"q": "把设计给的 logo.png 放进项目,想用 <code>Image.asset('assets/logo.png')</code> 显示,还必须做的一步是?",
"opts": [
"在 pubspec.yaml 的 flutter -&gt; assets 下声明这个路径",
"在 main.dart 里 import 这张图片",
"把图片改名成 logo.dart",
"在 AndroidManifest.xml 里注册这张图片"
],
"ans": 0,
"exp": "本地资源必须先在 <code>pubspec.yaml</code> 里声明才会被打包,在 <code>flutter:</code> 节点下写 <code>assets:</code> 再列出 <code>- assets/logo.png</code>;只写目录 <code>- assets/</code> 可以一次声明整个文件夹。注意改完 pubspec 要重新运行,Hot Reload 不会重新打包资源。"
},
{
"lv": 4,
"q": "<code>Image.asset</code> 和 <code>Image.network</code> 的区别是?",
"opts": [
"asset 加载随 App 打包的本地资源,network 通过 URL 加载网络图片",
"asset 加载网络图片,network 加载本地图片",
"两者完全一样,只是命名习惯不同",
"asset 只能加载 pngnetwork 只能加载 jpg"
],
"ans": 0,
"exp": "<code>Image.asset('assets/logo.png')</code> 读取打包进 App 的本地资源;<code>Image.network('https://...')</code> 运行时才下载,因此通常还要配 <code>loadingBuilder</code> 做占位、<code>errorBuilder</code> 做兜底。"
},
{
"lv": 4,
"q": "要把一段文字设置成 18 号、加粗、灰色,正确写法是?",
"opts": [
"Text('标题', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey))",
"Text('标题', fontSize: 18, bold: true, color: Colors.grey)",
"Text('标题', css: 'font-size:18px;font-weight:bold')",
"TextStyle('标题', size: 18, bold: true)"
],
"ans": 0,
"exp": "文字样式统一放在 <code>Text</code> 的 <code>style</code> 参数里,类型是 <code>TextStyle</code>。想在主题字号的基础上微调,用 <code>Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey)</code>。"
},
{
"lv": 4,
"q": "项目里多个页面都要用同一个主色调,最推荐的取色方式是?",
"opts": [
"Theme.of(context).colorScheme.primary",
"每个页面各自写死 Color(0xFF3D6B5E)",
"定义一个全局可变变量,谁都能改",
"每次从设计稿图片里取色"
],
"ans": 0,
"exp": "颜色、字体等统一配置在 <code>MaterialApp(theme: ThemeData(...))</code>,页面里通过 <code>Theme.of(context)</code> 取用。这样换肤、适配深色模式只需要改一处。顺带一提,Flutter 的颜色是 8 位 ARGB<code>0xFF</code> 开头才是不透明。"
},
{
"lv": 4,
"q": "要让卡片宽度占屏幕宽度的 80%,获取屏幕宽度的写法是?",
"opts": [
"MediaQuery.of(context).size.width",
"context.width",
"Scaffold.of(context).width",
"Window.screenWidth"
],
"ans": 0,
"exp": "<code>MediaQuery</code> 提供屏幕尺寸、状态栏高度、字体缩放比等信息,依赖 <code>context</code>,只能在 <code>build</code> 里或拿到 context 之后使用。如果想按<b>父容器</b>而不是屏幕的尺寸来布局,用 <code>LayoutBuilder</code>。"
},
{
"lv": 5,
"q": "想让整张商品卡片都能点击,并且点下去有 Material 水波纹反馈,应该用?",
"opts": [
"InkWell",
"GestureDetector",
"Container",
"AbsorbPointer"
],
"ans": 0,
"exp": "<code>GestureDetector</code> 也能响应点击,但没有任何视觉反馈;<code>InkWell</code> 会在点击时绘制水波纹,更符合 Material 交互习惯。注意水波纹画在 <code>Material</code> 上,所以 InkWell 上层要有 Material 背景才看得见。"
},
{
"lv": 5,
"q": "保存成功后,想在页面底部弹出一条“保存成功”的短提示,写法是?",
"opts": [
"ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存成功')))",
"showDialog(context: context, builder: ...)",
"print('保存成功')",
"Navigator.push 跳到一个提示页"
],
"ans": 0,
"exp": "底部短提示用 <code>SnackBar</code>,通过 <code>ScaffoldMessenger.of(context)</code> 弹出(旧写法 <code>Scaffold.of(context).showSnackBar</code> 已废弃)。它会自动消失,不打断用户当前操作。"
},
{
"lv": 5,
"q": "点“删除”后要弹确认框,并根据用户点的是“确定”还是“取消”决定是否真的删除,正确写法是?",
"opts": [
"final ok = await showDialog&lt;bool&gt;(...); if (ok == true) { 执行删除 }",
"showDialog(...); 紧接着直接执行删除",
"用 SnackBar 代替确认框",
"在 build 方法里判断用户点了什么"
],
"ans": 0,
"exp": "<code>showDialog</code> 返回一个 <code>Future</code>,对话框里用 <code>Navigator.pop(context, true)</code> 把结果带回来,外面 <code>await</code> 拿到后再决定后续动作。注意用户点遮罩关闭时结果是 <code>null</code>,所以要判断 <code>== true</code> 而不是直接当布尔用。"
},
{
"lv": 5,
"q": "点“选择城市”后,希望从屏幕底部滑出一个选项面板,应该用?",
"opts": [
"showModalBottomSheet",
"showDialog",
"Navigator.pop",
"BottomNavigationBar"
],
"ans": 0,
"exp": "<code>showModalBottomSheet</code> 从底部滑出模态面板,同样返回 <code>Future</code>,面板里用 <code>Navigator.pop(context, value)</code> 回传用户的选择;<code>BottomNavigationBar</code> 是常驻的底部标签栏,用途完全不同。"
},
{
"lv": 5,
"q": "App 底部要有“首页 / 分类 / 我的”三个标签来回切换,正确做法是?",
"opts": [
"Scaffold 的 bottomNavigationBar 放 BottomNavigationBar,用 currentIndex 记录选中项,onTap 里 setState",
"每点一个标签就 Navigator.push 一个新页面",
"用三个 Column 上下排列,点谁显示谁",
"在 MaterialApp 里配置三个 home"
],
"ans": 0,
"exp": "标签栏切换的是同一个页面里的内容,用 <code>currentIndex</code> + <code>onTap</code> 改状态即可;如果每次都 <code>push</code> 新页面,返回栈会越堆越深。希望切回来时保留各标签的滚动位置,可以配合 <code>IndexedStack</code>。"
},
{
"lv": 6,
"q": "登录表单要一次性校验“手机号”和“密码”两个输入框,官方推荐做法是?",
"opts": [
"用 Form 包住输入框,配一个 GlobalKey&lt;FormState&gt;,提交时调用 _formKey.currentState!.validate()",
"在每个 TextField 的 onChanged 里手写 if 判断",
"提交时挨个读 controller.text 用 if 判断",
"前端不校验,全部交给服务端返回错误"
],
"ans": 0,
"exp": "<code>Form</code> + <code>GlobalKey&lt;FormState&gt;</code> 是官方表单方案:<code>validate()</code> 会依次触发每个 <code>TextFormField</code> 的 <code>validator</code><b>全部通过才返回 true</b>,并自动把错误文案显示在对应输入框下方。"
},
{
"lv": 6,
"q": "<code>TextFormField</code> 的 <code>validator</code> 回调中,返回 <code>null</code> 表示什么?",
"opts": [
"校验通过,不显示任何错误提示",
"校验失败,但不提示用户",
"清空输入框里的内容",
"该字段不参与校验"
],
"ans": 0,
"exp": "<code>validator</code> 的约定是:返回 <code>null</code> 代表通过;返回一个字符串代表失败,且这个字符串就是显示在输入框下方的错误文案。例如 <code>validator: (v) =&gt; (v == null || v.isEmpty) ? '请输入手机号' : null</code>。"
},
{
"lv": 6,
"q": "进入页面时要立刻发一次网络请求拿数据,这段代码应该写在 State 的哪个方法里?",
"opts": [
"initState()",
"build()",
"dispose()",
"setState()"
],
"ans": 0,
"exp": "<code>initState()</code> 在 State 创建后只执行一次,适合做初始化:发首次请求、创建 Controller、注册监听。<code>build()</code> 可能被调用很多次,把请求写在里面会反复触发;<code>dispose()</code> 是销毁时做清理的地方。"
},
{
"lv": 6,
"q": "页面数据来自一次异步请求,要根据“加载中 / 成功 / 失败”分别渲染不同界面,最省事的组件是?",
"opts": [
"FutureBuilder",
"ListView.builder",
"StatelessWidget",
"AnimatedBuilder"
],
"ans": 0,
"exp": "<code>FutureBuilder</code> 接收一个 <code>Future</code>,在 <code>builder</code> 里根据 <code>snapshot.connectionState</code> 和 <code>snapshot.hasError</code> 分支渲染,不用自己维护一堆 isLoading 字段。<b>坑</b>:不要把 <code>future: fetchData()</code> 直接写在 build 里,那样每次重建都会重新请求,应在 <code>initState</code> 里先创建好再传进去。持续变化的数据流则用 <code>StreamBuilder</code>。"
},
{
"lv": 6,
"q": "异步请求 <code>await</code> 回来后要调用 <code>setState</code>,为什么通常先判断一下 <code>mounted</code>",
"opts": [
"请求返回时页面可能已被关闭,对已销毁的 State 调用 setState 会报错",
"mounted 是用来判断网络是否连通的",
"不判断的话请求会被系统取消",
"判断 mounted 能让请求返回得更快"
],
"ans": 0,
"exp": "<code>await</code> 期间用户可能已经返回上一页,State 被销毁。此时再 <code>setState</code> 会抛出 setState() called after dispose()。标准写法是拿到结果后先 <code>if (!mounted) return;</code>,再更新状态。"
}
]
}
+2 -1
View File
@@ -2,7 +2,8 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const BASE = 'http://localhost:3030'; const BASE = 'http://localhost:3030';
const FILES = ['set-dart-dev.json', 'set-flutter-dev.json']; const ARGS = process.argv.slice(2);
const FILES = ARGS.length ? ARGS : ['set-dart-dev.json', 'set-flutter-dev.json'];
async function main() { async function main() {
const loginRes = await fetch(BASE + '/api/admin/login', { const loginRes = await fetch(BASE + '/api/admin/login', {
+3 -1
View File
@@ -58,8 +58,10 @@ function findQuestion(qid) {
return null; return null;
} }
// 答题页需要整套缓存以支持离线判分,因此下发答案与解析。
// 保留显式白名单:以后给题目加内部字段(如出题备注)不会顺手漏出去。
function toPublic(q) { function toPublic(q) {
return { id: q.id, lv: q.lv, q: q.q, opts: q.opts }; return { id: q.id, lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp };
} }
function validateBody(body) { function validateBody(body) {
+5 -1
View File
@@ -6,7 +6,11 @@
<title>砚 · Dart 测验</title> <title>砚 · Dart 测验</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"> <!-- 非阻塞加载:先用 media="print" 让浏览器不把它当渲染阻塞资源,下载完成后再切回 all。
国内常连不上 fonts.googleapis.com,若按默认方式引入会把首屏一直卡到请求超时;
这样写则首屏立即绘制,先用 theme.css 里的本地回退字体(宋体/苹方/雅黑),字体到了再换上。 -->
<link rel="stylesheet" media="print" onload="this.media='all'"
href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+11 -3
View File
@@ -256,14 +256,22 @@ body {
.table tbody tr:hover { background: var(--surface-alt); } .table tbody tr:hover { background: var(--surface-alt); }
/* ---------- 过渡动画 ---------- */ /* ---------- 过渡动画 ---------- */
/* 每组都必须 enter / leave 成对定义。缺了 leave 时,Vue 会去读元素上
残留的 animation 时长来决定等多久,而那个 animation 早已播完、不会再触发
animationend,于是只能干等满兜底 timeout —— 表现就是切换时“卡一下”。
同理,元素自身不要再挂 animation,入场交给这里的 transition 负责。 */
.fade-enter-active, .fade-leave-active { transition: opacity .25s ease; } .fade-enter-active, .fade-leave-active { transition: opacity .25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active { transition: all .3s ease; } .slide-enter-active { transition: opacity .2s ease, transform .2s ease; }
.slide-enter-from { opacity: 0; transform: translateY(14px); } .slide-leave-active { transition: opacity .12s ease, transform .12s ease; }
.slide-enter-from { opacity: 0; transform: translateY(12px); }
.slide-leave-to { opacity: 0; transform: translateY(-8px); }
.pop-enter-active { transition: all .35s cubic-bezier(.2, .9, .3, 1.3); } .pop-enter-active { transition: opacity .28s ease, transform .28s cubic-bezier(.2, .9, .3, 1.3); }
.pop-leave-active { transition: opacity .12s ease, transform .12s ease; }
.pop-enter-from { opacity: 0; transform: scale(.92); } .pop-enter-from { opacity: 0; transform: scale(.92); }
.pop-leave-to { opacity: 0; transform: scale(.96); }
@keyframes fadeUp { @keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); } from { opacity: 0; transform: translateY(16px); }
+5 -2
View File
@@ -20,6 +20,7 @@ const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面
const setForm = reactive({ show: false, editingId: null, title: '', name: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' }) const setForm = reactive({ show: false, editingId: null, title: '', name: '', 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 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: ''
@@ -164,6 +165,8 @@ function onImportFile(e) {
importForm.msg = '' importForm.msg = ''
} }
reader.readAsText(file, 'utf-8') reader.readAsText(file, 'utf-8')
// 清空 value:否则再次选择同一个文件时 value 未变、不会触发 change,按钮看起来像失灵
e.target.value = ''
} }
async function doImport() { async function doImport() {
@@ -406,8 +409,8 @@ onMounted(async () => {
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</div> <div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</div>
<div class="import-zone"> <div class="import-zone">
<div> <div>
<input type="file" accept=".json,application/json" style="display:none;" :id="'importFile'" @change="onImportFile"> <input ref="importFileRef" type="file" accept=".json,application/json" style="display:none;" @change="onImportFile">
<button class="btn btn-outline btn-sm" @click="document.getElementById('importFile').click()">📁 选择 JSON 文件</button> <button class="btn btn-outline btn-sm" @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">
+90 -41
View File
@@ -15,7 +15,7 @@ const cur = ref(0)
const score = ref(0) const score = ref(0)
const userAnswers = ref([]) const userAnswers = ref([])
const answered = ref([]) const answered = ref([])
const checking = ref(false) const offline = ref(false)
const showResult = ref(false) const showResult = ref(false)
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0') const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
@@ -28,6 +28,29 @@ function shuffleList(arr) {
return a return a
} }
/* ---------- 离线缓存 ---------- */
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
const CACHE_VER = 'v1'
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
function readCache(key) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
function writeCache(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* 配额满或隐私模式,缓存失败不影响在线答题 */
}
}
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门'] const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
const lvNames = ref(DEFAULT_LV_NAMES) const lvNames = ref(DEFAULT_LV_NAMES)
@@ -56,11 +79,25 @@ function optClass(i) {
return {} return {}
} }
// 网络优先、缓存兜底:只要在线就一定拿到最新题目,
// 管理后台改完题不会被旧缓存挡住,因此不需要额外的缓存失效机制。
async function loadSets() { async function loadSets() {
loading.value = true loading.value = true
error.value = '' error.value = ''
let data = null
try { try {
const data = await apiPublic('/sets') 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 sets.value = data.sets
if (!sets.value.length) { if (!sets.value.length) {
error.value = '暂无套题,请联系管理员添加' error.value = '暂无套题,请联系管理员添加'
@@ -70,19 +107,36 @@ async function loadSets() {
const want = Number(route.query.set) const want = Number(route.query.set)
const target = sets.value.find(s => s.id === want) || sets.value[0] const target = sets.value.find(s => s.id === want) || sets.value[0]
await loadQuestions(target.id) await loadQuestions(target.id)
} catch (e) {
error.value = '题目加载失败,请检查网络后刷新'
loading.value = false
}
} }
async function loadQuestions(setId) { async function loadQuestions(setId) {
loading.value = true loading.value = true
error.value = '' error.value = ''
const key = setCacheKey(setId)
let data = null
try { try {
const data = await apiPublic('/questions?set=' + setId) data = await apiPublic('/questions?set=' + setId)
const list = shuffle.value ? shuffleList(data.questions) : data.questions writeCache(key, data)
questions.value = list offline.value = false
} catch (e) {
data = readCache(key)
if (!data) {
error.value = '该套题还没有缓存过,请联网后再打开一次'
loading.value = false
return
}
offline.value = true
}
// 本地判分依赖题目自带 ans。开发时 server 与 vite 是两个进程,
// 改完 server.js 忘了重启就会拿到不带 ans 的老数据,那样每题都会被判错。
if (data.questions.length && data.questions[0].ans === undefined) {
error.value = '服务端版本过旧(未下发答案),请重启 node server.js'
loading.value = false
return
}
questions.value = shuffle.value ? shuffleList(data.questions) : data.questions
activeSetId.value = setId activeSetId.value = setId
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6 lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
? ['', ...data.set.lvNames] ? ['', ...data.set.lvNames]
@@ -93,12 +147,8 @@ async function loadQuestions(setId) {
score.value = 0 score.value = 0
showResult.value = false showResult.value = false
if (!questions.value.length) error.value = '该套题暂无题目,请联系管理员添加' if (!questions.value.length) error.value = '该套题暂无题目,请联系管理员添加'
} catch (e) {
error.value = '题目加载失败,请检查网络后刷新'
} finally {
loading.value = false loading.value = false
} }
}
function pickSet(id) { function pickSet(id) {
if (id === activeSetId.value) { if (id === activeSetId.value) {
@@ -108,32 +158,16 @@ function pickSet(id) {
router.replace({ path: '/quiz', query: { set: id } }) router.replace({ path: '/quiz', query: { set: id } })
} }
async function choose(i) { // 本地同步判分:赋值与判分落在同一个渲染周期,不存在 ans 未知的中间态,
if (answeredHere.value || checking.value) return // 因此没有"先红后绿"的闪烁,断网也照样能答。
checking.value = true function choose(i) {
answered.value[cur.value] = true if (answeredHere.value) return
userAnswers.value[cur.value] = i userAnswers.value[cur.value] = i
const q = current.value answered.value[cur.value] = true
try { if (i === current.value.ans) score.value++
const data = await apiPublic('/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: q.id, answer: i })
})
q.ans = data.answer
q.exp = data.exp
if (data.correct) score.value++
} catch (e) {
answered.value[cur.value] = false
userAnswers.value[cur.value] = null
alert('判分失败,请重试:' + e.message)
} finally {
checking.value = false
}
} }
function next() { function next() {
if (checking.value) return
if (isLast.value) showResult.value = true if (isLast.value) showResult.value = true
else if (cur.value < total.value - 1) cur.value++ else if (cur.value < total.value - 1) cur.value++
} }
@@ -185,6 +219,7 @@ onMounted(loadSets)
<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>
</div> </div>
</div> </div>
@@ -205,7 +240,7 @@ onMounted(loadSets)
<button <button
v-for="(o, i) in current.opts" :key="i" v-for="(o, i) in current.opts" :key="i"
class="opt" :class="optClass(i)" class="opt" :class="optClass(i)"
:disabled="answeredHere || checking" :disabled="answeredHere"
@click="choose(i)"> @click="choose(i)">
<span class="opt-key">{{ keyOf(i) }}</span> <span class="opt-key">{{ keyOf(i) }}</span>
<span class="opt-text">{{ stripHtmlAndDecode(o) }}</span> <span class="opt-text">{{ stripHtmlAndDecode(o) }}</span>
@@ -225,15 +260,15 @@ onMounted(loadSets)
</transition> </transition>
<div class="btns"> <div class="btns">
<button class="btn btn-outline" :disabled="cur === 0 || checking" @click="prev">上一题</button> <button class="btn btn-outline" :disabled="cur === 0" @click="prev">上一题</button>
<button class="btn btn-filled" :disabled="checking" @click="next"> <button class="btn btn-filled" @click="next">
{{ isLast ? '看成绩' : '下一题' }} {{ isLast ? '看成绩' : '下一题' }}
</button> </button>
</div> </div>
</div> </div>
</transition> </transition>
<transition name="slide"> <transition name="pop">
<div v-if="showResult" class="card result-card"> <div v-if="showResult" class="card result-card">
<div class="circle" :style="{ '--p': pct }"> <div class="circle" :style="{ '--p': pct }">
<div class="circle-inner"> <div class="circle-inner">
@@ -297,7 +332,21 @@ onMounted(loadSets)
} }
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); } .shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
.quiz-card { margin-top: 20px; animation: fadeUp .4s ease; } .offline-tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--hairline-strong);
background: var(--surface-high);
color: var(--ink-secondary);
border-radius: 999px;
padding: 4px 11px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .5px;
white-space: nowrap;
}
.quiz-card { margin-top: 20px; }
.q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; } .q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; }
.score-pill { .score-pill {
background: var(--primary-container); background: var(--primary-container);
@@ -369,7 +418,7 @@ onMounted(loadSets)
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } .btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
.result-card { margin-top: 20px; text-align: center; animation: popIn .4s ease; } .result-card { margin-top: 20px; text-align: center; }
.circle { .circle {
width: 150px; height: 150px; width: 150px; height: 150px;
border-radius: 50%; border-radius: 50%;