chore: update project
This commit is contained in:
@@ -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 `<`. 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 1–6, 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.
|
||||
Generated
-42
@@ -491,9 +491,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -588,9 +585,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -604,9 +598,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -620,9 +611,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -636,9 +624,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -652,9 +637,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -668,9 +650,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -684,9 +663,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -700,9 +676,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -716,9 +689,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -732,9 +702,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -748,9 +715,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,9 +728,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -780,9 +741,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+406
-2
@@ -1659,8 +1659,412 @@
|
||||
"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 -> 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 只能加载 png,network 只能加载 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<bool>(...); 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<FormState>,提交时调用 _formKey.currentState!.validate()",
|
||||
"在每个 TextField 的 onChanged 里手写 if 判断",
|
||||
"提交时挨个读 controller.text 用 if 判断",
|
||||
"前端不校验,全部交给服务端返回错误"
|
||||
],
|
||||
"ans": 0,
|
||||
"exp": "<code>Form</code> + <code>GlobalKey<FormState></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) => (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>,再更新状态。"
|
||||
}
|
||||
],
|
||||
"lvNames": [
|
||||
"项目与骨架",
|
||||
"布局与间距",
|
||||
"常用组件",
|
||||
"资源与样式",
|
||||
"交互与反馈",
|
||||
"表单与异步"
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextSetId": 12,
|
||||
"nextQId": 191
|
||||
"nextSetId": 13,
|
||||
"nextQId": 221
|
||||
}
|
||||
@@ -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 -> 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 只能加载 png,network 只能加载 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<bool>(...); 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<FormState>,提交时调用 _formKey.currentState!.validate()",
|
||||
"在每个 TextField 的 onChanged 里手写 if 判断",
|
||||
"提交时挨个读 controller.text 用 if 判断",
|
||||
"前端不校验,全部交给服务端返回错误"
|
||||
],
|
||||
"ans": 0,
|
||||
"exp": "<code>Form</code> + <code>GlobalKey<FormState></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) => (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,7 +2,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
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() {
|
||||
const loginRes = await fetch(BASE + '/api/admin/login', {
|
||||
|
||||
@@ -58,8 +58,10 @@ function findQuestion(qid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 答题页需要整套缓存以支持离线判分,因此下发答案与解析。
|
||||
// 保留显式白名单:以后给题目加内部字段(如出题备注)不会顺手漏出去。
|
||||
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) {
|
||||
|
||||
+5
-1
@@ -6,7 +6,11 @@
|
||||
<title>砚 · Dart 测验</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<!-- 非阻塞加载:先用 media="print" 让浏览器不把它当渲染阻塞资源,下载完成后再切回 all。
|
||||
国内常连不上 fonts.googleapis.com,若按默认方式引入会把首屏一直卡到请求超时;
|
||||
这样写则首屏立即绘制,先用 theme.css 里的本地回退字体(宋体/苹方/雅黑),字体到了再换上。 -->
|
||||
<link rel="stylesheet" media="print" onload="this.media='all'"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Noto+Sans+SC:wght@400;500;700&display=swap">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -256,14 +256,22 @@ body {
|
||||
.table tbody tr:hover { background: var(--surface-alt); }
|
||||
|
||||
/* ---------- 过渡动画 ---------- */
|
||||
/* 每组都必须 enter / leave 成对定义。缺了 leave 时,Vue 会去读元素上
|
||||
残留的 animation 时长来决定等多久,而那个 animation 早已播完、不会再触发
|
||||
animationend,于是只能干等满兜底 timeout —— 表现就是切换时“卡一下”。
|
||||
同理,元素自身不要再挂 animation,入场交给这里的 transition 负责。 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .25s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.slide-enter-active { transition: all .3s ease; }
|
||||
.slide-enter-from { opacity: 0; transform: translateY(14px); }
|
||||
.slide-enter-active { transition: opacity .2s ease, transform .2s ease; }
|
||||
.slide-leave-active { transition: opacity .12s ease, transform .12s ease; }
|
||||
.slide-enter-from { opacity: 0; transform: translateY(12px); }
|
||||
.slide-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
|
||||
.pop-enter-active { transition: all .35s cubic-bezier(.2, .9, .3, 1.3); }
|
||||
.pop-enter-active { transition: opacity .28s ease, transform .28s cubic-bezier(.2, .9, .3, 1.3); }
|
||||
.pop-leave-active { transition: opacity .12s ease, transform .12s ease; }
|
||||
.pop-enter-from { opacity: 0; transform: scale(.92); }
|
||||
.pop-leave-to { opacity: 0; transform: scale(.96); }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
|
||||
@@ -20,6 +20,7 @@ const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面
|
||||
|
||||
const setForm = reactive({ show: false, editingId: null, title: '', name: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' })
|
||||
const importForm = reactive({ show: false, text: '', name: '', msg: '' })
|
||||
const importFileRef = ref(null)
|
||||
const qForm = reactive({
|
||||
show: false, editingId: null, title: '',
|
||||
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
|
||||
@@ -164,6 +165,8 @@ function onImportFile(e) {
|
||||
importForm.msg = ''
|
||||
}
|
||||
reader.readAsText(file, 'utf-8')
|
||||
// 清空 value:否则再次选择同一个文件时 value 未变、不会触发 change,按钮看起来像失灵
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
@@ -406,8 +409,8 @@ onMounted(async () => {
|
||||
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</div>
|
||||
<div class="import-zone">
|
||||
<div>
|
||||
<input type="file" accept=".json,application/json" style="display:none;" :id="'importFile'" @change="onImportFile">
|
||||
<button class="btn btn-outline btn-sm" @click="document.getElementById('importFile').click()">📁 选择 JSON 文件</button>
|
||||
<input ref="importFileRef" type="file" accept=".json,application/json" style="display:none;" @change="onImportFile">
|
||||
<button class="btn btn-outline btn-sm" @click="importFileRef?.click()">📁 选择 JSON 文件</button>
|
||||
<span style="font-size:12px;color:var(--ink-faint);">(或直接粘贴下方 JSON)</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
|
||||
+104
-55
@@ -15,7 +15,7 @@ const cur = ref(0)
|
||||
const score = ref(0)
|
||||
const userAnswers = ref([])
|
||||
const answered = ref([])
|
||||
const checking = ref(false)
|
||||
const offline = ref(false)
|
||||
const showResult = ref(false)
|
||||
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
|
||||
|
||||
@@ -28,6 +28,29 @@ function shuffleList(arr) {
|
||||
return a
|
||||
}
|
||||
|
||||
/* ---------- 离线缓存 ---------- */
|
||||
// key 带版本号:以后改数据形状时,老缓存不会把页面带崩
|
||||
const CACHE_VER = 'v1'
|
||||
const setsCacheKey = () => `quiz-cache-${CACHE_VER}-sets`
|
||||
const setCacheKey = id => `quiz-cache-${CACHE_VER}-set-${id}`
|
||||
|
||||
function readCache(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* 配额满或隐私模式,缓存失败不影响在线答题 */
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
||||
const lvNames = ref(DEFAULT_LV_NAMES)
|
||||
|
||||
@@ -56,48 +79,75 @@ function optClass(i) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 网络优先、缓存兜底:只要在线就一定拿到最新题目,
|
||||
// 管理后台改完题不会被旧缓存挡住,因此不需要额外的缓存失效机制。
|
||||
async function loadSets() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
let data = null
|
||||
try {
|
||||
const data = await apiPublic('/sets')
|
||||
sets.value = data.sets
|
||||
if (!sets.value.length) {
|
||||
error.value = '暂无套题,请联系管理员添加'
|
||||
data = await apiPublic('/sets')
|
||||
writeCache(setsCacheKey(), data)
|
||||
offline.value = false
|
||||
} catch (e) {
|
||||
data = readCache(setsCacheKey())
|
||||
if (!data) {
|
||||
error.value = '套题列表加载失败,本地也没有缓存,请联网后重试'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const want = Number(route.query.set)
|
||||
const target = sets.value.find(s => s.id === want) || sets.value[0]
|
||||
await loadQuestions(target.id)
|
||||
} catch (e) {
|
||||
error.value = '题目加载失败,请检查网络后刷新'
|
||||
loading.value = false
|
||||
offline.value = true
|
||||
}
|
||||
sets.value = data.sets
|
||||
if (!sets.value.length) {
|
||||
error.value = '暂无套题,请联系管理员添加'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const want = Number(route.query.set)
|
||||
const target = sets.value.find(s => s.id === want) || sets.value[0]
|
||||
await loadQuestions(target.id)
|
||||
}
|
||||
|
||||
async function loadQuestions(setId) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const key = setCacheKey(setId)
|
||||
let data = null
|
||||
try {
|
||||
const data = await apiPublic('/questions?set=' + setId)
|
||||
const list = shuffle.value ? shuffleList(data.questions) : data.questions
|
||||
questions.value = list
|
||||
activeSetId.value = setId
|
||||
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
|
||||
? ['', ...data.set.lvNames]
|
||||
: DEFAULT_LV_NAMES
|
||||
userAnswers.value = new Array(questions.value.length).fill(null)
|
||||
answered.value = new Array(questions.value.length).fill(false)
|
||||
cur.value = 0
|
||||
score.value = 0
|
||||
showResult.value = false
|
||||
if (!questions.value.length) error.value = '该套题暂无题目,请联系管理员添加'
|
||||
data = await apiPublic('/questions?set=' + setId)
|
||||
writeCache(key, data)
|
||||
offline.value = false
|
||||
} catch (e) {
|
||||
error.value = '题目加载失败,请检查网络后刷新'
|
||||
} finally {
|
||||
loading.value = false
|
||||
data = readCache(key)
|
||||
if (!data) {
|
||||
error.value = '该套题还没有缓存过,请联网后再打开一次'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
// 本地判分依赖题目自带 ans。开发时 server 与 vite 是两个进程,
|
||||
// 改完 server.js 忘了重启就会拿到不带 ans 的老数据,那样每题都会被判错。
|
||||
if (data.questions.length && data.questions[0].ans === undefined) {
|
||||
error.value = '服务端版本过旧(未下发答案),请重启 node server.js'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
questions.value = shuffle.value ? shuffleList(data.questions) : data.questions
|
||||
activeSetId.value = setId
|
||||
lvNames.value = Array.isArray(data.set.lvNames) && data.set.lvNames.length === 6
|
||||
? ['', ...data.set.lvNames]
|
||||
: DEFAULT_LV_NAMES
|
||||
userAnswers.value = new Array(questions.value.length).fill(null)
|
||||
answered.value = new Array(questions.value.length).fill(false)
|
||||
cur.value = 0
|
||||
score.value = 0
|
||||
showResult.value = false
|
||||
if (!questions.value.length) error.value = '该套题暂无题目,请联系管理员添加'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function pickSet(id) {
|
||||
@@ -108,32 +158,16 @@ function pickSet(id) {
|
||||
router.replace({ path: '/quiz', query: { set: id } })
|
||||
}
|
||||
|
||||
async function choose(i) {
|
||||
if (answeredHere.value || checking.value) return
|
||||
checking.value = true
|
||||
answered.value[cur.value] = true
|
||||
// 本地同步判分:赋值与判分落在同一个渲染周期,不存在 ans 未知的中间态,
|
||||
// 因此没有"先红后绿"的闪烁,断网也照样能答。
|
||||
function choose(i) {
|
||||
if (answeredHere.value) return
|
||||
userAnswers.value[cur.value] = i
|
||||
const q = current.value
|
||||
try {
|
||||
const data = await apiPublic('/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: q.id, answer: i })
|
||||
})
|
||||
q.ans = data.answer
|
||||
q.exp = data.exp
|
||||
if (data.correct) score.value++
|
||||
} catch (e) {
|
||||
answered.value[cur.value] = false
|
||||
userAnswers.value[cur.value] = null
|
||||
alert('判分失败,请重试:' + e.message)
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
answered.value[cur.value] = true
|
||||
if (i === current.value.ans) score.value++
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (checking.value) return
|
||||
if (isLast.value) showResult.value = true
|
||||
else if (cur.value < total.value - 1) cur.value++
|
||||
}
|
||||
@@ -185,6 +219,7 @@ onMounted(loadSets)
|
||||
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
|
||||
<span>打乱题目顺序</span>
|
||||
</label>
|
||||
<span v-if="offline" class="offline-tag" title="当前使用本地缓存的题目,联网后会自动取最新">离线 · 使用本地缓存</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,7 +240,7 @@ onMounted(loadSets)
|
||||
<button
|
||||
v-for="(o, i) in current.opts" :key="i"
|
||||
class="opt" :class="optClass(i)"
|
||||
:disabled="answeredHere || checking"
|
||||
:disabled="answeredHere"
|
||||
@click="choose(i)">
|
||||
<span class="opt-key">{{ keyOf(i) }}</span>
|
||||
<span class="opt-text">{{ stripHtmlAndDecode(o) }}</span>
|
||||
@@ -225,15 +260,15 @@ onMounted(loadSets)
|
||||
</transition>
|
||||
|
||||
<div class="btns">
|
||||
<button class="btn btn-outline" :disabled="cur === 0 || checking" @click="prev">上一题</button>
|
||||
<button class="btn btn-filled" :disabled="checking" @click="next">
|
||||
<button class="btn btn-outline" :disabled="cur === 0" @click="prev">上一题</button>
|
||||
<button class="btn btn-filled" @click="next">
|
||||
{{ isLast ? '看成绩' : '下一题' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="slide">
|
||||
<transition name="pop">
|
||||
<div v-if="showResult" class="card result-card">
|
||||
<div class="circle" :style="{ '--p': pct }">
|
||||
<div class="circle-inner">
|
||||
@@ -297,7 +332,21 @@ onMounted(loadSets)
|
||||
}
|
||||
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
|
||||
|
||||
.quiz-card { margin-top: 20px; animation: fadeUp .4s ease; }
|
||||
.offline-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
background: var(--surface-high);
|
||||
color: var(--ink-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-card { margin-top: 20px; }
|
||||
.q-num { font-family: var(--serif); font-weight: 700; font-size: 15px; color: var(--ink-secondary); letter-spacing: 1px; }
|
||||
.score-pill {
|
||||
background: var(--primary-container);
|
||||
@@ -369,7 +418,7 @@ onMounted(loadSets)
|
||||
|
||||
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||
|
||||
.result-card { margin-top: 20px; text-align: center; animation: popIn .4s ease; }
|
||||
.result-card { margin-top: 20px; text-align: center; }
|
||||
.circle {
|
||||
width: 150px; height: 150px;
|
||||
border-radius: 50%;
|
||||
|
||||
Reference in New Issue
Block a user