add flutter quiz project code
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
File diff suppressed because one or more lines are too long
Generated
+2445
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "flutter-quiz",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Dart & Flutter 学习测验系统(可管理题目,支持分享)",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,web -c blue,green \"node server.js\" \"vite\"",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"concurrently": "^9.2.4",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"vite": "^6.4.3",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.25.12": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+1666
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"name": "Dart 开发实战",
|
||||||
|
"lvNames": ["基础语法", "控制流与函数", "集合", "面向对象", "空安全与异步", "Dart 高级特性"],
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "开发注册页时,用户输入的年龄是字符串,需要转成 <code>int</code> 保存。已知 <code>String ageText = \"18\";</code>,正确的转换写法是?",
|
||||||
|
"opts": ["<code>int(ageText)</code>", "<code>toInt(ageText)</code>", "<code>int.parse(ageText)</code>", "<code>ageText.parseInt()</code>"],
|
||||||
|
"ans": 2,
|
||||||
|
"exp": "<code>int.parse()</code> 把字符串解析成整数,解析失败会抛 FormatException,更稳妥的是 <code>int.tryParse()</code>(失败返回 null)。注意 Dart 没有 <code>\"18\".parseInt()</code> 这种写法,<code>int(...)</code> 也不是转换函数。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "项目里要定义一个\"永远不允许修改\"的 API 地址常量,以下哪种声明最合适?",
|
||||||
|
"opts": ["<code>final String api = \"https://api.example.com\";</code>", "<code>const String api = \"https://api.example.com\";</code>", "<code>String api = \"https://api.example.com\";</code>", "<code>var api = \"https://api.example.com\";</code>"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<b>const</b> 是编译期常量:值在编译时就确定,且不可变,编译器可直接内联替换,还能配合 const 构造函数做实例复用。<b>final</b> 是运行期赋值一次后不可变。给字面量常量用 const 是更优选择。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "你在重构代码时写下:<code>var x = 10; x = \"hello\";</code> 会发生什么?",
|
||||||
|
"opts": ["正常运行,x 变成字符串", "编译报错:x 已被推断为 int 类型", "运行时报错", "x 自动变为 null"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<b>var</b> 声明时由初始值自动推断类型,推断后类型就<b>固定</b>了:x 是 int,再赋 String 在编译期直接报错。想存任意类型,应显式声明 <code>dynamic</code> 或 <code>Object?</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "给变量起名时,以下哪个<b>不是</b>合法的 Dart 标识符?",
|
||||||
|
"opts": ["<code>_score</code>", "<code>2x</code>", "<code>中文名</code>", "<code>name_2</code>"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "Dart 标识符<b>不能以数字开头</b>。<code>_score</code> 合法(下划线开头表示库内私有,是 Dart 的约定俗成);Dart 支持 Unicode 标识符,中文变量名同样合法。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "结算功能计算人均消费:<code>int total = 10; int people = 4; double avg = total / people;</code> 打印 <code>avg</code> 得到?",
|
||||||
|
"opts": ["2", "2.5", "2.0", "报错"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "Dart 中 <b>int 除以 int 的结果一定是 double</b>:10 / 4 = 2.5。想得到整除结果用 <code>~/</code>:<code>10 ~/ 4 == 2</code>,取余用 <code>%</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "写优惠逻辑:订单数 > 10 打 9 折,> 50 打 8 折。对 <code>orders = 60</code>,以下代码会得到什么折扣?<br><code>double d; if (orders > 10) { d = 0.9; } else if (orders > 50) { d = 0.8; }</code>",
|
||||||
|
"opts": ["0.8", "0.9", "1.0", "0.7"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "if-else 链<b>从上到下匹配,命中即止</b>:60 > 10 先成立就直接取 0.9,后面的 else if 不再执行。正确写法应把范围大的条件放前面(先判断 > 50)。判断顺序是 if-else 链最经典的坑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "接口返回 <code>Map<String, dynamic> data</code>,想遍历所有 key 打印出来,以下哪种写法正确?",
|
||||||
|
"opts": ["<code>for (var k in data) print(k);</code>", "<code>for (var k in data.keys) print(k);</code>", "<code>data.keys.forEach(print);</code>", "B 和 C 都可以"],
|
||||||
|
"ans": 3,
|
||||||
|
"exp": "Map 本身<b>不能</b>直接 for-in 遍历,要先取 <code>keys</code> / <code>values</code> / <code>entries</code>。B 是标准写法;C 中 <code>forEach(print)</code> 利用了函数引用,同样正确。两种都行,按团队规范选择。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "写一个返回用户名的函数:<code>String getUserName() { }</code>,忘了写 <code>return</code>,会怎样?",
|
||||||
|
"opts": ["返回 null", "编译报错", "运行时崩溃", "自动返回空字符串"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "空安全下,声明了非空返回类型 <code>String</code> 的函数<b>必须有 return</b>,漏写直接编译报错,把隐患消灭在编译期。如果确实允许无返回值,应把返回类型声明为 <code>String?</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "问候函数:<code>void greet(String name, {String greeting = \"你好\"})</code>,调用 <code>greet(\"小明\", \"早上好\")</code> 会发生什么?",
|
||||||
|
"opts": ["正常执行,greeting 为 \"早上好\"", "编译报错:命名参数不能按位置传参", "greeting 变为 null", "编译警告但能运行"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<code>{}</code> 包裹的是<b>命名参数</b>,必须写成 <code>greet(\"小明\", greeting: \"早上好\")</code>;<code>[]</code> 才是可选位置参数。命名参数让调用意图清晰,是团队协作中常用的做法。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "写\"重试发送验证码\"逻辑:要求<b>至少发送一次</b>,失败则继续重试。哪种循环语义最贴切?",
|
||||||
|
"opts": ["<code>while</code>", "<code>do-while</code>", "<code>for</code>", "<code>repeat</code>"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<b>do-while</b> 先执行循环体再判断条件,保证至少执行一次,正是\"先发一次、不行再重试\"的语义。while 先判断后执行,可能一次都不执行。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "实现\"删除待办\"功能,<code>list.remove(3)</code> 和 <code>list.removeAt(3)</code> 的区别是?",
|
||||||
|
"opts": ["remove 按值删除,removeAt 按下标删除", "两者完全相同", "remove 删下标,removeAt 按值删除", "remove 会删除所有等于 3 的元素"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>remove(value)</code> 删除<b>第一个</b>值相等的元素并返回是否成功;<code>removeAt(index)</code> 按下标删除并返回被删的元素,越界抛 RangeError。写代码前想清楚你要删的是\"值\"还是\"位置\"。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "解析接口数据:用 <code>data[\"name\"]</code> 取一个<b>不存在的 key</b> 时,返回什么?",
|
||||||
|
"opts": ["抛异常", "null", "空字符串", "抛出 NoSuchMethodError"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "Map 用 <code>[]</code> 取值时,key 不存在<b>返回 null</b> 而不是抛异常。所以 <code>data[\"name\"]</code> 可能为 null,取值后判空再使用是常规操作(配合 <code>??</code> 兜底)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "配置列表按条件插入元素:<code>var l = [1, 2, if (isVip) 3];</code> 当 <code>isVip</code> 为 false 时 l 是?",
|
||||||
|
"opts": ["<code>[1, 2]</code>", "<code>[1, 2, 3]</code>", "<code>[1, 2, false]</code>", "编译错误"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "这是 <b>collection-if</b> 语法:条件不成立时该元素直接不生成。Flutter 里常用于条件渲染组件列表,配合 <code>...</code> 展开和 collection-for 一起使用。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "两个用户各自关注了一批标签,要找出<b>共同关注</b>的标签,用哪种写法?",
|
||||||
|
"opts": ["<code>a & b</code>", "<code>a.intersect(b)</code>", "<code>a.toSet().intersection(b.toSet())</code>", "<code>a.union(b)</code>"],
|
||||||
|
"ans": 2,
|
||||||
|
"exp": "List 没有交集方法,要先转 Set。<code>intersection()</code> 返回两个集合的共同元素;<code>union()</code> 是并集。注意方法名是 <code>intersection()</code>,不是其它语言的 <code>intersect()</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "计算购物车总价:<code>List<double> prices = [];</code> 空列表调用 <code>prices.reduce((a, b) => a + b)</code> 会?",
|
||||||
|
"opts": ["返回 0", "抛出 StateError 异常", "返回 null", "返回空列表"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<code>reduce</code> 要求列表<b>至少有一个元素</b>,空列表直接抛 \"Bad state: No element\"。更稳的写法是 <code>fold(0, (a, b) => a + b)</code>:带初始值,空列表返回 0。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "写日期工具类,希望 <code>DateUtil.parse(\"2024-01-01\")</code> 在字符串不合法时返回 null 而不是抛异常,用哪种构造函数?",
|
||||||
|
"opts": ["factory 构造函数", "普通构造函数", "命名构造函数", "const 构造函数"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>factory</b> 构造函数可以不返回新实例——可以返回缓存实例、子类实例,甚至返回 null。它把\"按需创建/校验\"的逻辑封装在构造入口,调用方写法统一且安全。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "日志打印、权限检查这类能力要被多个<b>互不相关</b>的类复用,又不想强制继承关系,Dart 的推荐做法是?",
|
||||||
|
"opts": ["<code>extends</code> 继承", "<code>implements</code> 实现", "<code>with</code> 混入 Mixin", "<code>abstract</code> 抽象类"],
|
||||||
|
"ans": 2,
|
||||||
|
"exp": "<b>Mixin</b>(<code>with</code>)把可复用逻辑抽出来按需混入,避免\"为了复用而强行继承\"的长继承链。Flutter 源码大量使用,如 <code>class _X with RouteAware</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "类内部要声明一个\"仅供本文件使用\"的私有字段,正确写法是?",
|
||||||
|
"opts": ["<code>private String _token;</code>", "<code>String _token;</code>", "<code>-String token;</code>", "<code>#token</code>"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "Dart 没有 <code>private</code> 关键字,以<b>下划线开头</b>表示库内私有:<code>_token</code>。注意这是\"库\"级别私有(同文件可见),不是类级别。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "用户类的 <code>name</code> 字段希望<b>赋值时自动去掉首尾空格</b>,最佳做法是?",
|
||||||
|
"opts": ["声明 getter/setter,在 setter 里 trim", "直接写公共字段,调用方自己 trim", "用 final 禁止修改", "用 static 修饰"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>setter</b> 在赋值入口统一处理,所有调用方(包括构造函数)都自动享受规范化,不用每个地方都记得 trim。写法:<code>String get name</code> 与 <code>set name(String v)</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "<code>class Dog implements Animal</code> 与 <code>extends</code> 继承的关键区别是?",
|
||||||
|
"opts": ["implements 要求实现 Animal 的<b>全部</b>成员(含字段),extends 可复用已有实现", "两者完全一样", "implements 只能用于抽象类", "extends 要求实现全部成员"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>implements</code> 把 Animal 当<b>接口</b>:所有成员都要自己重写,即使父类已有实现;<code>extends</code> 继承实现,只覆写需要改的部分。implements 用于\"规定能力\",extends 用于\"复用实现\"。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "用户对象可能为 null:<code>User? u = await fetchUser();</code>,以下哪种使用方式更安全?",
|
||||||
|
"opts": ["<code>if (u != null) { print(u.name); }</code> 判空后直接用 u", "<code>if (u != null) { print(u!.name); }</code> 必须加 !", "<code>print(u?.name ?? \"\");</code>", "A 和 C 都可以"],
|
||||||
|
"ans": 3,
|
||||||
|
"exp": "<b>A</b>:局部变量判空后编译器会<b>自动提升</b>为非空类型,直接使用即可;<b>C</b>:<code>?.</code> 安全调用 + <code>??</code> 兜底,一行搞定。只有被闭包捕获的变量才需要 <code>!</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "详情页要同时请求\"用户信息\"和\"订单列表\"两个接口,都拿到再渲染,用哪个方法?",
|
||||||
|
"opts": ["<code>Future.wait([fetchUser(), fetchOrders()])</code>", "串行写两个 await", "<code>Future.any</code>", "<code>Future.delayed</code> 等待"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>Future.wait</b> 并发启动多个 Future,全部完成后统一返回结果列表,总耗时约等于最慢的那个;串行 await 则是两个请求耗时相加。<code>Future.any</code> 只等第一个完成的。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "某个字段要等登录成功后才能赋值,但又想声明成非空类型,正确声明方式是?",
|
||||||
|
"opts": ["<code>late String token;</code>", "<code>String? token = \"\";</code>", "<code>dynamic token;</code>", "<code>final token = null;</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>late</b> 表示\"推迟初始化,首次访问时才赋值\",适合依赖异步结果的字段或计算代价高的属性。注意:late 变量在赋值前被访问会抛 LateInitializationError。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "把解析结果转成目标类型:<code>var age = data[\"age\"] as int;</code>,当 age 实际是 String 时会发生?",
|
||||||
|
"opts": ["返回 null", "抛出 TypeError(类型转换失败)", "静默忽略", "自动转成 int"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<code>as</code> 是<b>强制类型转换</b>,类型不符立即抛异常。安全做法:先用 <code>is</code> 判断类型,或先统一规范数据(如 num 一律 <code>.toInt()</code>)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "对可能为 null 的 <code>String? s</code> 使用 <code>s!</code>,若 s 确实为 null 会?",
|
||||||
|
"opts": ["返回 null", "抛出 TypeError(空检查失败)", "静默继续", "编译错误"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<code>!</code> 是<b>强制解包</b>运算符,向编译器承诺\"这里不可能为 null\",承诺落空时运行时抛 TypeError。能用判空提升或 <code>??</code> 的地方尽量别用 <code>!</code>,减少运行时崩溃点。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "Flutter 中常见 <code>ButtonStyle()..backgroundColor = ... ..shape = ...</code> 写法,<code>..</code> 级联操作符的核心行为是?",
|
||||||
|
"opts": ["对同一对象连续操作且返回对象本身", "链式调用并返回最后一次结果", "只能用于 List", "与 <code>...</code> 展开等价"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>级联操作符</b> <code>..</code> 对同一对象连续执行多个操作,<b>不改变返回值</b>(始终是原对象),适合集中配置对象。<code>.</code> 返回的是方法调用的返回值,两者行为不同。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "Dart 3 中,函数想直接返回\"用户名 + 年龄\"这组小数据,又不想专门建一个类,推荐用?",
|
||||||
|
"opts": ["<code>Map<String, dynamic></code>", "Record 记录类型:<code>(String name, int age)</code>", "<code>List<dynamic></code>", "用全局变量"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "<b>Record</b> 是 Dart 3 的轻量聚合类型:<code>var r = (\"张三\", 18);</code> 或命名字段 <code>(name: \"张三\", age: 18)</code>,用 <code>r.$1</code> / <code>r.name</code> 取值,还能配合模式匹配解构,比 Map 更类型安全。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "Dart 3 的 switch 可以直接当表达式赋值:<code>var s = switch (status) { \"ok\" => \"成功\", _ => \"未知\" };</code> 这种写法?",
|
||||||
|
"opts": ["合法:switch 表达式返回一个值", "非法:switch 不能作表达式", "必须每个分支加 break", "只能匹配字符串"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>switch 表达式</b>用 <code>=></code> 代替冒号,整体作为值赋给变量,配合模式匹配可以解构、判断类型,比旧式 switch 简洁,也天然避免了漏 break 的坑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "想给系统类 <code>String</code> 加一个 <code>isEmail()</code> 方法,但 String 不能改源码,用?",
|
||||||
|
"opts": ["<b>extension</b> 扩展:<code>extension EmailCheck on String { bool isEmail() {...} }</code>", "继承 String", "用 mixin 混入", "写静态工具类"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>extension</b> 可以不修改原类给已有类型添加方法,之后 <code>\"a@b.com\".isEmail()</code> 直接调用。注意扩展方法是静态分发(编译期决定),不参与运行时多态。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "泛型容器 <code>class Box<T extends num> {}</code> 中 <code>extends num</code> 的含义是?",
|
||||||
|
"opts": ["T 只能是 int、double 等数值类型", "T 可以是任意类型", "T 必须可空", "T 必须是集合类型"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>泛型约束</b>限定 T 必须是 num 及其子类(int/double),这样 Box 内部就能安全调用数值运算。不加约束时 T 等价于 <code>Object?</code>。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"name": "Flutter 开发实战",
|
||||||
|
"lvNames": ["Widget 基础", "布局与组件", "状态管理", "路由与导航", "数据与网络", "实战综合"],
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "新建 Flutter 项目后,把应用真正跑起来的入口代码是?",
|
||||||
|
"opts": ["<code>void main() => runApp(MyApp());</code>", "<code>void main() { MyApp(); }</code>", "<code>runApp() { MyApp }</code>", "<code>main() { MaterialApp(); }</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Dart 程序入口是 <code>main()</code>,Flutter 通过 <code>runApp(Widget)</code> 把根组件挂载到引擎上。注意 <code>MyApp()</code> 是创建 Widget 实例,不能少了括号。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "实现一个\"3 秒倒计时\"按钮,每秒刷新显示剩余秒数,应使用?",
|
||||||
|
"opts": ["StatelessWidget", "StatefulWidget", "Container", "Scaffold"],
|
||||||
|
"ans": 1,
|
||||||
|
"exp": "有<b>动态变化的数据</b>(倒计时数字)就需要 StatefulWidget:数据保存在 State 里,变化时调 <code>setState()</code> 刷新。没有可变状态时应优先用 StatelessWidget。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "界面上要显示一行固定文案\"登录成功\",最合适的组件是?",
|
||||||
|
"opts": ["Text", "Label", "Paragraph", "RichText"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>Text</code> 是最常用的文本组件,可通过 style 配置字号、颜色、字重等。RichText 用于一段文字中混排多种样式的富文本场景。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "点击\"确认\"按钮后分数要 +1 并立即显示到界面,正确流程是?",
|
||||||
|
"opts": ["修改 State 字段后调用 <code>setState()</code>", "直接修改字段就行", "重启整个 App", "修改 Widget 的构造参数"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "State 里的字段只是普通数据,必须调用 <code>setState()</code> 通知框架\"数据变了\",框架才会重新执行 build 刷新 UI。注意 setState 只重建<b>当前 State 的子树</b>,不是整棵树。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 1,
|
||||||
|
"q": "要展示 10000 条商品记录,性能最优的做法是?",
|
||||||
|
"opts": ["<code>ListView.builder</code>(按需构建可见项)", "Column 里循环放 10000 个组件", "SingleChildScrollView 包 Column", "GridView.count 全部构建"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>ListView.builder</code> 只构建<b>当前可见</b>的列表项(懒加载),滚动时回收复用;Column + for 会一次性构建全部组件,万条数据直接卡死。数据量大优先 builder 系列。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "登录页要垂直排列\"输入框、输入框、登录按钮\",用哪个布局组件?",
|
||||||
|
"opts": ["Column", "Row", "Stack", "Wrap"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>Column</code> 垂直方向排列子组件,<code>Row</code> 水平方向,<code>Stack</code> 叠加并支持定位,<code>Wrap</code> 在空间不足时自动换行。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "Row 里想让一个按钮<b>占满剩余宽度</b>(剩余空间全给它),按钮外层套?",
|
||||||
|
"opts": ["<code>Expanded</code>", "<code>Padding</code>", "<code>Center</code>", "<code>Spacer</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>Expanded</code> 让子组件占据主轴的剩余空间(Row 的主轴是水平方向)。多个 Expanded 可通过 flex 参数按比例分配空间。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "Column 里有几个按钮,想让它们在<b>水平方向居中</b>,应该设置?",
|
||||||
|
"opts": ["<code>crossAxisAlignment: CrossAxisAlignment.center</code>", "<code>mainAxisAlignment: MainAxisAlignment.center</code>", "<code>alignment: Alignment.center</code>", "<code>textAlign: TextAlign.center</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Column 的<b>主轴是垂直</b>方向、<b>交叉轴是水平</b>方向。水平居中=沿交叉轴居中=crossAxisAlignment.center;mainAxisAlignment 控制的是垂直方向。主/交叉轴搞反是新手最常踩的坑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "封面图要<b>铺满整个区域且不拉伸变形</b>(多出的部分裁掉),fit 参数应该用?",
|
||||||
|
"opts": ["<code>BoxFit.cover</code>", "<code>BoxFit.fill</code>", "<code>BoxFit.none</code>", "<code>BoxFit.scaleDown</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<b>cover</b>:等比缩放填满、超出裁剪,常用于头像、封面;<b>fill</b> 会拉伸变形;<b>contain</b> 完整显示但可能留白;<b>none</b> 不缩放。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 2,
|
||||||
|
"q": "商品名过长时希望<b>单行显示,超出部分显示省略号</b>,Text 的配置是?",
|
||||||
|
"opts": ["<code>maxLines: 1, overflow: TextOverflow.ellipsis</code>", "只设 overflow: TextOverflow.clip", "只设 maxLines: 3", "<code>softWrap: false</code> 就够了"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>maxLines</code> 限制最大行数 + <code>overflow: TextOverflow.ellipsis</code> 指定超出的表现(省略号/裁剪/fade),两者配合才能出现\"…\"效果。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "输入框需要\"预填默认值 + 按钮点击后读取内容 + 随时清空\",推荐用?",
|
||||||
|
"opts": ["<code>TextEditingController</code>", "每次读 onChanged 参数", "GlobalKey 临时查找", "无法实现"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>TextEditingController</code> 是输入框的\"数据源\":<code>TextEditingController(text: \"预填\")</code> 初始化,<code>controller.text</code> 读取,<code>controller.clear()</code> 清空。用完后在 dispose 里释放。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "页面销毁时,取消监听、释放 Controller 的代码应该写在哪里?",
|
||||||
|
"opts": ["<code>dispose()</code>", "<code>initState()</code>", "<code>build()</code>", "<code>didChangeDependencies()</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>dispose()</code> 是 State 被移除时的清理钩子:取消 Timer、Stream 订阅、释放 Controller。不清理会造成内存泄漏和 \"setState called after dispose\" 报错。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "登录状态、用户信息要被<b>多个页面共享</b>,又不想层层传构造参数,项目常用方案是?",
|
||||||
|
"opts": ["Provider / InheritedWidget", "定义全局变量直接读写", "每个页面重新请求接口", "把状态塞进路由参数"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Flutter 官方的底层方案是 <b>InheritedWidget</b>,<b>Provider</b> 是对它的优雅封装:共享数据放顶层,子组件 <code>context.watch()</code> 取用并自动刷新。全局变量没有通知机制,页面不会自动更新。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "商品列表页要支持<b>下拉刷新</b>,应该把 ListView 包进哪个组件?",
|
||||||
|
"opts": ["<code>RefreshIndicator</code>", "<code>GestureDetector</code>", "<code>AnimatedList</code>", "<code>Scrollbar</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>RefreshIndicator</code> 包住可滚动组件,配合 <code>onRefresh</code> 返回的 Future 实现下拉刷新动画。这是 Material 规范自带的下拉刷新方案。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 3,
|
||||||
|
"q": "App 切到后台、再回到前台时,需要暂停/恢复计时器,通过什么监听?",
|
||||||
|
"opts": ["<code>WidgetsBindingObserver</code>", "<code>RouteObserver</code>", "<code>NavigatorObserver</code>", "<code>MediaQuery</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "给 State 混入 <code>with WidgetsBindingObserver</code>,在 <code>didChangeAppLifecycleState()</code> 中监听 AppLifecycleState(paused / resumed / inactive),即可感知前后台切换。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "列表页点击商品跳转详情页,标准写法是?",
|
||||||
|
"opts": ["<code>Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage(id: id)))</code>", "<code>Navigator.go(DetailPage(id: id))</code>", "<code>Route.open(DetailPage())</code>", "<code>DetailPage(id: id).show()</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>Navigator.push</code> 把新路由压入导航栈,MaterialPageRoute 提供转场动画,返回上一页用 <code>Navigator.pop(context)</code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "详情页编辑完要把结果<b>回传给列表页</b>,详情页返回时应该写?",
|
||||||
|
"opts": ["<code>Navigator.pop(context, result)</code>", "<code>Navigator.back(result)</code>", "<code>Navigator.pushResult(result)</code>", "<code>context.popAndPush(result)</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>pop(context, 返回值)</code> 的第二个参数就是回传的数据,列表页 <code>await push(...)</code> 的返回值即可收到。注意 pop 的参数是\"返回给上一页的数据\",不是新路由。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "跳转时需要传递商品 id、名称等参数,团队协作中<b>最推荐</b>的传参方式是?",
|
||||||
|
"opts": ["<code>DetailPage(id: 1, name: \"商品\")</code> 构造参数传递", "全局变量临时存", "static 静态字段存", "URL 字符串拼接"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "构造参数显式、类型安全、可读性好——跳转处一眼看到传了什么。全局变量/静态字段是隐藏依赖,页面多起来后非常难维护。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "在 App 里注册命名路由(如 <code>/login</code>、<code>/home</code>),配置位置是?",
|
||||||
|
"opts": ["<code>MaterialApp(routes: {...})</code>", "<code>main()</code> 函数里", "<code>runApp()</code> 参数", "每个页面里各自声明"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "命名路由集中配置在 <code>MaterialApp</code> 的 <code>routes</code> 表,之后用 <code>Navigator.pushNamed(context, \"/login\")</code> 跳转;需要传参时配合 <code>arguments</code> 参数。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 4,
|
||||||
|
"q": "跳到选择页,用户选完返回后要根据<b>选择结果</b>继续处理,列表页怎么写?",
|
||||||
|
"opts": ["<code>final r = await Navigator.push(...);</code> 拿到返回值再处理", "无法等待结果", "用 setState 轮询", "跳转时把回调函数传过去"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>push</code> 返回 <code>Future<T></code>,await 后拿到 pop 回传的值,再执行后续逻辑(如刷新选中项)。这是\"页面间取结果\"的标准姿势。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "接口返回 JSON 字符串,执行 <code>var data = jsonDecode(text);</code> 后 data 的类型是?",
|
||||||
|
"opts": ["<code>dynamic</code>(实际可能是 Map 或 List)", "<code>String</code>", "<code>Map<String, dynamic></code>", "<code>Object</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>jsonDecode</code> 的返回类型是 <code>dynamic</code>:JSON 对象变 Map<String, dynamic>、数组变 List<dynamic>、数字/布尔原样返回。通常需要手动转换:<code>jsonDecode(text) as Map<String, dynamic></code>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "要把用户对象 <code>User(id: 1, name: \"张三\")</code> 转成 JSON 字符串发给服务器,正确流程是?",
|
||||||
|
"opts": ["给 User 写 <code>toJson()</code>,再 <code>jsonEncode(user.toJson())</code>", "<code>jsonEncode(user)</code> 直接序列化", "<code>user.toString()</code>", "<code>JSON.stringify(user)</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Dart 默认不会自动序列化任意对象,惯例是模型类自己实现 <code>Map<String, dynamic> toJson()</code>,再用 <code>jsonEncode</code> 转字符串;反序列化则对应 <code>User.fromJson(Map)</code> 工厂构造。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "网络请求可能超时/断网,用户点\"登录\"时抛出了异常,正确的处理是?",
|
||||||
|
"opts": ["用 try-catch 包裹 await 请求,捕获异常并提示用户", "不做处理", "打印日志后忽略", "延迟 3 秒自动重试"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>try { await api.login(...) } catch (e) { 提示\"网络异常,请重试\" }</code> 是最基础的错误处理;生产环境还会区分 SocketException(断网)、TimeoutException(超时)等做分类提示。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "请求进行中需要展示\"加载中\"状态,最合适用?",
|
||||||
|
"opts": ["<code>CircularProgressIndicator</code>", "Text(\"加载中...\")", "Opacity 半透明遮罩", "AnimatedContainer 闪烁"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Material 标准的加载指示器是 <code>CircularProgressIndicator</code>(转圈)和 <code>LinearProgressIndicator</code>(条),配合 loading 状态变量做条件渲染。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 5,
|
||||||
|
"q": "http 请求要设置 10 秒超时,正确写法是?",
|
||||||
|
"opts": ["<code>await http.get(url).timeout(Duration(seconds: 10))</code>", "<code>http.get(url, timeout: 10)</code>", "<code>Timer(Duration(seconds: 10), ...)</code>", "<code>Future.any</code> 自动超时"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Future 自带 <code>.timeout()</code>:超时抛 TimeoutException,可再接 <code>onTimeout</code> 参数兜底。http 包本身没有 timeout 参数,这是常见的误解。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "首页同时请求\"轮播图、公告、商品列表\"三个接口,全部完成再渲染页面,用?",
|
||||||
|
"opts": ["<code>Future.wait([...])</code> 并发等待", "三个 await 串行", "依次 catch 分别显示", "用 <code>Future.any</code>"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "并发场景用 <code>Future.wait</code>,总耗时约等于最慢的请求而不是三个相加。生产上可配合记录单个请求失败做局部降级(某块失败不影响整页)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "商品列表滑到底部要自动加载下一页,监听滚动的方案是?",
|
||||||
|
"opts": ["<code>ScrollController</code> 监听 pixels 接近 maxScrollExtent 时触发", "用 Timer 每 3 秒加载", "下拉刷新代替", "GestureDetector 监听手势"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "给 ListView 挂 <code>ScrollController</code>,在 <code>addListener</code> 里判断 <code>position.pixels >= position.maxScrollExtent - 200</code>(预留阈值)就加载下一页,配合 loading 状态防止重复触发。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "商品标题中要<b>红色高亮\"热卖\"二字</b>、其余部分灰色,用?",
|
||||||
|
"opts": ["<code>RichText</code> + <code>TextSpan</code> 组合不同样式", "拆成多个 Text 排一排", "用换行符拼接字符串", "TextStyle 做不到,只能整段同色"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "<code>RichText</code> 的 <code>TextSpan</code> 支持树状嵌套:每个 span 可以有自己的 style,是\"一段文字多种样式\"的标准方案。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "界面出现<b>黄黑条纹</b>溢出警告(RenderFlex overflowed),说明?",
|
||||||
|
"opts": ["子组件超出了父组件给定的约束空间", "主题颜色配置错误", "内存泄漏", "字体加载失败"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Row/Column 空间不够时子组件溢出,debug 模式显示黄黑条纹并打印 \"RenderFlex overflowed by XX pixels\"。解决思路:Expanded/Flexible 让出空间、SingleChildScrollView 滚动、FittedBox 缩放。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lv": 6,
|
||||||
|
"q": "瀑布流商品图列表很卡、图片解码占内存高,优先优化手段是?",
|
||||||
|
"opts": ["<code>cacheWidth</code> / <code>ResizeImage</code> 限制解码尺寸", "给图片加圆角", "提高图片的清晰度", "减少列表项数量"],
|
||||||
|
"ans": 0,
|
||||||
|
"exp": "Image.network 默认按原图尺寸解码,一张 4000px 大图在手机上就是内存杀手。用 <code>cacheWidth: 300</code> 或 <code>ResizeImage</code> 让引擎按需解码成小尺寸位图,内存占用和滚动性能立竿见影。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const html = fs.readFileSync('index.html', 'utf8');
|
||||||
|
const start = html.indexOf('const questions');
|
||||||
|
const arrStart = html.indexOf('[', start);
|
||||||
|
let depth = 0, inStr = false, esc = false, end = -1;
|
||||||
|
for (let i = arrStart; i < html.length; i++) {
|
||||||
|
const c = html[i];
|
||||||
|
if (inStr) {
|
||||||
|
if (esc) { esc = false; continue; }
|
||||||
|
if (c === '\\') { esc = true; continue; }
|
||||||
|
if (c === "'") inStr = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "'") { inStr = true; continue; }
|
||||||
|
if (c === '[') depth++;
|
||||||
|
else if (c === ']') { depth--; if (depth === 0) { end = i; break; } }
|
||||||
|
}
|
||||||
|
let src = html.slice(arrStart, end + 1);
|
||||||
|
src = src.replace(/\/\/\s*=====[^']*?=====\s*/g, ' ');
|
||||||
|
|
||||||
|
const questions = vm.runInNewContext(src);
|
||||||
|
const withIds = questions.map((q, i) => ({ id: i + 1, ...q }));
|
||||||
|
const data = {
|
||||||
|
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: withIds }],
|
||||||
|
nextSetId: 2,
|
||||||
|
nextQId: withIds.length + 1
|
||||||
|
};
|
||||||
|
fs.writeFileSync('questions.json', JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
console.log('已提取', withIds.length, '道题 -> questions.json');
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { escapeSmartHtml, decodeEntities } = await import('../web/src/htmlUtil.mjs');
|
||||||
|
|
||||||
|
const file = path.join(__dirname, '..', 'questions.json');
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
let fixed = 0;
|
||||||
|
|
||||||
|
for (const set of data.sets) {
|
||||||
|
for (const q of set.questions) {
|
||||||
|
const nq = escapeSmartHtml(decodeEntities(q.q));
|
||||||
|
const nx = escapeSmartHtml(decodeEntities(q.exp || ''));
|
||||||
|
if (nq !== q.q) { q.q = nq; fixed++; }
|
||||||
|
if (nx !== q.exp) { q.exp = nx; fixed++; }
|
||||||
|
const no = q.opts.map(o => escapeSmartHtml(decodeEntities(o)));
|
||||||
|
if (JSON.stringify(no) !== JSON.stringify(q.opts)) { q.opts = no; fixed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
console.log('清洗完成,修正字段数:', fixed);
|
||||||
|
console.log('幂等检查(再跑一遍应无变化)...');
|
||||||
|
let again = 0;
|
||||||
|
for (const set of data.sets) {
|
||||||
|
for (const q of set.questions) {
|
||||||
|
if (escapeSmartHtml(decodeEntities(q.q)) !== q.q) again++;
|
||||||
|
if (escapeSmartHtml(decodeEntities(q.exp || '')) !== q.exp) again++;
|
||||||
|
if (JSON.stringify(q.opts.map(o => escapeSmartHtml(decodeEntities(o)))) !== JSON.stringify(q.opts)) again++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(again === 0 ? '幂等 ✓' : `仍会变化: ${again} 处 ✗`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3030';
|
||||||
|
const FILES = ['set-dart-dev.json', 'set-flutter-dev.json'];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const loginRes = await fetch(BASE + '/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'admin123' })
|
||||||
|
});
|
||||||
|
const login = await loginRes.json();
|
||||||
|
if (!loginRes.ok) throw new Error('登录失败: ' + login.error);
|
||||||
|
|
||||||
|
for (const file of FILES) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', file), 'utf8'));
|
||||||
|
const res = await fetch(BASE + '/api/admin/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + login.token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(`✗ ${data.name} 导入失败: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(`✓ ${data.name}: 导入 ${result.imported} 题, 套题 id=${result.set.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const d = require('../questions.json');
|
||||||
|
let total = 0, bad = [];
|
||||||
|
for (const set of d.sets) {
|
||||||
|
for (const q of set.questions) {
|
||||||
|
total++;
|
||||||
|
if (!q.q || !Array.isArray(q.opts) || q.opts.length < 2) { bad.push(`[${set.name} #${q.id}] 选项不足`); continue; }
|
||||||
|
if (!Number.isInteger(q.ans) || q.ans < 0 || q.ans >= q.opts.length) { bad.push(`[${set.name} #${q.id}] ans 越界`); continue; }
|
||||||
|
if (!q.exp) { bad.push(`[${set.name} #${q.id}] 缺解析`); continue; }
|
||||||
|
if (!Number.isInteger(q.lv) || q.lv < 1 || q.lv > 6) { bad.push(`[${set.name} #${q.id}] lv 非法`); continue; }
|
||||||
|
const dup = q.opts.findIndex((o, i) => q.opts.indexOf(o) !== i);
|
||||||
|
if (dup !== -1) { bad.push(`[${set.name} #${q.id}] 选项重复: "${q.opts[dup]}"`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('总题数:', total);
|
||||||
|
if (bad.length) { console.log('问题:'); bad.forEach(b => console.log(' -', b)); }
|
||||||
|
else console.log('结构校验全部通过');
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['Dart 开发实战', 'var x = 10', 1],
|
||||||
|
['Dart 开发实战', 'orders = 60', 1],
|
||||||
|
['Dart 开发实战', 'remove(3)', 0],
|
||||||
|
['Dart 开发实战', '不存在的 key', 1],
|
||||||
|
['Dart 开发实战', 'if (isVip) 3', 0],
|
||||||
|
['Dart 开发实战', 'reduce((a, b)', 1],
|
||||||
|
['Dart 开发实战', 'factory 构造函数', 0],
|
||||||
|
['Dart 开发实战', 'mixin', 2],
|
||||||
|
['Dart 开发实战', 's!', 1],
|
||||||
|
['Dart 开发实战', 'late String token', 0],
|
||||||
|
['Flutter 开发实战', 'runApp(MyApp())', 0],
|
||||||
|
['Flutter 开发实战', 'crossAxisAlignment', 0],
|
||||||
|
['Flutter 开发实战', 'TextOverflow.ellipsis', 0],
|
||||||
|
['Flutter 开发实战', 'RefreshIndicator', 0],
|
||||||
|
['Flutter 开发实战', 'Navigator.pop(context, result)', 0],
|
||||||
|
['Flutter 开发实战', 'jsonDecode(text)', 0],
|
||||||
|
['Flutter 开发实战', '.timeout(Duration', 0],
|
||||||
|
['Flutter 开发实战', 'cacheWidth', 0],
|
||||||
|
['Flutter 开发实战', 'RichText', 0],
|
||||||
|
['Flutter 开发实战', 'dispose()', 0]
|
||||||
|
];
|
||||||
|
let fail = 0;
|
||||||
|
for (const [setName, keyword, expect] of checks) {
|
||||||
|
const set = d.sets.find(s => s.name === setName);
|
||||||
|
const q = set.questions.find(x => (x.q + x.opts.join('') + x.exp).includes(keyword));
|
||||||
|
if (!q) { console.log('✗ 未找到题:', setName, keyword); fail++; continue; }
|
||||||
|
const ok = q.ans === expect;
|
||||||
|
if (!ok) { console.log(`✗ [${setName}] ${keyword} → ans=${q.ans} 期望 ${expect},正确选项内容: "${q.opts[q.ans]}"`); fail++; }
|
||||||
|
}
|
||||||
|
console.log(fail ? `抽查 ${checks.length} 题,${fail} 题不符` : `抽查 ${checks.length} 道关键题答案全部正确`);
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3030;
|
||||||
|
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
||||||
|
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin123';
|
||||||
|
const DATA_FILE = path.join(__dirname, 'questions.json');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
|
const DIST = path.join(__dirname, 'dist');
|
||||||
|
if (fs.existsSync(DIST)) {
|
||||||
|
app.use(express.static(DIST));
|
||||||
|
app.get(/^\/(?!api\/).*/, (req, res) => {
|
||||||
|
res.sendFile(path.join(DIST, 'index.html'));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('未找到 dist/ 目录,请先执行 npm run build 再访问页面');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
if (!fs.existsSync(DATA_FILE)) return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const qs = raw;
|
||||||
|
const migrated = {
|
||||||
|
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: qs }],
|
||||||
|
nextSetId: 2,
|
||||||
|
nextQId: qs.length ? Math.max(...qs.map(q => q.id)) + 1 : 1
|
||||||
|
};
|
||||||
|
fs.writeFileSync(DATA_FILE, JSON.stringify(migrated, null, 2), 'utf8');
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
if (raw && Array.isArray(raw.sets)) return raw;
|
||||||
|
return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||||
|
} catch {
|
||||||
|
return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = loadData();
|
||||||
|
|
||||||
|
function saveData() {
|
||||||
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = new Set();
|
||||||
|
|
||||||
|
function findQuestion(qid) {
|
||||||
|
for (const set of data.sets) {
|
||||||
|
const q = set.questions.find(x => x.id === qid);
|
||||||
|
if (q) return { set, q };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublic(q) {
|
||||||
|
return { id: q.id, lv: q.lv, q: q.q, opts: q.opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBody(body) {
|
||||||
|
const { lv, q, opts, ans, exp } = body || {};
|
||||||
|
if (!q || typeof q !== 'string' || q.trim() === '') return '题目内容不能为空';
|
||||||
|
if (!Array.isArray(opts) || opts.length < 2) return '至少需要 2 个选项';
|
||||||
|
if (opts.some(o => typeof o !== 'string' || o.trim() === '')) return '选项不能为空';
|
||||||
|
if (typeof ans !== 'number' || !Number.isInteger(ans) || ans < 0 || ans >= opts.length) return '答案序号不正确';
|
||||||
|
if (lv !== undefined && (!Number.isInteger(lv) || lv < 1 || lv > 6)) return '难度需为 1-6 的整数';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuestion(body) {
|
||||||
|
const { lv, q, opts, ans, exp } = body;
|
||||||
|
return { id: data.nextQId++, lv: lv === undefined ? 1 : lv, q: q.trim(), opts, ans, exp: (exp || '').trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLvNames(v) {
|
||||||
|
if (v === undefined || v === null) return null;
|
||||||
|
if (!Array.isArray(v) || v.length !== 6 || v.some(x => typeof x !== 'string' || !x.trim())) {
|
||||||
|
return '难度名称必须为 6 个非空字符串';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/sets', (req, res) => {
|
||||||
|
res.json({ sets: data.sets.map(s => ({ id: s.id, name: s.name, count: s.questions.length, lvNames: s.lvNames || null })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/questions', (req, res) => {
|
||||||
|
const setId = req.query.set ? Number(req.query.set) : (data.sets[0] ? data.sets[0].id : null);
|
||||||
|
const set = data.sets.find(s => s.id === setId);
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
res.json({
|
||||||
|
set: { id: set.id, name: set.name, lvNames: set.lvNames || null },
|
||||||
|
count: set.questions.length,
|
||||||
|
questions: set.questions.map(toPublic)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/check', (req, res) => {
|
||||||
|
const { id, answer } = req.body || {};
|
||||||
|
const hit = findQuestion(id);
|
||||||
|
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
||||||
|
if (!Number.isInteger(answer)) return res.status(400).json({ error: 'answer 必须为数字' });
|
||||||
|
res.json({ correct: hit.q.ans === answer, answer: hit.q.ans, exp: hit.q.exp });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/login', (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (username === ADMIN_USER && password === ADMIN_PASS) {
|
||||||
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
|
tokens.add(token);
|
||||||
|
res.json({ token });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: '用户名或密码错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (token && tokens.has(token)) return next();
|
||||||
|
res.status(401).json({ error: '未登录或登录已过期' });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/admin/sets', requireAuth, (req, res) => {
|
||||||
|
res.json({ sets: data.sets });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/sets', requireAuth, (req, res) => {
|
||||||
|
const { name } = req.body || {};
|
||||||
|
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||||
|
const lvErr = validateLvNames(req.body.lvNames);
|
||||||
|
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||||
|
const set = { id: data.nextSetId++, name: name.trim(), questions: [] };
|
||||||
|
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||||
|
data.sets.push(set);
|
||||||
|
saveData();
|
||||||
|
res.json(set);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/sets/:id', requireAuth, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const set = data.sets.find(s => s.id === id);
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
const { name } = req.body || {};
|
||||||
|
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||||
|
const lvErr = validateLvNames(req.body.lvNames);
|
||||||
|
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||||
|
set.name = name.trim();
|
||||||
|
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||||
|
saveData();
|
||||||
|
res.json(set);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/sets/:id', requireAuth, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const idx = data.sets.findIndex(s => s.id === id);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
data.sets.splice(idx, 1);
|
||||||
|
saveData();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/import', requireAuth, (req, res) => {
|
||||||
|
const { name, questions } = req.body || {};
|
||||||
|
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||||
|
if (!Array.isArray(questions)) return res.status(400).json({ error: 'questions 必须为数组' });
|
||||||
|
const lvErr = validateLvNames(req.body.lvNames);
|
||||||
|
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const err = validateBody(questions[i]);
|
||||||
|
if (err) return res.status(400).json({ error: '第 ' + (i + 1) + ' 题格式错误:' + err });
|
||||||
|
}
|
||||||
|
const set = { id: data.nextSetId++, name: name.trim(), questions: questions.map(buildQuestion) };
|
||||||
|
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||||
|
data.sets.push(set);
|
||||||
|
saveData();
|
||||||
|
res.json({ set: { id: set.id, name: set.name, count: set.questions.length }, imported: set.questions.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const set = data.sets.find(s => s.id === id);
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
res.json({
|
||||||
|
name: set.name,
|
||||||
|
lvNames: set.lvNames || null,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
questions: set.questions.map(q => ({ lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp }))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/sets/:id/questions', requireAuth, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const set = data.sets.find(s => s.id === id);
|
||||||
|
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||||
|
const err = validateBody(req.body);
|
||||||
|
if (err) return res.status(400).json({ error: err });
|
||||||
|
const item = buildQuestion(req.body);
|
||||||
|
set.questions.push(item);
|
||||||
|
saveData();
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||||
|
const hit = findQuestion(Number(req.params.qid));
|
||||||
|
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
||||||
|
const err = validateBody(req.body);
|
||||||
|
if (err) return res.status(400).json({ error: err });
|
||||||
|
const { lv, q, opts, ans, exp } = req.body;
|
||||||
|
const item = hit.q;
|
||||||
|
item.lv = lv === undefined ? item.lv : lv;
|
||||||
|
item.q = q.trim();
|
||||||
|
item.opts = opts;
|
||||||
|
item.ans = ans;
|
||||||
|
item.exp = (exp || '').trim();
|
||||||
|
saveData();
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||||
|
const hit = findQuestion(Number(req.params.qid));
|
||||||
|
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
||||||
|
const idx = hit.set.questions.indexOf(hit.q);
|
||||||
|
hit.set.questions.splice(idx, 1);
|
||||||
|
saveData();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log('测验系统已启动: http://localhost:' + PORT);
|
||||||
|
console.log('答题页: http://localhost:' + PORT + '/');
|
||||||
|
console.log('管理后台: http://localhost:' + PORT + '/admin.html');
|
||||||
|
console.log('管理员账号: ' + ADMIN_USER + ' / ' + ADMIN_PASS);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'web',
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3030'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand-seal">砚</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-name">砚 · Dart 测验</div>
|
||||||
|
<div class="brand-sub">DART & FLUTTER QUIZ</div>
|
||||||
|
</div>
|
||||||
|
<nav class="app-nav">
|
||||||
|
<router-link class="nav-link" to="/">答题</router-link>
|
||||||
|
<router-link class="nav-link" to="/admin">管理</router-link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">砚台磨墨,静心作答 —— Dart & Flutter 学习测验</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const TOKEN_KEY = 'quiz_token'
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(path, options = {}) {
|
||||||
|
const headers = Object.assign({}, options.headers || {})
|
||||||
|
if (getToken()) headers.Authorization = 'Bearer ' + getToken()
|
||||||
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||||
|
const res = await fetch('/api' + path, Object.assign({}, options, { headers }))
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(data.error || '请求失败')
|
||||||
|
err.status = res.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPublic(path, options = {}) {
|
||||||
|
const res = await fetch('/api' + path, options)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || '请求失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const ALLOWED_TAGS = [
|
||||||
|
'b', 'strong', 'i', 'em', 'code', 'br', 'span', 'div', 'p',
|
||||||
|
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'blockquote', 'pre', 'u', 's', 'mark', 'small', 'sub', 'sup'
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^<>]*>/g
|
||||||
|
|
||||||
|
export function escapeSmartHtml(html) {
|
||||||
|
const placeholders = []
|
||||||
|
const text = String(html).replace(TAG_RE, (m, tag) => {
|
||||||
|
if (ALLOWED_TAGS.includes(tag.toLowerCase())) {
|
||||||
|
placeholders.push(m)
|
||||||
|
return '\u0000' + (placeholders.length - 1) + '\u0000'
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\u0000(\d+)\u0000/g, (_, i) => placeholders[Number(i)])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeEntities(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHtmlAndDecode(html) {
|
||||||
|
return decodeEntities(String(html).replace(/<[^>]*>/g, ''))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/theme.css'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import QuizView from './views/QuizView.vue'
|
||||||
|
import AdminView from './views/AdminView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'quiz', component: QuizView },
|
||||||
|
{ path: '/quiz', name: 'quiz-alt', component: QuizView },
|
||||||
|
{ path: '/admin', name: 'admin', component: AdminView }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
:root {
|
||||||
|
--surface: #faf7f0;
|
||||||
|
--surface-alt: #f3efe5;
|
||||||
|
--surface-high: #ece7d9;
|
||||||
|
--primary: #3d6b5e;
|
||||||
|
--on-primary: #ffffff;
|
||||||
|
--primary-container: #d9e9e2;
|
||||||
|
--on-primary-container: #123c31;
|
||||||
|
--secondary: #5c6b74;
|
||||||
|
--secondary-container: #e2e9ee;
|
||||||
|
--on-secondary-container: #1d2b33;
|
||||||
|
--ink: #28251f;
|
||||||
|
--ink-secondary: #6f6a5f;
|
||||||
|
--ink-faint: #9a9387;
|
||||||
|
--error: #b23a2f;
|
||||||
|
--error-container: #fbe4e0;
|
||||||
|
--on-error-container: #7c1f16;
|
||||||
|
--success: #4c7a4a;
|
||||||
|
--success-container: #e2eedc;
|
||||||
|
--on-success-container: #23411f;
|
||||||
|
--hairline: #e0dbcf;
|
||||||
|
--hairline-strong: #cfc8b8;
|
||||||
|
--shadow: 0 1px 2px rgba(40, 37, 31, .05), 0 8px 24px rgba(40, 37, 31, .07);
|
||||||
|
--shadow-lift: 0 2px 4px rgba(40, 37, 31, .06), 0 16px 40px rgba(40, 37, 31, .12);
|
||||||
|
--radius-s: 10px;
|
||||||
|
--radius-m: 16px;
|
||||||
|
--radius-l: 24px;
|
||||||
|
--serif: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||||
|
--sans: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--ink);
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 500px at 85% -10%, rgba(61, 107, 94, .10), transparent 60%),
|
||||||
|
radial-gradient(800px 480px at -10% 110%, rgba(92, 107, 116, .10), transparent 60%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 品牌头 ---------- */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 26px 32px 18px;
|
||||||
|
max-width: 860px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-seal {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(160deg, #b23a2f, #8f2b22);
|
||||||
|
color: #fdf6ec;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 24px;
|
||||||
|
box-shadow: 0 4px 12px rgba(178, 58, 47, .35);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
letter-spacing: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover { background: var(--surface-high); }
|
||||||
|
|
||||||
|
.nav-link.router-link-active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 卡片 ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 28px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card + .card { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* ---------- 按钮 ---------- */
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s, box-shadow .2s, background .2s, color .2s, opacity .2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active { transform: scale(.97); }
|
||||||
|
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-filled { background: var(--ink); color: var(--surface); }
|
||||||
|
.btn-filled:hover:not(:disabled) { box-shadow: var(--shadow-lift); }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--primary); color: var(--on-primary); }
|
||||||
|
.btn-primary:hover:not(:disabled) { box-shadow: var(--shadow-lift); }
|
||||||
|
|
||||||
|
.btn-tonal { background: var(--primary-container); color: var(--on-primary-container); }
|
||||||
|
.btn-tonal:hover:not(:disabled) { filter: brightness(.97); }
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
}
|
||||||
|
.btn-outline:hover:not(:disabled) { border-color: var(--ink); color: var(--ink); }
|
||||||
|
|
||||||
|
.btn-danger { background: var(--error-container); color: var(--on-error-container); }
|
||||||
|
.btn-danger:hover:not(:disabled) { filter: brightness(.97); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 7px 14px; font-size: 12.5px; border-radius: 10px; }
|
||||||
|
|
||||||
|
/* ---------- 表单 ---------- */
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, .textarea, .select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus, .textarea:focus, .select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 0 0 3px rgba(61, 107, 94, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea { min-height: 80px; resize: vertical; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ---------- 徽章/章 ---------- */
|
||||||
|
.seal {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal-lv1 { background: #e2e9ee; color: #2c4b5e; }
|
||||||
|
.seal-lv2 { background: #e2eedc; color: #2c5226; }
|
||||||
|
.seal-lv3 { background: #f6e9d7; color: #7a4a13; }
|
||||||
|
.seal-lv4 { background: #efe2ee; color: #5c2f5c; }
|
||||||
|
.seal-lv5 { background: #fbe4e0; color: #8c2c21; }
|
||||||
|
.seal-lv6 { background: #d9e9e2; color: #1e5241; }
|
||||||
|
|
||||||
|
.msg { font-size: 13px; margin-top: 8px; min-height: 18px; }
|
||||||
|
.msg.err { color: var(--error); }
|
||||||
|
.msg.ok { color: var(--success); }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.table tbody tr { transition: background .15s; }
|
||||||
|
.table tbody tr:hover { background: var(--surface-alt); }
|
||||||
|
|
||||||
|
/* ---------- 过渡动画 ---------- */
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.pop-enter-active { transition: all .35s cubic-bezier(.2, .9, .3, 1.3); }
|
||||||
|
.pop-enter-from { opacity: 0; transform: scale(.92); }
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popIn {
|
||||||
|
from { opacity: 0; transform: scale(.9); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shakeX {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-4px); }
|
||||||
|
75% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 48px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-header { padding: 20px 16px 12px; }
|
||||||
|
.card { padding: 20px; border-radius: 20px; }
|
||||||
|
.brand-name { font-size: 18px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import { api, getToken, setToken, clearToken } from '../api.js'
|
||||||
|
import { escapeSmartHtml, decodeEntities, stripHtmlAndDecode } from '../htmlUtil.mjs'
|
||||||
|
|
||||||
|
const authed = ref(false)
|
||||||
|
const loginUser = ref('')
|
||||||
|
const loginPass = ref('')
|
||||||
|
const loginMsg = ref('')
|
||||||
|
|
||||||
|
const sets = ref([])
|
||||||
|
const view = ref('list')
|
||||||
|
const currentSetId = ref(null)
|
||||||
|
const qlist = ref([])
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
const keyOf = i => String.fromCharCode(65 + i)
|
||||||
|
|
||||||
|
const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
||||||
|
|
||||||
|
const setForm = reactive({ show: false, editingId: null, title: '', name: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' })
|
||||||
|
const importForm = reactive({ show: false, text: '', name: '', msg: '' })
|
||||||
|
const qForm = reactive({
|
||||||
|
show: false, editingId: null, title: '',
|
||||||
|
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentSet = computed(() => sets.value.find(s => s.id === currentSetId.value) || null)
|
||||||
|
|
||||||
|
const lvNameOptions = computed(() => {
|
||||||
|
const names = currentSet.value?.lvNames
|
||||||
|
return (Array.isArray(names) && names.length === 6) ? names : DEFAULT_LV_NAMES
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
if (!getToken()) return
|
||||||
|
try {
|
||||||
|
await api('/admin/sets')
|
||||||
|
authed.value = true
|
||||||
|
} catch (e) {
|
||||||
|
clearToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
loginMsg.value = ''
|
||||||
|
if (!loginUser.value.trim() || !loginPass.value) { loginMsg.value = '请输入用户名和密码'; return }
|
||||||
|
try {
|
||||||
|
const data = await api('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value })
|
||||||
|
})
|
||||||
|
setToken(data.token)
|
||||||
|
authed.value = true
|
||||||
|
} catch (e) {
|
||||||
|
loginMsg.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout() {
|
||||||
|
clearToken()
|
||||||
|
authed.value = false
|
||||||
|
view.value = 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSets() {
|
||||||
|
const data = await api('/admin/sets')
|
||||||
|
sets.value = data.sets
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 套题:新增 / 重命名 / 删除 ---------- */
|
||||||
|
function showAddSet() {
|
||||||
|
setForm.show = true
|
||||||
|
setForm.editingId = null
|
||||||
|
setForm.title = '新增套题'
|
||||||
|
setForm.name = ''
|
||||||
|
setForm.lvNames = [...DEFAULT_LV_NAMES]
|
||||||
|
setForm.msg = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenameSet(id) {
|
||||||
|
const s = sets.value.find(x => x.id === id)
|
||||||
|
if (!s) return
|
||||||
|
setForm.show = true
|
||||||
|
setForm.editingId = id
|
||||||
|
setForm.title = '重命名套题'
|
||||||
|
setForm.name = s.name
|
||||||
|
setForm.lvNames = s.lvNames ? [...s.lvNames] : [...DEFAULT_LV_NAMES]
|
||||||
|
setForm.msg = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSet() {
|
||||||
|
setForm.msg = ''
|
||||||
|
if (!setForm.name.trim()) { setForm.msg = '请输入套题名称'; return }
|
||||||
|
const body = {
|
||||||
|
name: setForm.name.trim(),
|
||||||
|
lvNames: setForm.lvNames.map(n => n.trim())
|
||||||
|
}
|
||||||
|
if (body.lvNames.some(n => !n)) { setForm.msg = '6 个难度名称都不能为空'; return }
|
||||||
|
try {
|
||||||
|
if (setForm.editingId === null) {
|
||||||
|
await api('/admin/sets', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
} else {
|
||||||
|
await api('/admin/sets/' + setForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
setForm.show = false
|
||||||
|
loadSets()
|
||||||
|
} catch (e) {
|
||||||
|
setForm.msg = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSet(id) {
|
||||||
|
const s = sets.value.find(x => x.id === id)
|
||||||
|
if (!confirm(`确定删除套题「${s.name}」吗?其下 ${s.questions.length} 道题将一并删除,不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await api('/admin/sets/' + id, { method: 'DELETE' })
|
||||||
|
if (currentSetId.value === id) backToList()
|
||||||
|
loadSets()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 套题:导出 / 导入 ---------- */
|
||||||
|
async function exportSet(id) {
|
||||||
|
try {
|
||||||
|
const data = await api('/admin/sets/' + id + '/export')
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = data.name + '.json'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showImport() {
|
||||||
|
importForm.show = true
|
||||||
|
importForm.text = ''
|
||||||
|
importForm.name = ''
|
||||||
|
importForm.msg = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFile(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
importForm.text = reader.result
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(reader.result)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.name) {
|
||||||
|
importForm.name = parsed.name
|
||||||
|
}
|
||||||
|
} catch (err) { /* 稍后统一提示 */ }
|
||||||
|
importForm.msg = ''
|
||||||
|
}
|
||||||
|
reader.readAsText(file, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
importForm.msg = ''
|
||||||
|
const raw = importForm.text.trim()
|
||||||
|
if (!raw) { importForm.msg = '请粘贴 JSON 或选择文件'; return }
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
importForm.msg = 'JSON 解析失败:' + e.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let name, questions
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
questions = parsed
|
||||||
|
name = importForm.name.trim()
|
||||||
|
} else if (parsed && Array.isArray(parsed.questions)) {
|
||||||
|
questions = parsed.questions
|
||||||
|
name = importForm.name.trim() || parsed.name || ''
|
||||||
|
} else {
|
||||||
|
importForm.msg = '格式不支持:需要题目数组,或 {"name":..., "questions":[...]}'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!name) { importForm.msg = '请填写套题名称'; return }
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/import', { method: 'POST', body: JSON.stringify({ name, questions }) })
|
||||||
|
importForm.show = false
|
||||||
|
loadSets()
|
||||||
|
alert('导入成功:共 ' + res.imported + ' 道题')
|
||||||
|
} catch (e) {
|
||||||
|
importForm.msg = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 套题详情 ---------- */
|
||||||
|
async function openSet(id) {
|
||||||
|
currentSetId.value = id
|
||||||
|
view.value = 'detail'
|
||||||
|
await renderQuestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList() {
|
||||||
|
view.value = 'list'
|
||||||
|
currentSetId.value = null
|
||||||
|
loadSets()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderQuestions() {
|
||||||
|
if (currentSetId.value == null) return
|
||||||
|
const data = await api('/admin/sets')
|
||||||
|
const set = data.sets.find(s => s.id === currentSetId.value)
|
||||||
|
qlist.value = set ? set.questions : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 题目 ---------- */
|
||||||
|
function resetOpts(opts) {
|
||||||
|
qForm.opts = (opts || []).map((o, i) => ({ key: keyOf(i), val: stripHtmlAndDecode(o) }))
|
||||||
|
qForm.ans = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQuestionForm() {
|
||||||
|
qForm.show = true
|
||||||
|
qForm.editingId = null
|
||||||
|
qForm.title = '新增题目'
|
||||||
|
qForm.lv = '1'
|
||||||
|
qForm.q = ''
|
||||||
|
qForm.exp = ''
|
||||||
|
qForm.msg = ''
|
||||||
|
resetOpts(['', '', '', ''])
|
||||||
|
syncAns()
|
||||||
|
scrollFormIntoView()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOpt() {
|
||||||
|
qForm.opts.push({ key: keyOf(qForm.opts.length), val: '' })
|
||||||
|
syncAns()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOpt(i) {
|
||||||
|
qForm.opts.splice(i, 1)
|
||||||
|
qForm.opts.forEach((o, j) => { o.key = keyOf(j) })
|
||||||
|
syncAns()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAns() {
|
||||||
|
const n = qForm.opts.length
|
||||||
|
if (Number(qForm.ans) >= n) qForm.ans = n > 0 ? String(n - 1) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function editQuestion(id) {
|
||||||
|
const item = qlist.value.find(x => x.id === id)
|
||||||
|
if (!item) return
|
||||||
|
qForm.show = true
|
||||||
|
qForm.editingId = id
|
||||||
|
qForm.title = '编辑题目 #' + id
|
||||||
|
qForm.lv = String(item.lv || 1)
|
||||||
|
qForm.q = decodeEntities(item.q)
|
||||||
|
qForm.exp = decodeEntities(item.exp || '')
|
||||||
|
qForm.msg = ''
|
||||||
|
resetOpts(item.opts)
|
||||||
|
qForm.ans = String(item.ans)
|
||||||
|
scrollFormIntoView()
|
||||||
|
}
|
||||||
|
|
||||||
|
const qPreview = computed(() => escapeSmartHtml(decodeEntities(qForm.q)))
|
||||||
|
const expPreview = computed(() => escapeSmartHtml(decodeEntities(qForm.exp)))
|
||||||
|
|
||||||
|
const qRef = ref(null)
|
||||||
|
const expRef = ref(null)
|
||||||
|
const qFormRef = ref(null)
|
||||||
|
|
||||||
|
function scrollFormIntoView() {
|
||||||
|
nextTick(() => {
|
||||||
|
qFormRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCode(target) {
|
||||||
|
const el = target === 'q' ? qRef.value : expRef.value
|
||||||
|
if (!el) return
|
||||||
|
const start = el.selectionStart
|
||||||
|
const end = el.selectionEnd
|
||||||
|
const selected = qForm[target].slice(start, end)
|
||||||
|
qForm[target] = qForm[target].slice(0, start) + '<code>' + selected + '</code>' + qForm[target].slice(end)
|
||||||
|
el.focus()
|
||||||
|
const pos = start + '<code>'.length + selected.length + '</code>'.length - 2
|
||||||
|
el.setSelectionRange(pos, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuestion() {
|
||||||
|
qForm.msg = ''
|
||||||
|
const opts = qForm.opts.map(o => o.val.trim())
|
||||||
|
const body = {
|
||||||
|
lv: parseInt(qForm.lv, 10),
|
||||||
|
q: escapeSmartHtml(decodeEntities(qForm.q.trim())),
|
||||||
|
opts,
|
||||||
|
ans: parseInt(qForm.ans, 10),
|
||||||
|
exp: escapeSmartHtml(decodeEntities(qForm.exp.trim()))
|
||||||
|
}
|
||||||
|
if (!body.q) { qForm.msg = '请填写题干'; return }
|
||||||
|
if (opts.length < 2 || opts.some(o => !o)) { qForm.msg = '请填写所有选项(至少 2 个且不能为空)'; return }
|
||||||
|
try {
|
||||||
|
if (qForm.editingId === null) {
|
||||||
|
await api('/admin/sets/' + currentSetId.value + '/questions', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
} else {
|
||||||
|
await api('/admin/questions/' + qForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
qForm.show = false
|
||||||
|
renderQuestions()
|
||||||
|
} catch (e) {
|
||||||
|
qForm.msg = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQuestion(id) {
|
||||||
|
if (!confirm('确定删除题目 #' + id + ' 吗?此操作不可恢复。')) return
|
||||||
|
try {
|
||||||
|
await api('/admin/questions/' + id, { method: 'DELETE' })
|
||||||
|
renderQuestions()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkAuth()
|
||||||
|
if (authed.value) loadSets()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 登录 -->
|
||||||
|
<div v-if="!authed" class="card login-card">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
|
||||||
|
<div class="brand-seal small">印</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--serif);font-weight:900;font-size:20px;letter-spacing:2px;">管理员登录</div>
|
||||||
|
<div class="sub">登录后可管理套题与题目</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:20px;">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input class="input" v-model="loginUser" placeholder="admin" @keydown.enter="doLogin">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input class="input" type="password" v-model="loginPass" placeholder="请输入密码" @keydown.enter="doLogin">
|
||||||
|
</div>
|
||||||
|
<div class="msg err">{{ loginMsg }}</div>
|
||||||
|
<button class="btn btn-filled" style="width:100%;justify-content:center;margin-top:10px;" @click="doLogin">登 录</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 管理 -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<template v-if="view === 'detail'">
|
||||||
|
<button class="btn btn-outline btn-sm" @click="backToList">← 返回</button>
|
||||||
|
<span style="font-family:var(--serif);font-weight:700;font-size:17px;">{{ currentSet?.name }}</span>
|
||||||
|
<span style="font-size:12px;color:var(--ink-faint);">({{ qlist.length }} 题)</span>
|
||||||
|
</template>
|
||||||
|
<span v-else style="font-family:var(--serif);font-weight:900;font-size:19px;letter-spacing:2px;">套题集</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<template v-if="view === 'list'">
|
||||||
|
<button class="btn btn-tonal btn-sm" @click="showAddSet">+ 新增套题</button>
|
||||||
|
<button class="btn btn-outline btn-sm" @click="showImport">⬆ 导入套题</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn btn-tonal btn-sm" @click="showQuestionForm">+ 新增题目</button>
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-outline btn-sm" @click="doLogout">退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 套题表单(新增/重命名) -->
|
||||||
|
<div v-if="setForm.show" class="card" style="padding:22px 24px;margin-bottom:20px;">
|
||||||
|
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ setForm.title }}</div>
|
||||||
|
<div class="field" style="margin-top:12px;">
|
||||||
|
<label>套题名称</label>
|
||||||
|
<input class="input" v-model="setForm.name" placeholder="例如:Dart 语法测验" @keydown.enter="saveSet">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>6 个难度等级名称(答题页徽章与题目表单使用)</label>
|
||||||
|
<div v-for="(n, i) in setForm.lvNames" :key="i" class="lv-row">
|
||||||
|
<span class="lv-row-key">Lv{{ i + 1 }}</span>
|
||||||
|
<input class="input" v-model="setForm.lvNames[i]" :placeholder="'第 ' + (i + 1) + ' 级名称'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg err">{{ setForm.msg }}</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
|
<button class="btn btn-primary" @click="saveSet">保存</button>
|
||||||
|
<button class="btn btn-outline" @click="setForm.show = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导入表单 -->
|
||||||
|
<div v-if="importForm.show" class="card" style="padding:22px 24px;margin-bottom:20px;">
|
||||||
|
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</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>
|
||||||
|
<span style="font-size:12px;color:var(--ink-faint);">(或直接粘贴下方 JSON)</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
支持两种格式:<br>
|
||||||
|
① {"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}<br>
|
||||||
|
② 仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}](名称在下方填写)
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea" style="margin-top:10px;min-height:110px;" v-model="importForm.text" placeholder="粘贴 JSON 内容,或选择文件后自动填入"></textarea>
|
||||||
|
<div class="field" style="margin-top:10px;">
|
||||||
|
<label>套题名称(格式②或覆盖默认名时填写)</label>
|
||||||
|
<input class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg err">{{ importForm.msg }}</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
|
<button class="btn btn-primary" @click="doImport">开始导入</button>
|
||||||
|
<button class="btn btn-outline" @click="importForm.show = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 套题列表 -->
|
||||||
|
<div v-if="view === 'list'" class="card">
|
||||||
|
<div v-if="!sets.length" class="empty-state">暂无套题,点击「新增套题」创建第一套</div>
|
||||||
|
<div v-for="s in sets" :key="s.id" class="set-item">
|
||||||
|
<div class="set-mark">{{ s.questions.length }}</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="set-name">{{ s.name }}</div>
|
||||||
|
<div class="set-sub">共 {{ s.questions.length }} 题</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;">
|
||||||
|
<button class="btn btn-tonal btn-sm" @click="openSet(s.id)">编辑题目</button>
|
||||||
|
<button class="btn btn-outline btn-sm" @click="showRenameSet(s.id)">重命名</button>
|
||||||
|
<button class="btn btn-outline btn-sm" @click="exportSet(s.id)">导出</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="deleteSet(s.id)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 套题详情:题目管理 -->
|
||||||
|
<div v-else class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th style="width:50px;">ID</th><th style="width:70px;">难度</th><th>题干</th><th style="width:150px;">操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="q in qlist" :key="q.id">
|
||||||
|
<td style="color:var(--ink-faint);">{{ q.id }}</td>
|
||||||
|
<td><span class="seal" :class="'seal-lv' + (q.lv || 1)">Lv{{ q.lv || 1 }}</span></td>
|
||||||
|
<td style="color:var(--ink-secondary);line-height:1.6;">{{ stripHtmlAndDecode(q.q).slice(0, 60) }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
<button class="btn btn-outline btn-sm" @click="editQuestion(q.id)">编辑</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="deleteQuestion(q.id)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="!qlist.length" class="empty-state">暂无题目,点击「新增题目」添加第一道题</div>
|
||||||
|
|
||||||
|
<!-- 题目表单 -->
|
||||||
|
<div v-if="qForm.show" class="qform" ref="qFormRef">
|
||||||
|
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ qForm.title }}</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>难度</label>
|
||||||
|
<select class="select" v-model="qForm.lv">
|
||||||
|
<option v-for="(n, i) in lvNameOptions" :key="i" :value="String(i + 1)">{{ i + 1 }} - {{ n }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>题干(支持 HTML;代码里的 < > 会<b>自动转义</b>,不用手写 &lt;)</label>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<button class="btn btn-outline btn-sm" @click="insertCode('q')"><code> 插入代码块</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea" ref="qRef" v-model="qForm.q" placeholder="例如:Dart 程序的入口函数是?"></textarea>
|
||||||
|
<div v-if="qForm.q" class="preview">
|
||||||
|
<div class="preview-head">实时预览</div>
|
||||||
|
<div class="preview-body" v-html="qPreview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>选项(至少 2 个,可增减)</label>
|
||||||
|
<div v-for="(o, i) in qForm.opts" :key="i" class="opt-row">
|
||||||
|
<span class="opt-row-key">{{ o.key }}</span>
|
||||||
|
<input class="input" v-model="o.val" :placeholder="'选项 ' + o.key">
|
||||||
|
<button class="btn btn-danger btn-sm" @click="removeOpt(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline btn-sm" @click="addOpt">+ 添加选项</button>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>正确答案</label>
|
||||||
|
<select class="select" v-model="qForm.ans">
|
||||||
|
<option v-for="(o, i) in qForm.opts" :key="i" :value="String(i)">{{ o.key }} - {{ stripHtmlAndDecode(o.val) || '(空)' }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>解析(答题后展示,支持 HTML;< & > 自动转义)</label>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<button class="btn btn-outline btn-sm" @click="insertCode('exp')"><code> 插入代码块</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 <b>main()</b>。"></textarea>
|
||||||
|
<div v-if="qForm.exp" class="preview">
|
||||||
|
<div class="preview-head">实时预览</div>
|
||||||
|
<div class="preview-body" v-html="expPreview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg err">{{ qForm.msg }}</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:10px;">
|
||||||
|
<button class="btn btn-primary" @click="saveQuestion">保存</button>
|
||||||
|
<button class="btn btn-outline" @click="qForm.show = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-card { max-width: 400px; margin: 4vh auto 0; animation: fadeUp .4s ease; }
|
||||||
|
.brand-seal.small { width: 40px; height: 40px; font-size: 20px; border-radius: 11px; }
|
||||||
|
.sub { font-size: 12.5px; color: var(--ink-faint); margin-top: 2px; letter-spacing: 1px; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 6px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.set-item:last-child { border-bottom: none; }
|
||||||
|
.set-mark {
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary-container);
|
||||||
|
color: var(--on-primary-container);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.set-name { font-weight: 800; font-size: 15px; color: var(--ink); }
|
||||||
|
.set-sub { font-size: 12px; color: var(--ink-faint); margin-top: 2px; }
|
||||||
|
|
||||||
|
.import-zone {
|
||||||
|
border: 1.5px dashed var(--hairline-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.hint { font-size: 12px; color: var(--ink-faint); margin-top: 10px; line-height: 1.8; font-family: 'Consolas', monospace; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.qform {
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
padding-top: 20px;
|
||||||
|
animation: fadeUp .3s ease;
|
||||||
|
}
|
||||||
|
.opt-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.opt-row-key {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lv-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.lv-row-key {
|
||||||
|
width: 42px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px dashed var(--hairline-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.preview-head {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.preview-body { font-size: 13.5px; line-height: 1.8; color: var(--ink); }
|
||||||
|
.preview-body :deep(code) { background: var(--surface-high); padding: 1px 6px; border-radius: 5px; font-family: 'Consolas', monospace; font-size: .92em; color: var(--primary); }
|
||||||
|
.preview-body :deep(b) { color: var(--primary); }
|
||||||
|
.preview-body :deep(pre) { background: var(--surface-high); border-radius: 10px; padding: 12px; overflow-x: auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { apiPublic } from '../api.js'
|
||||||
|
import { stripHtmlAndDecode } from '../htmlUtil.mjs'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const sets = ref([])
|
||||||
|
const questions = ref([])
|
||||||
|
const activeSetId = ref(null)
|
||||||
|
const cur = ref(0)
|
||||||
|
const score = ref(0)
|
||||||
|
const userAnswers = ref([])
|
||||||
|
const answered = ref([])
|
||||||
|
const checking = ref(false)
|
||||||
|
const showResult = ref(false)
|
||||||
|
const shuffle = ref(localStorage.getItem('quiz-shuffle') !== '0')
|
||||||
|
|
||||||
|
function shuffleList(arr) {
|
||||||
|
const a = [...arr]
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[a[i], a[j]] = [a[j], a[i]]
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LV_NAMES = ['', '基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
|
||||||
|
const lvNames = ref(DEFAULT_LV_NAMES)
|
||||||
|
|
||||||
|
const keyOf = i => String.fromCharCode(65 + i)
|
||||||
|
|
||||||
|
const total = computed(() => questions.value.length)
|
||||||
|
const current = computed(() => questions.value[cur.value] || null)
|
||||||
|
const progress = computed(() => total.value ? ((cur.value + 1) / total.value * 100) : 0)
|
||||||
|
const pct = computed(() => total.value ? Math.round(score.value / total.value * 100) : 0)
|
||||||
|
const isLast = computed(() => cur.value === total.value - 1)
|
||||||
|
const answeredHere = computed(() => answered.value[cur.value] || false)
|
||||||
|
const isRight = computed(() => answeredHere.value && userAnswers.value[cur.value] === current.value.ans)
|
||||||
|
|
||||||
|
function gradeOf(p) {
|
||||||
|
if (p >= 90) return { title: '翰林之才', sub: 'Dart 已了然于心,可放心挥毫写 Flutter 了。' }
|
||||||
|
if (p >= 75) return { title: '文采斐然', sub: '基础扎实,不妨把错题解析再细读一遍。' }
|
||||||
|
if (p >= 60) return { title: '小有所成', sub: '已登堂入室,针对薄弱章节再下功夫。' }
|
||||||
|
if (p >= 40) return { title: '尚需磨墨', sub: '建议重读错题所在的难度阶段,温故知新。' }
|
||||||
|
return { title: '闻鸡起舞', sub: '从 Dart 基础重新研习,再来一场。' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function optClass(i) {
|
||||||
|
if (!answeredHere.value) return {}
|
||||||
|
if (i === current.value.ans) return { correct: true }
|
||||||
|
if (i === userAnswers.value[cur.value]) return { wrong: true }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSets() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await apiPublic('/sets')
|
||||||
|
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)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '题目加载失败,请检查网络后刷新'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuestions(setId) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
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 = '该套题暂无题目,请联系管理员添加'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '题目加载失败,请检查网络后刷新'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSet(id) {
|
||||||
|
if (id === activeSetId.value) {
|
||||||
|
loadQuestions(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (checking.value) return
|
||||||
|
if (isLast.value) showResult.value = true
|
||||||
|
else if (cur.value < total.value - 1) cur.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (cur.value > 0) cur.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
if (shuffle.value) questions.value = shuffleList(questions.value)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShuffle() {
|
||||||
|
localStorage.setItem('quiz-shuffle', shuffle.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.query.set, v => {
|
||||||
|
const id = Number(v)
|
||||||
|
if (id && id !== activeSetId.value && sets.value.some(s => s.id === id)) {
|
||||||
|
loadQuestions(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadSets)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="loading" class="empty-state">墨已研好,正在取题…</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="card empty-state">{{ error }}</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="card" style="padding:18px 24px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<span style="font-size:12.5px;color:var(--ink-secondary);font-weight:700;letter-spacing:1px;">选一套题</span>
|
||||||
|
<button
|
||||||
|
v-for="s in sets" :key="s.id"
|
||||||
|
class="chip" :class="{ active: s.id === activeSetId }"
|
||||||
|
@click="pickSet(s.id)">
|
||||||
|
{{ s.name }}<span class="chip-count">{{ s.count }}</span>
|
||||||
|
</button>
|
||||||
|
<label class="shuffle-toggle" title="开启后,每次进入或重开套题都会打乱题目顺序">
|
||||||
|
<input type="checkbox" v-model="shuffle" @change="toggleShuffle">
|
||||||
|
<span>打乱题目顺序</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="slide" mode="out-in">
|
||||||
|
<div v-if="!showResult" :key="cur" class="card quiz-card">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px;">
|
||||||
|
<div class="q-num">第 {{ cur + 1 }} 题 · 共 {{ total }} 题</div>
|
||||||
|
<div class="score-pill">{{ score }} 分</div>
|
||||||
|
</div>
|
||||||
|
<div class="bar"><div class="bar-fill" :style="{ width: progress + '%' }"></div></div>
|
||||||
|
|
||||||
|
<div class="q-head">
|
||||||
|
<span class="seal" :class="'seal-lv' + (current.lv || 1)">{{ lvNames[current.lv] || '基础' }}</span> </div>
|
||||||
|
|
||||||
|
<h2 class="q-title" v-html="current.q"></h2>
|
||||||
|
|
||||||
|
<div class="opts">
|
||||||
|
<button
|
||||||
|
v-for="(o, i) in current.opts" :key="i"
|
||||||
|
class="opt" :class="optClass(i)"
|
||||||
|
:disabled="answeredHere || checking"
|
||||||
|
@click="choose(i)">
|
||||||
|
<span class="opt-key">{{ keyOf(i) }}</span>
|
||||||
|
<span class="opt-text">{{ stripHtmlAndDecode(o) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="answeredHere" class="explain" :class="isRight ? 'ok' : 'no'">
|
||||||
|
<div class="explain-head">
|
||||||
|
<template v-if="isRight">✓ 回答正确</template>
|
||||||
|
<template v-else>✗ 回答错误 · 正确答案是 <b>{{ keyOf(current.ans) }}</b></template>
|
||||||
|
</div>
|
||||||
|
<div class="explain-body">
|
||||||
|
<span style="font-weight:700;margin-right:6px;">解析</span><span v-html="current.exp"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{{ isLast ? '看成绩' : '下一题' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="slide">
|
||||||
|
<div v-if="showResult" class="card result-card">
|
||||||
|
<div class="circle" :style="{ '--p': pct }">
|
||||||
|
<div class="circle-inner">
|
||||||
|
<div class="score-num">{{ score }}</div>
|
||||||
|
<div class="score-lbl">/ {{ total }} 题</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grade">{{ gradeOf(pct).title }}</div>
|
||||||
|
<div class="grade-sub">{{ gradeOf(pct).sub }}</div>
|
||||||
|
|
||||||
|
<div class="review">
|
||||||
|
<div class="review-head">答题回顾</div>
|
||||||
|
<div v-for="(q, i) in questions" :key="q.id" class="review-item">
|
||||||
|
<b style="color:var(--ink-faint);margin-right:6px;">{{ i + 1 }}.</b>
|
||||||
|
<span class="review-text">{{ stripHtmlAndDecode(q.q).slice(0, 40) }}</span>
|
||||||
|
<span v-if="userAnswers[i] === q.ans" class="review-verdict ok">✓ 对</span>
|
||||||
|
<span v-else class="review-verdict no">
|
||||||
|
✗ 错(你:{{ userAnswers[i] == null ? '未答' : keyOf(userAnswers[i]) }},正:{{ keyOf(q.ans) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin-top:20px;">
|
||||||
|
<button class="btn btn-filled" @click="restart">再答一场</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: var(--ink); color: var(--ink); }
|
||||||
|
.chip.active { background: var(--ink); border-color: var(--ink); color: var(--surface); }
|
||||||
|
.chip-count { font-size: 11px; opacity: .75; }
|
||||||
|
|
||||||
|
.shuffle-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.shuffle-toggle input { cursor: pointer; accent-color: var(--primary); }
|
||||||
|
|
||||||
|
.quiz-card { margin-top: 20px; animation: fadeUp .4s ease; }
|
||||||
|
.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);
|
||||||
|
color: var(--on-primary-container);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
}
|
||||||
|
.bar { height: 6px; background: var(--surface-high); border-radius: 99px; overflow: hidden; margin-bottom: 18px; }
|
||||||
|
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--primary), var(--ink)); transition: width .4s ease; border-radius: 99px; }
|
||||||
|
|
||||||
|
.q-head { margin-bottom: 10px; }
|
||||||
|
.q-title { font-size: 17px; line-height: 1.8; color: var(--ink); margin-bottom: 22px; font-weight: 600; }
|
||||||
|
.q-title :deep(code) { background: var(--surface-high); padding: 1px 7px; border-radius: 6px; font-family: 'Consolas', monospace; font-size: .92em; color: var(--primary); }
|
||||||
|
.q-title :deep(b) { color: var(--primary); }
|
||||||
|
|
||||||
|
.opts { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1.5px solid var(--hairline);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .18s;
|
||||||
|
}
|
||||||
|
.opt:hover:not(:disabled) { border-color: var(--primary); transform: translateX(3px); }
|
||||||
|
.opt:disabled { cursor: default; }
|
||||||
|
.opt-key {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all .18s;
|
||||||
|
}
|
||||||
|
.opt.selected { border-color: var(--primary); background: var(--primary-container); }
|
||||||
|
.opt.selected .opt-key { background: var(--primary); color: #fff; }
|
||||||
|
.opt.correct { border-color: var(--success); background: var(--success-container); }
|
||||||
|
.opt.correct .opt-key { background: var(--success); color: #fff; }
|
||||||
|
.opt.wrong { border-color: var(--error); background: var(--error-container); animation: shakeX .35s ease; }
|
||||||
|
.opt.wrong .opt-key { background: var(--error); color: #fff; }
|
||||||
|
|
||||||
|
.explain {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.9;
|
||||||
|
animation: fadeUp .3s ease;
|
||||||
|
}
|
||||||
|
.explain.ok { background: var(--success-container); border-color: var(--success); color: var(--on-success-container); }
|
||||||
|
.explain.no { background: var(--error-container); border-color: var(--error); color: var(--on-error-container); }
|
||||||
|
.explain-head { font-weight: 800; margin-bottom: 4px; }
|
||||||
|
.explain-body { opacity: .9; }
|
||||||
|
|
||||||
|
.btns { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||||
|
|
||||||
|
.result-card { margin-top: 20px; text-align: center; animation: popIn .4s ease; }
|
||||||
|
.circle {
|
||||||
|
width: 150px; height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 8px auto 18px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: conic-gradient(var(--primary) calc(var(--p) * 1%), var(--surface-high) 0);
|
||||||
|
}
|
||||||
|
.circle-inner {
|
||||||
|
width: 118px; height: 118px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.score-num { font-family: var(--serif); font-weight: 900; font-size: 40px; color: var(--ink); }
|
||||||
|
.score-lbl { font-size: 12.5px; color: var(--ink-faint); }
|
||||||
|
.grade { font-family: var(--serif); font-weight: 900; font-size: 26px; color: var(--ink); letter-spacing: 3px; }
|
||||||
|
.grade-sub { color: var(--ink-secondary); font-size: 13.5px; margin: 8px 0 20px; }
|
||||||
|
|
||||||
|
.review { text-align: left; border-top: 1px solid var(--hairline); padding-top: 14px; max-height: 300px; overflow-y: auto; }
|
||||||
|
.review-head { font-weight: 800; color: var(--primary); font-size: 13px; margin-bottom: 8px; letter-spacing: 1px; }
|
||||||
|
.review-item { font-size: 13px; padding: 9px 4px; border-bottom: 1px dashed var(--hairline); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.review-text { color: var(--ink-secondary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.review-verdict { font-weight: 800; font-size: 12px; flex-shrink: 0; }
|
||||||
|
.review-verdict.ok { color: var(--success); }
|
||||||
|
.review-verdict.no { color: var(--error); }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user