Compare commits

..
35 Commits
Author SHA1 Message Date
jacobxu666andClaude Haiku 4.5 c706f485cc 试卷题目配图(几何体):编辑器上传区+孩子端渲染+文件名校验+public挂卷持久化
test / test (push) Successful in 37s
uploadPaperFigure 存 public/exam/<卷>/<uuid>.ext;编辑器预览/移除;
quiz-block 渲染 q.figure;compose 挂 exam-figures 卷防重建丢失。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 16:08:27 +08:00
jacobxu666andClaude Haiku 4.5 890e7ef3ac 编辑卡片类型徽标不随题干换行(shrink-0+min-w-0);新增题目按钮去掉双加号(图标+文字)
test / test (push) Failing after 43s
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 15:59:20 +08:00
jacobxu666andClaude Haiku 4.5 95994b14d0 试卷编辑页三处布局优化:头部标题独立成行可换行、网格显式单列+元素自适应、题目编辑表单渲染在被编辑题下方
test / test (push) Failing after 6m52s
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 15:24:10 +08:00
jacobxu666 131ee41ac8 docs: 设计文档与 C1 裁定对齐(新建应用题固定 answer 模式,存量 rubric 只读)
test / test (push) Failing after 6m37s
2026-08-20 14:58:17 +08:00
jacobxu666andClaude Haiku 4.5 5ce33f3edf 编辑器存量 rubric 徽标/题型切换清理/分值输入修复(最终复核 R1-R3)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 14:57:40 +08:00
jacobxu666andClaude Haiku 4.5 5e4904742a 编辑器新建应用题仅 answer 模式+数据完整性修复(最终评审:rubric 0分守卫/答案下标/points/ossKey 前缀/测试修正)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 14:49:21 +08:00
jacobxu666 fe68ed7512 新增 Gitea Actions 测试 workflow:push 即跑 vitest 全量 2026-08-20 14:23:09 +08:00
jacobxu666andClaude Haiku 4.5 063420595d 移除多余测试依赖 @vitejs/plugin-react,改用内置 OXC JSX 配置(评审约束违反修复)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 14:20:53 +08:00
jacobxu666andClaude Haiku 4.5 0749ba2c38 家长端结构化题目编辑器:题型联动表单替代 JSON 文本域,应用题支持 judgeMode 切换
- 新增 question-list.tsx:结构化题目卡片列表(选择题/判断题/填空题/应用题)
- 新增 question-list.test.tsx:4 个 RTL 组件测试覆盖渲染/新增/编辑/校验
- paper-form.tsx:JSON textarea 替换为 QuestionList,保存时序列化 QuizQ[]
- vitest.config.ts:加 @vitejs/plugin-react 支持 JSX 组件测试
- test-setup.ts:注册 afterEach cleanup 避免跨测试 DOM 污染

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 14:16:13 +08:00
jacobxu666andClaude Haiku 4.5 255c9a244c 孩子端应用题 answer 模式:拍照+填最终答案,交卷归一化匹配秒判(rubric 流程不变)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 14:07:12 +08:00
jacobxu666andClaude Haiku 4.5 e896b2be51 题目校验抽取为可单测 lib;扩展 solve judgeMode(answer/rubric)分支校验
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 13:59:51 +08:00
jacobxu666andClaude Haiku 4.5 bb8bb25096 搭建 Vitest 测试框架;新增答案等价判定 lib(answer-match),QuizQ 支持 judgeMode
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 13:55:16 +08:00
jacobxu666 637c6032d4 docs: 结构化编辑器+答案判分实施计划(5任务含Vitest框架/组件测试/CI) 2026-08-20 13:49:01 +08:00
jacobxu666 611b493c84 docs: 设计文档补充自动化测试框架节(Vitest+jsdom+CI 测试 workflow) 2026-08-20 13:45:10 +08:00
jacobxu666andClaude Haiku 4.5 8de35e85c2 docs: 结构化题目编辑器+应用题答案判分模式设计文档
题型联动表单替代 JSON 文本域(首发 judge/choice/fill/solve);
应用题新增 judgeMode=answer(最终答案数组+归一化匹配秒判),rubric AI 判分共存。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 13:38:46 +08:00
jacobxu666andClaude Haiku 4.5 88c2ee8c52 补试卷题目 JSON 校验(choice options/order words)+ 删周测列表死导入(最终评审)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 17:13:42 +08:00
jacobxu666andClaude Haiku 4.5 3de907164b 删除试卷管理列表页未使用的 countQuestions 死代码(评审发现)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 17:04:27 +08:00
jacobxu666andClaude Haiku 4.5 522ff91525 家长端考试试卷管理:列表/新建/编辑(/parent/exams),题目 JSON 保存校验,孩子端即时生效
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 17:01:00 +08:00
jacobxu666andClaude Haiku 4.5 1b5e95725e 家长端考试记录管理页:/parent/exam-records 编辑分数达标+删除(卷已删兼容)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 16:55:16 +08:00
jacobxu666andClaude Haiku 4.5 abcdd5bed8 家长端每周检测成绩管理页:/parent/weekly-records 编辑分数达标+删除,替换课程管理只读块
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 16:51:50 +08:00
jacobxu666 859fdcbda7 家长端新增成绩与试卷管理 action:周测/考试记录改删+试卷保存删除(归属性校验+JSON校验) 2026-08-19 16:47:06 +08:00
jacobxu666 36ecc71139 docs: 家长端成绩与试卷管理实施计划(4任务:actions/周测管理/考试记录/试卷管理) 2026-08-19 16:45:29 +08:00
jacobxu666andClaude Haiku 4.5 60fa03786a docs: 家长端成绩与试卷管理设计文档(周测/考试记录编辑删除+试卷内容管理)
零新表零迁移;三套管理全走家长端独立页;编辑不联动积分打卡。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 16:41:07 +08:00
jacobxu666 348dad28fe docs: 新文件补末尾换行(评审 Minor) 2026-08-19 15:35:41 +08:00
jacobxu666 145ba1b7d1 家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入 2026-08-19 15:24:38 +08:00
jacobxu666 df41f7713d 新增家长注册页 /register:凭家庭邀请码注册加入并自动登录 2026-08-19 15:20:49 +08:00
jacobxu666andClaude Haiku 4.5 da7f7a6a19 新增 family_invite 家庭邀请码表(schema+0009迁移)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 15:17:38 +08:00
jacobxu666 8c9d8ece1a docs: 家庭管理实施计划(3任务:迁移/注册页/家庭管理卡片) 2026-08-19 14:36:42 +08:00
jacobxu666andClaude Haiku 4.5 4ac4e2091a docs: 家庭管理设计文档(邀请码注册第二位家长)
新表 family_invite+注册页 /register+家长端家庭管理卡片;
新家长与现有家长权限一致,本次不做成员移除。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 11:55:13 +08:00
jacobxu666andClaude Haiku 4.5 f6d5aa8680 家长中心待掌握错题查看:概览卡片可点击,进入孩子错题页并可标记已掌握
概览页「待掌握错题」卡片改 Link 进入 /parent/wrongbook?kid=xx(仅未掌握),
WrongRow 注入 markAction 由家长 action 标记,家庭归属防越权,孩子端零改动。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 11:23:59 +08:00
jacobxu666andClaude Opus 4.8 7a11a89031 新增积分系统:打卡/测试达标赚积分,纯累计可配置
- point_rule 规则表 + point_log 流水表(0008 迁移),唯一键防刷:同一事件首次达标才发放,重复静默返回 0
- 打卡分值随任务设置(模板/实例 points 字段,默认 10);测试类 6 种事件按规则表取值,各有默认分(单词 20/数学课 15/语法点 15/周测 50×2/考试通过 100)
- 发放埋点 6 处:打卡、单词检测、数学课、语法知识点、英语/数学周测、考试通过;周测达标联动打卡实例不重复计分
- 孩子端:/kid 总积分徽章,新增 /points 积分页(总积分+流水),测验结果页即时显示「+N 积分」,周测结果页刷新后从流水取回已发积分
- 家长端:功能管理新增「积分管理」Section(规则分值配置+每孩子总积分/最近流水);任务管理内联编辑任务分值
- 修复:drizzle 将 mysql2 重复键错误包进 cause,沿错误链识别 ER_DUP_ENTRY

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 17:58:53 +08:00
jacobxu666andClaude Haiku 4.5 841f937b8e docs: 家长中心待掌握错题查看设计文档
家长概览「待掌握错题」卡片当前不可点击,设计为新建
/parent/wrongbook?kid=xx 页面(仅展示未掌握错题、家长可标记已掌握),
复用孩子端 WrongRow 组件,家庭归属防越权。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 09:29:01 +08:00
jacobxu666andClaude Opus 4.8 5f207e4a57 课程管理新增单词与数学思维内容编辑:单词词库落库(word_entry+0007迁移,表空回退words.json),家长端按天增删改单词;数学思维编辑器对标语法编辑器;课程管理加入两张入口卡片
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 17:25:04 +08:00
jacobxu666andClaude Opus 4.8 ab84bec1b4 家长端课程管理移除跟读录音、语法检测分数、孩子错题本卡片,只保留课程编辑与每周检测成绩
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 16:24:31 +08:00
jacobxu666andClaude Opus 4.8 f80a931ef0 新增解答题拍照判分:百炼 Qwen-VL 按评分点逐点判分,无 API Key 时演示判分回退;混合卷按真实分值加权计分;判分照片与评分点进错题本;补齐历史迁移缺口(0005 模板生效方式/0006 五张缺失表+grammar_progress point_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 16:24:23 +08:00
90 changed files with 10050 additions and 872 deletions
+12
View File
@@ -0,0 +1,12 @@
# xuchao: 自动化测试——push 即跑,守护题型校验/答案判定/编辑器组件
name: test
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test
+3
View File
@@ -22,3 +22,6 @@ materials/
# playwright-mcp debug snapshots # playwright-mcp debug snapshots
.playwright-mcp/ .playwright-mcp/
# 本地开发解答题照片回退目录
.dev-uploads/
+6
View File
@@ -5,6 +5,8 @@ services:
container_name: superjump container_name: superjump
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
volumes:
- exam-figures:/app/public/exam
networks: [proxy, data] networks: [proxy, data]
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -21,3 +23,7 @@ networks:
data: data:
name: data name: data
external: true external: true
# 试卷配图持久化:容器内 public/exam 写卷挂载,重建不丢
volumes:
exam-figures:
@@ -0,0 +1,469 @@
# 家庭管理(邀请第二位家长)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 家长端可生成家庭邀请码,第二位家长凭码在 `/register` 注册加入家庭,登录后看到全部孩子数据(权限与现有家长一致)。
**Architecture:** 新增 `family_invite` 表(仿 `activation_code`:6 位码、一次性、15 分钟);新增 `/register` 家长注册页(server action 校验码→建 user→自动登录);家长端功能 tab 新增「家庭管理」Section(成员列表 + 生成邀请码按钮)。`users.familyId` 已按家庭隔离数据,第二位家长同 familyId 即自动可见全部数据,无需权限改造。
**Tech Stack:** Next.js 15.5 App Router、TypeScript、Drizzle ORM(mysql2)、bcryptjs、Tailwind/ui 组件(shadcn 风格)、zod。
**Spec:** `docs/superpowers/specs/2026-08-19-family-manage-design.md`
## Global Constraints
- 项目无自动化测试框架,验证靠 `npx tsc --noEmit` + `npm run build`(每任务末尾跑 tsc,最终任务跑 build)+ 手工清单。
- 新代码注释遵循项目风格,新函数加一行 `// xuchao:` 注释(用户全局规范)。
- 验证码/邀请码一律只存 sha256 哈希(`crypto.createHash("sha256").update(code).digest("hex")`),明文只在生成后一次性返回给调用方。
- 迁移文件格式:文件头 `-- xuchao:` 注释 + `CREATE TABLE IF NOT EXISTS` + 幂等,语句间 `--> statement-breakpoint` 分隔(参照 `drizzle/0008_points.sql`)。
- 邀请码规则固定:6 位数字、15 分钟过期、一次性(未用且未过期才算有效)。
- 手机号唯一(`uq_user_phone`),注册时先查占用,报「该手机号已注册」。
---
### Task 1:family_invite 表(schema + 迁移 0009)
**Files:**
- Modify: `src/db/schema.ts`(在 `activationCodes` 表定义之后追加)
- Create: `drizzle/0009_family-invite.sql`
**Interfaces:**
- Produces: `familyInvites` 表的 drizzle 对象(列:`id`/`familyId`/`codeHash`/`createdBy`/`createdAt`/`expiresAt`/`usedAt`),供 Task 2/3 的 server action 使用。
- [ ] **Step 1: schema.ts 追加 family_invite 表**
在 `src/db/schema.ts` 中 `activationCodes` 表定义(`// ===== 设备激活码` 那一节)结束之后、`// ===== 周模板` 注释之前插入:
```ts
// ===== 家庭邀请码(家长生成,其他家长凭码注册加入;一次性,15 分钟)=====
export const familyInvites = mysqlTable("family_invite", {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
familyId: bigint("family_id", { mode: "number" })
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
codeHash: varchar("code_hash", { length: 64 }).notNull(),
createdBy: bigint("created_by", { mode: "number" })
.notNull()
.references(() => users.id),
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: datetime("expires_at").notNull(),
usedAt: datetime("used_at"),
});
```
- [ ] **Step 2: 创建迁移文件 `drizzle/0009_family-invite.sql`**
```sql
-- xuchao: 家庭邀请码——家长生成一次性邀请码,其他家长凭码注册加入家庭。幂等。
CREATE TABLE IF NOT EXISTS `family_invite` (
`id` bigint AUTO_INCREMENT NOT NULL,
`family_id` bigint NOT NULL,
`code_hash` varchar(64) NOT NULL,
`created_by` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` datetime NOT NULL,
`used_at` datetime,
CONSTRAINT `family_invite_id` PRIMARY KEY(`id`)
);
```
- [ ] **Step 3: 类型检查**
Run: `npx tsc --noEmit`
Expected: 无输出(退出码 0)
- [ ] **Step 4: 提交**
```bash
git add src/db/schema.ts drizzle/0009_family-invite.sql
git commit -m "新增 family_invite 家庭邀请码表(schema+0009迁移)"
```
---
### Task 2:`/register` 注册页 + 登录页入口
**Files:**
- Create: `src/app/register/actions.ts`
- Create: `src/app/register/page.tsx`
- Modify: `src/app/login/page.tsx`(登录按钮下的提示区加链接)
**Interfaces:**
- Consumes: Task 1 的 `familyInvites`、现有 `users`/`createSession`、`bcryptjs`。
- Produces: `registerWithInvite(prev: RegisterState, formData: FormData): Promise<RegisterState>`(RegisterState = `{ error?: string } | null`)。
- [ ] **Step 1: 创建 `src/app/register/actions.ts`**
```ts
"use server";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { and, eq, gt, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/db";
import { familyInvites, users } from "@/db/schema";
import { createSession } from "@/lib/auth";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
const registerSchema = z.object({
displayName: z.string().trim().min(1, "请输入称呼").max(20, "称呼最多 20 字"),
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
password: z.string().min(6, "密码至少 6 位"),
code: z.string().regex(/^\d{6}$/, "邀请码是 6 位数字"),
});
export type RegisterState = { error?: string } | null;
// xuchao: 家长凭邀请码注册:校验码→查重手机号→事务(码标记已用+建家长)→自动登录跳家长中心
export async function registerWithInvite(
_prev: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const parsed = registerSchema.safeParse({
displayName: formData.get("displayName"),
phone: formData.get("phone"),
password: formData.get("password"),
code: formData.get("code"),
});
if (!parsed.success) return { error: parsed.error.issues[0].message };
const { displayName, phone, password, code } = parsed.data;
const [invite] = await db
.select({ id: familyInvites.id, familyId: familyInvites.familyId })
.from(familyInvites)
.where(
and(
eq(familyInvites.codeHash, hashCode(code)),
isNull(familyInvites.usedAt),
gt(familyInvites.expiresAt, new Date()),
),
)
.limit(1);
if (!invite) return { error: "邀请码无效或已过期,请让家长重新生成" };
const [dup] = await db.select({ id: users.id }).from(users).where(eq(users.phone, phone)).limit(1);
if (dup) return { error: "该手机号已注册" };
const passwordHash = await bcrypt.hash(password, 10);
const newUserId = await db.transaction(async (tx) => {
await tx.update(familyInvites).set({ usedAt: new Date() }).where(eq(familyInvites.id, invite.id));
const [created] = await tx
.insert(users)
.values({ familyId: invite.familyId, role: "parent", displayName, phone, passwordHash })
.$returningId();
return created.id;
});
await createSession(newUserId, "家长设备", 30);
redirect("/parent");
}
```
- [ ] **Step 2: 创建 `src/app/register/page.tsx`**
```tsx
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { BackgroundBlobs } from "@/components/background-blobs";
import { registerWithInvite } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const [state, formAction, pending] = useActionState(registerWithInvite, null);
return (
<main className="relative flex min-h-dvh items-center justify-center p-4">
<BackgroundBlobs />
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
<CardHeader className="items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-btn">
<UserPlus className="size-8 text-white" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight">加入家庭</h1>
<p className="text-sm text-muted-foreground">需要家里已有成员的邀请码</p>
</CardHeader>
<CardContent>
<form action={formAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">称呼</Label>
<Input id="displayName" name="displayName" placeholder="如:妈妈" required />
</div>
<div className="space-y-2">
<Label htmlFor="phone">手机号</Label>
<Input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
autoComplete="username"
placeholder="11 位手机号"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="至少 6 位"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code">邀请码</Label>
<Input
id="code"
name="code"
inputMode="numeric"
maxLength={6}
placeholder="6 位数字"
className="font-mono tracking-widest"
required
/>
</div>
{state?.error && <p className="text-sm font-medium text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? "注册中…" : "注册并加入家庭"}
</Button>
<p className="text-center text-sm text-muted-foreground">
已有账号?{" "}
<Link href="/login" className="font-semibold text-indigo-600 underline-offset-4 hover:underline">
去登录
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}
```
- [ ] **Step 3: 登录页加注册入口**
`src/app/login/page.tsx` 中「去输入激活码」那个 `<p>` 之后、`</form>` 之前插入:
```tsx
<p className="text-center text-sm text-muted-foreground">
家里另一位家长想加入?{" "}
<Link
href="/register"
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
>
用邀请码加入家庭
</Link>
</p>
```
- [ ] **Step 4: 类型检查**
Run: `npx tsc --noEmit`
Expected: 无输出(退出码 0)
- [ ] **Step 5: 提交**
```bash
git add src/app/register src/app/login/page.tsx
git commit -m "新增家长注册页 /register:凭家庭邀请码注册加入并自动登录"
```
---
### Task 3:家长端「家庭管理」Section
**Files:**
- Modify: `src/app/parent/actions.ts`(schema 导入行加 `familyInvites`;文件内新增 `generateFamilyInvite` action)
- Create: `src/app/parent/family-manage.tsx`
- Modify: `src/app/parent/dashboard.tsx`(新增 `FamilyData` 类型 + props + Section)
- Modify: `src/app/parent/page.tsx`(查询家庭名与家长列表,组装 `family` 传入 Dashboard)
**Interfaces:**
- Consumes: Task 1 的 `familyInvites`;现有 `getSessionUser`/`db`/`families`/`users`。
- Produces: `generateFamilyInvite(): Promise<{ code?: string; error?: string }>`;`FamilyManage({ familyName, parents, kids })` 组件;`FamilyData` 类型。
- [ ] **Step 1: parent/actions.ts 加 `generateFamilyInvite`**
先改 schema 导入行(文件顶部 `import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema";` → 追加 `familyInvites`);再在 `createActivationCode` action 之后追加:
```ts
// xuchao: 生成家庭邀请码(6 位,15 分钟一次性),对方凭码在 /register 注册加入
export async function generateFamilyInvite(): Promise<{ code?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.insert(familyInvites).values({
familyId: parent.familyId,
codeHash: hashCode(code),
createdBy: parent.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
return { code };
}
```
- [ ] **Step 2: 创建 `src/app/parent/family-manage.tsx`**
```tsx
"use client";
import { useState, useTransition } from "react";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { generateFamilyInvite } from "./actions";
// xuchao: 家庭管理——展示成员列表,生成一次性邀请码让其他家长注册加入
export function FamilyManage({
familyName,
parents,
kids,
}: {
familyName: string;
parents: { id: number; name: string }[];
kids: { id: number; name: string }[];
}) {
const [invite, setInvite] = useState<{ code?: string; error?: string } | null>(null);
const [pending, startTransition] = useTransition();
const gen = () => {
setInvite(null);
startTransition(async () => {
setInvite(await generateFamilyInvite());
});
};
return (
<div className="space-y-3">
<p className="text-sm text-slate-700">
<span className="font-semibold">{familyName}</span> · {parents.length} 位家长 · {kids.length} 个孩子
</p>
<div className="space-y-1.5">
{parents.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{p.name}</span>
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">家长</span>
</div>
))}
{kids.map((k) => (
<div key={k.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{k.name}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">孩子</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="secondary" disabled={pending} onClick={gen}>
<UserPlus className="size-4" /> {pending ? "生成中…" : "生成邀请码"}
</Button>
{invite?.error && <p className="text-sm font-medium text-destructive">{invite.error}</p>}
{invite?.code && (
<p className="text-sm text-slate-700">
邀请码{" "}
<span className="font-mono text-lg font-bold tracking-widest text-indigo-600">{invite.code}</span>
<span className="ml-1 text-xs text-muted-foreground">15 分钟有效,一次性</span>
</p>
)}
</div>
</div>
);
}
```
- [ ] **Step 3: dashboard.tsx 加 `FamilyData` 类型、props 与 Section**
a) `WeeklyRow` 类型定义之后追加:
```ts
// xuchao: 家庭管理数据——家庭名 + 家长成员 + 孩子成员
export type FamilyData = {
familyName: string;
parents: { id: number; name: string }[];
kids: { id: number; name: string }[];
};
```
b) 导入区加 `import { FamilyManage } from "./family-manage";`(与 `ChildManage` 导入相邻)。
c) `ParentDashboard` 函数签名 props 加 `family: FamilyData;`,`({ overview, templates, devices, weekly, points, family }: { ...; family: FamilyData; })`。
d) 「孩子管理」Section 之前插入:
```tsx
<Section title="家庭管理" desc="添加家庭其他家长,一起查看孩子的学习情况">
<FamilyManage
familyName={family.familyName}
parents={family.parents}
kids={family.kids}
/>
</Section>
```
- [ ] **Step 4: page.tsx 查询并传入 family 数据**
a) schema 导入行(第 4-16 行 `import { sessions, ... } from "@/db/schema";`)追加 `families`。
b) Dashboard 类型导入(第 27-34 行)追加 `FamilyData`。
c) 会话/周测查询之后、`return` 之前插入:
```ts
// ===== 家庭管理(家庭名 + 全部成员) =====
const [familyRow] = await db
.select({ name: families.name })
.from(families)
.where(eq(families.id, parent.familyId))
.limit(1);
const parentRows = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(and(eq(users.familyId, parent.familyId), eq(users.role, "parent")))
.orderBy(asc(users.id));
const family: FamilyData = {
familyName: familyRow?.name ?? "我的家庭",
parents: parentRows.map((p) => ({ id: p.id, name: p.displayName })),
kids: kids.map((k) => ({ id: k.id, name: k.name })),
};
```
d) `return` 里 `<ParentDashboard ... />` 追加 `family={family}` prop。
- [ ] **Step 5: 类型检查 + 构建**
Run: `npx tsc --noEmit`
Expected: 无输出
Run: `npm run build`
Expected: 构建成功,`/register`、`/parent` 均出现在路由表
- [ ] **Step 6: 提交**
```bash
git add src/app/parent
git commit -m "家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入"
```
---
## 手工测试清单(部署 jump.hn12301.net 前在本地 MySQL 可用时/或部署后执行)
1. 家长端功能 tab → 「家庭管理」:显示家庭名、家长/孩子成员与计数。
2. 点「生成邀请码」→ 显示 6 位码与「15 分钟有效,一次性」。
3. 浏览器开 `/register`:输称呼+新手机号+密码+邀请码 → 注册成功自动进 `/parent`,看到全部孩子与数据。
4. 第二家长登录(新手机号+密码)→ 能编辑课程/看积分,设备管理里出现两个家长会话。
5. 同一邀请码二次注册 → 「邀请码无效或已过期」;过期(15 分钟)同样报错。
6. 已注册手机号再注册 → 「该手机号已注册」;错误邀请码 → 「邀请码无效或已过期」。
7. 登录页出现「用邀请码加入家庭」链接,可达 `/register`;孩子设备激活流程不受影响。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
# 家长中心「待掌握错题」查看 - 设计文档
日期:2026-08-18
状态:已批准
## 背景
家长中心「学习概览」tab 有一张「待掌握错题」计数卡片(显示某个孩子未掌握错题数 `wrongOpen`),
目前**不可点击**。家长希望点击后能查看该孩子的错题信息。
相关现状:
- 孩子端已有完整错题本页 `/wrongbook`(仅 child 角色可进),展示组件 `WrongRow` 含
题干/标准答案/用户作答/解答题作答照片,并可「标记已掌握」。
- 标记动作 `toggleMastered`(src/app/wrongbook/actions.ts)是 child 角色限定、按会话孩子 id 锁定。
- 提交 ab84bec 移除了课程管理里的「孩子错题本」快照卡片,但概览 tab 的计数卡片仍在且不可点。
## 需求(已与用户确认)
1. 点击概览页「待掌握错题」卡片 → 进入该孩子(当前选中孩子)的错题查看页。
2. 页面**只展示待掌握(未掌握)的错题**,不含已掌握。
3. 家长可以「标记已掌握」,返回概览后计数同步下降。
4. 页面内不需要孩子切换(看别的孩子需返回概览重新点)。
## 方案选择
- **方案 A(采用):新建家长版错题页 `/parent/wrongbook?kid=<id>`**
- 权限模型清晰(家长页 = 家庭归属校验),遵循现有 `/parent/grammar`、`/parent/words` 目录模式;
孩子端逻辑零改动。
- 方案 B(弃):放开孩子端 `/wrongbook` 允许家长进入 → 孩子页面被家长/孩子两套模式搅乱。
- 方案 C(弃):概览页内点击后同页展开列表 → 破坏日历布局,无法分享/刷新。
## 设计
### 1. 入口:src/app/parent/overview-tab.tsx
「待掌握错题」卡片包 `<Link href={/parent/wrongbook?kid=${kid.id}}>`,样式保留,加 hover 微动效。
### 2. 新页面:src/app/parent/wrongbook/page.tsx(server component)
- 鉴权:未登录 `redirect("/login")`;非 parent `redirect("/kid")`(与其它家长页一致)。
- 从 `searchParams.kid` 取孩子 id,校验 `familyId === 家长.familyId` 且 `role === child`,
不合法一律 `redirect("/parent")` —— 防越权查看别的家庭孩子。
- 数据:`listWrong(kidId)` 过滤 `mastered === false`(复用现有 lib,不加新查询层)。
- 渲染:header「← 返回家长中心」+「待掌握错题」+ 孩子名 + 数量;无错题显示空态
(🎉 没有待掌握错题);每题复用 `WrongRow`,解答题作答照片(OSS 签名 URL)逻辑同孩子端。
### 3. 新 action:src/app/parent/wrongbook/actions.ts
- `markMastered(id: number, mastered: boolean)`:
1. 取会话家长,非家长直接 return;
2. 按错题 id 查记录拿 `childUserId`;
3. 校验该孩子属于本家庭,否则 return;
4. 调现有 `setMastered(id, childUserId, mastered)`;
5. `revalidatePath("/parent")` + `revalidatePath("/parent/wrongbook")`。
### 4. 组件复用:src/app/wrongbook/wrong-row.tsx
- `WrongRow` 增加可选 prop `markAction?: (id: number, mastered: boolean) => Promise<void>`,
缺省走现有孩子端 `toggleMastered`;家长页传入 `markMastered`。
- 孩子端行为零变化;家长版因只过滤未掌握,revalidate 后已标记行自然消失,
组件内不加任何分支逻辑。
### 5. 不改动
- DB schema(wrongBook 表已含 mastered 字段)
- src/lib/wrongbook.ts(listWrong / setMastered 已够用)
## 测试清单(手工,项目无自动化测试框架)
1. 家长登录 → 点卡片 → 看到该孩子待掌握错题(含解答题照片)。
2. 标记已掌握 → 行消失;返回概览计数 -1。
3. 多孩子:选中 A 孩子进入 A 的错题;篡改 `?kid=` 为别的家庭孩子 → 被重定向回 `/parent`。
4. 全部掌握后 → 🎉 空态。
5. 孩子账号 / 未登录访问 `/parent/wrongbook` → 正确重定向。
6. 部署 jump.hn12301.net 后线上复验。
@@ -0,0 +1,90 @@
# 家庭管理:邀请第二位家长加入 - 设计文档
日期:2026-08-19
状态:已批准
## 背景
家长端目前只有 seed 脚本创建的首位家长(手机号+密码登录),家庭内无法自助添加其他家长(如配偶)。
用户希望:能添加家庭其他家长,第二位家长也能查看孩子的学习情况。
关键现状:
- `users.familyId` 已支持一家庭多家长;`/parent` 所有查询按 familyId 隔离,
**第二位家长只要拿到同一 familyId 即自动看到全部孩子数据,无需权限改造**。
- 首位家长账号由 `scripts/seed.ts` 创建,生产无注册页。
- 孩子加入:家长生成 6 位激活码(`activation_code` 表,一次性+15分钟) → 孩子设备输入 → 2 年设备会话。
- `families` 表已有 `name` 字段(seed 写入),尚未展示。
## 需求(已与用户确认)
1. 家长端「家庭管理」可生成邀请码,第二位家长凭码注册加入家庭。
2. 新加入家长权限与现有家长完全一样(概览/课程/积分/设备/孩子管理全可见)。
3. 本次不做成员移除(用户选定)。
4. 邀请码:6 位数字、一次性、15 分钟有效(与孩子激活码一致)。
## 方案选择
- **方案 A(采用):独立邀请码表 `family_invite` + 新注册页 `/register`**
- 仿 `activation_code` 既有模式,注册页表单(称呼+手机号+密码+邀请码),对方自设密码。
- 方案 B(弃):家长代建账号直接填手机号+初始密码 → 对方无法自选密码,需线下传递。
- 方案 C(弃):复用 `activation_code` 表 → 该表按 childUserId 绑定,语义冲突。
## 设计
### 1. 数据模型(schema.ts + 迁移 0009_family-invite.sql)
```ts
export const familyInvites = mysqlTable("family_invite", {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
familyId: bigint("family_id", { mode: "number" })
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
codeHash: varchar("code_hash", { length: 64 }).notNull(),
createdBy: bigint("created_by", { mode: "number" })
.notNull()
.references(() => users.id),
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: datetime("expires_at").notNull(),
usedAt: datetime("used_at"),
});
```
### 2. 注册页:`src/app/register/page.tsx` + `actions.ts`(仿 join,server action + useActionState)
- 表单:称呼、手机号(11 位)、密码(≥6 位)、邀请码(6 位数字)。
- `registerWithInvite(prev, formData)` 逻辑:
1. zod 校验表单;邀请码 sha256 查询 `family_invite`(未用且未过期),失败统一报「邀请码无效或已过期,请让家长重新生成」;
2. 校验手机号未被占用(`users.phone` 唯一),占用报「该手机号已注册」;
3. 事务:标记邀请码已用 → 插入 `user(role=parent, familyId=邀请码家庭, phone, passwordHash)` → `createSession(30 天)` → `redirect("/parent")`。
- 密码 bcrypt 哈希(与 login 一致)。
### 3. 登录页入口
- `src/app/login/page.tsx` 底部「去输入激活码」旁加「没有账号?用邀请码加入家庭」链接 → `/register`。
### 4. 家长端「家庭管理」卡片
- `src/components` 或 `src/app/parent/family-manage.tsx`(client 组件,仿 child-manage):
- 展示:家庭名 + 成员列表(家长显示称呼+「家长」徽标,孩子显示名字+「孩子」徽标);
- 「生成邀请码」按钮 → 调 `createFamilyInvite` action → 内联显示 6 位码 +「15 分钟有效,一次性」;
- 允许同时存在多个未用邀请码,不做互斥。
- dashboard.tsx「功能」tab 中与孩子管理并列插入该卡片;`/parent/page.tsx` 查询家庭成员数据传入:
- `parents`: `users.role=parent AND familyId=parent.familyId`
- `kids`: 已有查询复用
- `familyName`: `families` 表按 familyId 查询
- action `createFamilyInvite`(parent/actions.ts):取会话家长 → 生成 6 位随机数字码(与 createActivationCode 同法,15 分钟过期) → 存 hash → 返回码明文给调用方展示。
### 5. 不改动
- 孩子激活流程、`login` 逻辑、`activation_code` 表、`devices-tab`。
## 测试清单(手工,项目无自动化测试框架)
1. 家长端功能 tab → 家庭管理卡片:显示家庭名与全部成员。
2. 生成邀请码 → 显示 6 位码;15 分钟后(或码用过后)注册报「邀请码无效或已过期」。
3. `/register` 成功注册 → 自动登录进入 `/parent`,看到全部孩子与数据(概览/错题/周测)。
4. 第二家长登录后:设备管理能看到两家长会话;课程/积分改动互相可见。
5. 手机号已注册(任意家庭) → 报「该手机号已注册」。
6. 孩子设备激活流程不受影响;登录页新链接可达 `/register`。
7. 部署 jump.hn12301.net 后线上复验。
@@ -0,0 +1,81 @@
# 家长端成绩与试卷管理 - 设计文档
日期:2026-08-19
状态:已批准
## 背景
家长端「课程管理」只有一张只读的最近 10 条周测成绩列表,考试板块(试卷+作答记录)在家长端完全没有入口。
用户要求:每周检测成绩、考试中心对应的数据都可管理(已确认:成绩记录编辑+删除、试卷内容管理一次全做)。
关键现状:
- `weekly_test`:孩子周测自动写入(child+weekKey 唯一),达标联动发积分 + 当日英语打卡。
- `exam_paper`:试卷内容存库(meta + questions quiz JSON);`exam_record`:孩子作答(child+paperId 唯一),达标联动发积分 + 当日数学打卡。
- 家长端已有内容编辑器成熟模式(grammar/words/math:列表页 → 详情编辑页,保存即生效)。
- `quiz-parse` 只提供 `extractQuiz(md)` 与 `QuizQ` 类型,无独立 JSON 校验器 → 校验在保存 action 内做形状校验。
## 需求(已与用户确认)
1. **每周检测成绩管理**:家长可编辑(分数 + 达标状态)、删除记录。
2. **考试成绩记录管理**:同款编辑 + 删除。
3. **考试试卷内容管理**:新增/编辑/删除试卷(元信息表单 + 题目 JSON 文本域,保存时校验)。
4. 编辑成绩/达标**不联动**积分与打卡回滚(用户选定,简单优先)。
5. 题目编辑用 **JSON 文本域 + 保存时校验**(用户选定,非结构化逐题表单)。
## 方案选择
- **方案 A(采用):三套管理全部进家长端独立页,零新表零迁移**
- 成绩记录管理仿 wrongbook/family-manage 模式(server action + 行内编辑);试卷管理仿 grammar/math 编辑器(列表 → 详情编辑)。
- 方案 B(弃):结构化逐题表单编辑题目 → 表单工程量大,两倍工作量。
- 方案 C(弃):成绩管理塞进课程管理 Section 行内 → 交互空间挤、可扩展性差。
## 设计
### 1. 周测成绩管理 `/parent/weekly-records`
- 入口:课程管理 Section 的只读周测列表块删除,替换为「每周检测成绩管理」入口卡片(与课程编辑卡片同风格)。
- 页面(server component):鉴权同其它家长页;查询 `weekly_test` join `users`(本家庭全部孩子),按 takenAt 倒序最近 100 条,按孩子分组展示。
- 每行:孩子名 · 周 key · 分数 · 达标徽标 · 日期;操作:编辑(行内展开分数输入 + 达标开关 + 保存/取消)、删除(按钮二次确认)。
- 分数编辑:整数 0-1000 表单校验;达标独立开关,不自动按 80 分计算(家长手动决定,与"不联动回滚"精神一致)。
- 删除后孩子端 `getWeeklyRecord` 为空 → 可重测,唯一索引重建记录。
### 2. 考试成绩记录管理 `/parent/exam-records`
- 同款独立页。查询 `exam_record` join `users`(孩子)+ `exam_paper`(**left join**,卷被删时 title 显示「已删试卷」)。
- 每行:孩子名 · 试卷标题 · 分数 · 达标 · 日期;编辑(分数+达标)+ 删除(确认)。
- 删除后孩子端 `getExamRecord` 为空 → 可重考。
### 3. 考试试卷管理 `/parent/exams`
- 列表页:按月份分组(复用 exam 页 MONTH_LABEL 分组逻辑,固定 g7/s1/math,与孩子端一致),每组内每卷:标题 · 类型徽标 · 题数;编辑/删除按钮;顶部「+ 新建试卷」。
- 编辑页 `/parent/exams/[paperId]`:元信息表单(标题/考试类型/地区/年份/时长/满分/来源链接/考查说明/月份/序号)+ **题目 JSON 文本域**(初始值 `JSON.stringify(questions, null, 2)`)。
- 保存时校验(server action):`JSON.parse` → 必须是数组 → 每题形状校验(`stem`/`type`/`answer`/`explain` 非空 + type 条件字段,如 fill 的 `answers`、order/match 的 `answer` 数组、solve 的 `reference`)→ 失败返回具体错误信息不保存。
- 文本域下方实时显示"共 N 题"统计(客户端解析,失败显示红色提示)。
- 新建页 `/parent/exams/new`:同表单,paperId 手填(校验格式 `[a-z0-9-]+`、唯一),科目/年级/学期/月份/序号默认值。
- 删除试卷:允许(记录 left join 已容错);孩子端 `/exam` 列表即时消失,已打开的卷页 404。
### 4. parent/actions.ts 新增 6 个 action(全部家长身份 + 家庭归属校验)
| action | 说明 |
| --- | --- |
| `updateWeeklyRecord(id, score, passed)` / `deleteWeeklyRecord(id)` | 周测记录;按 id 查 childUserId → 归属校验 |
| `updateExamRecord(id, score, passed)` / `deleteExamRecord(id)` | 考试记录;同上 |
| `saveExamPaper(formData)` | 新增/更新试卷(带 paperId 唯一性检查 + 题目 JSON 校验) |
| `deleteExamPaper(paperId)` | 删除试卷(仅家长 + 本家庭无需校验——试卷非家庭私有,家长即可) |
- 编辑/删除周测与考试记录后 `revalidatePath("/parent")` + 对应管理页;试卷保存后 `revalidatePath("/parent/exams")` + `revalidatePath("/exam")`(孩子端立即生效)。
### 5. 不改动
- DB schema(三张表已有足够的字段)、`weekly/actions.ts`、`exam/actions.ts`、`lib/weekly.ts`、孩子端任何页面。
- 积分/打卡链路不动。
## 测试清单(手工,项目无自动化测试框架)
1. 课程管理 Section:三张入口卡片(周测管理/考试记录管理/试卷管理)替代原只读周测块。
2. 周测管理:编辑分数/达标 → 显示更新;删除 → 记录消失,孩子端 weekly 页可重测;越权孩子 id 篡改 → 拒绝。
3. 考试记录管理:编辑/删除生效;删除试卷后再看记录 → 标题显示「已删试卷」。
4. 试卷管理:新建试卷(JSON 合法)→ 孩子端 /exam 立即出现;编辑保留;JSON 非法 → 报错不保存;删除 → 孩子端消失;paperId 重复 → 报错。
5. 孩子达标积分/当日打卡在编辑周测/考试记录后不变(不联动,预期行为)。
6. 部署 jump.hn12301.net 后线上复验。
@@ -0,0 +1,103 @@
# 结构化题目编辑器 + 应用题答案判分模式 - 设计文档
日期:2026-08-20
状态:已批准
## 背景
`/parent/exams/[paperId]` 编辑页目前用**裸 JSON 文本域**录入题目,家长需手写 quiz JSON 数组(曾明确拒绝结构化表单,现转向)。同时用户对应用题判分提出新模式:孩子拍照上传解题过程 + 填写最终答案来判定对错(替代/补充现有 AI 逐评分点判分)。
现状事实(grilling 确认):
- 现有 86 道种子题 = choice 48 + fill 30 + solve 8,**judge/order/match 零数据**。
- solve 题现状:孩子拍照 → 百炼 Qwen-VL 按 rubric 评分点逐点判分(ai-grade.ts,无 key 演示回退),照片与逐点判定进错题本。
- 孩子端 QuizBlock 已支持全部 6 题型答题 + 对错反馈;fill 题的参考答案本身是**数组**(answers)。
- 现有 svaeEqxamPaper 的 validateQuestionsJson 已做形状校验。
## 需求(已与用户确认)
1. **结构化编辑器**:试卷题目改为逐题表单(题型联动字段),不再手写 JSON。题目卡片流:新增/编辑/删除/上移/下移。
2. **首发四种题型**:判断题/选择题/填空题/应用题(judge/order/match 暂不做,有数据时后置)。
3. **应用题新增判定模式** `judgeMode: "answer"`:家长只填**最终答案数组**(复用 fill 的 answers 语义)+ 分值;孩子拍照解题过程 + 填最终答案,**归一化匹配秒级判对错**,不调 AI。
4. **共存**:现有 rubric 模式(AI 逐点评分)保留,存量 8 道题零改动(`judgeMode` 缺省 = rubric)。
5. 孩子端此模式下:拍照必填(进错题本),最终答案判对错;对 = 满分,错 = 0 分(不做部分给分,可解释)。
## 方案选择
- **方案 A(采用):结构化表单产出仍是 QuizQ JSON,存储/孩子端渲染零schema改动**
- 编辑 UI 从 JSON textarea 换成题型联动表单;服务端校验扩展 judgeMode 分支;判分在现有 child 提交链路内按 judgeMode 分流。
- 方案 B(弃):引入独立"题目表"(questions 拆表)→ 迁移 86 题、错题本/组卷/判分全部重写,收益为零。
- 方案 C(弃):AI 判最终答案(取代归一化)→ 有延迟、依赖 key、判定无法对孩子解释;归一化数组对家庭场景足够。
## 设计
### 1. QuizQ 扩展(src/lib/quiz-parse.ts)
```ts
// solve 新增可选字段(缺省即 rubric 老模式,向后兼容):
judgeMode?: "rubric" | "answer";
// judgeMode==="answer" 时复用:
answers?: string[]; // 最终答案数组(与 fill 同字段语义,等价答法都填)
// points 两种模式下都必须(分值);reference 为展示用参考解答
```
### 2. 归一化判定 lib(新建 src/lib/answer-match.ts)
```ts
// xuchao: 答案等价判定——数值类归一化(分数/小数/百分数互转),文本类去空格/全角/大小写
export function normalizeAnswer(s: string): string;
export function answersMatch(expected: string[], given: string): boolean; // 任一期望答案等价即中
```
- 纯数字语义(`1/2`→`0.5`、`50%`→`0.5`、`0.50`→`0.5`,去尾零)互转比较。
- 含字母/单位词(如 `10个`、`across`)按文本归一(去空白、全角→半角、小写)严格比较;等价形式靠家长在答案数组里补填。
- **fill 现有判分不动**(grammar.ts `gradeOne`:trim+小写精确匹配)——归一化仅用于 judgeMode==="answer" 的应用题,避免改变既有周测/记录判分行为。
### 3. 孩子端判分流(exam/actions.ts takeExam + QuizBlock)
- `takeExam` 中 solve 题分流:
- `judgeMode==="answer"`:`answersMatch(q.answers, 孩子填的答案)` → score=points(对)/0(错);判定与照片写 `solveSnap`(`hits:[{ point:"最终答案", score, hit, reason }]`),照片 OSS 照常上传。
- 缺省/`rubric`:走现有 ai-grade 逐点评分,零改动。
- QuizBlock solve 分支:answer 模式下拍照必填 + 显示最终答案 Input;rubric 模式现状不变。
- 错题本:**零改动**(solveSnap 结构兼容;answerText 已对 solve 显示拍照与得分)。
### 4. 家长端结构化编辑器(/parent/exams/[paperId] 重构)
- PaperForm 拆为:试卷 MetaForm(现有字段,不动)+ `QuestionList`(题目卡片区)。
- QuestionList:
- 每题卡片:题型徽标 + 题干摘要 + 操作(编辑/上移/下移/删除)。
- 「+ 新增题目」按钮;题型下拉(judge/choice/fill/solve 四项)。
- QuestionForm 按 type 联动:
- **judge**:题干 + 解析 + 对/错单选
- **choice**:题干 + 选项列表(增/删/编辑,单选正确答案)+ 解析
- **fill**:题干 + 参考答案数组(增/删/编辑)+ 解析
- **solve**:题干 + 分值 + **新建题固定「仅最终答案(秒判)」模式**(最终答案数组 + 参考解答可选);存量 rubric 题只读徽标「AI 逐评分点判分(存量题,保留原评分点)」+ 参考解答(必填语义),不提供评分点编辑(2026-08-20 用户裁定:新题仅 answer,无判分模式开关)
- 非法时客户端给出对应提示(与服务端同文案);保存仍走现有 `saveExamPaper`(JSON 序列化 QuizQ 数组)。
- 服务端 validateQuestionsJson 扩展:
- solve:rubric 模式沿用现行(reference + points 必填);answer 模式要求 `answers` 非空数组 + points 必填,reference 可选;answer 模式不得带 rubric。
- 其余题型校验不变。
### 5. 不改动
- DB schema(quiz JSON 存储不变)、ai-grade.ts、错题本 WrongRow、积分/达标规则、周测组卷。
- 孩子端之外:seed、overview、记录管理全不动。
### 6. 自动化测试框架(用户追加要求:没有就搭建)
- 框架:**Vitest**(TS 原生、零配置成本),+ jsdom + @testing-library/react 用于组件测试(QuestionForm/QuestionList 题型联动)。
- 关键可测逻辑**沉淀为纯函数**进 lib,不做 DB/网络依赖:
- 新增 `src/lib/answer-match.ts`(归一化/等价,已设计于 §2)
- 现 `validateQuestionsJson`(parent/actions.ts 私有)→ 迁到新 `src/lib/question-validation.ts` 导出,`saveExamPaper` 引用之(行为不变)
- 测试覆盖:answer-match 全分支(数值/文本/单位/边界);question-validation 全题型 + judgeMode 新分支(accept/reject 用例);QuestionForm 题型联动渲染与非法输入提示。
- 脚本:`"test": "vitest run"`;vitest.config.ts 配 `@` alias。
- CI:Gitea Actions 新增 `.gitea/workflows/test.yaml`,push 即跑 `npm ci && npm run test`(runner 为 node:24-slim,已可用)。
## 测试清单(手工 + 自动化)
- 自动化:上述 Vitest 用例全绿;`npm run test` 通过。
- 手工:编辑页 JSON 文本域消失,题目卡片流可新增/编辑/删除/上下移/保存。
- 手工:四种题型表单字段联动正确;应用题的 rubric/answer 开关切换字段正确。
- 手工:新建 answer 应用题(答案 0.5 与 1/2 都填):孩子端拍照 + 填 `1/2` → 判对满分;填 `2/3` → 判错 0 分,错题本含照片与判定。
- 手工:存量 rubric 题:孩子照旧拍照 AI 逐点评分,进错题本逐点判定。
- 手工:服务端校验:answer 模式缺 answers / rubric 模式缺 reference → 对应报错不保存。
- 部署 jump.hn12301.net 后线上复验。
+73
View File
@@ -0,0 +1,73 @@
# 解答题拍照判分方案(设计决策记录)
> 2026-08-06 用户授权:全部按推荐方案落地,无需逐项确认。本文档记录已确认的全部决策。
## 一、目标
考试板块支持**数学解答题**(计算/解方程/应用题):孩子在纸上写完整解答过程,手机拍照上传,AI 视觉模型按评分点部分给分。首期试点一张**初一入学考(小升初分班考)卷**,现有 5 张月考卷不动。
## 二、已确认决策(逐条)
| # | 决策点 | 结论 |
| --- | --- | --- |
| 1 | 题型范围 | 困难档:解答题**过程判分**(不是只判最终答案) |
| 2 | 作答输入 | **纸上手写 + 拍照**(不做屏幕手写、不做分步结构化输入) |
| 3 | AI 模型 | **阿里云百炼 Qwen-VL**(qwen-vl-max),用户 Tokenplan 计费;经 OpenAI 兼容端点调用 |
| 4 | 答题流程 | **混合模式**:客观题(选择/判断/填空)App 内作答;**解答题逐题拍照**,不做整页拍 |
| 5 | 判分规则 | **部分给分 + 评分点(rubric)锚定**:AI 逐点评判,只能返回离散分数,不给自由打分权 |
| 6 | 题目配图 | 原卷裁剪为主、SVG 兜底,存 `public/exam/<卷id>/`;几何题判分时**原图+孩子照片一起喂模型**。本期入学卷无图题 |
| 7 | 内容生产 | **零人工**:抓卷/转录 → **双模型背靠背独立验算,不一致即丢弃**;本期因网络策略无法直抓教育站,内容改为**本地 Obsidian 数学素材 + 经典分班考题型按真实卷格式组卷**,已在试卷 summary 标注 |
| 8 | 计分 | 分值跟随原卷;总分 = 客观题 + 解答题部分得分;**达标线保持 80%**;exam_record 存每题照片 URL + 评分点命中明细 |
| 9 | 错题本 | v1 **只存不重做**:存题目 + 当时照片 + AI 评判 + 参考解答;保留手动"已掌握";重做重判放二期 |
| 10 | 质量控制 | AI 返回状态:`ok / blank(空白) / blurry(模糊) / printed(印刷体拒判) / unrelated(无关内容)`;非手写拒判;**每人每天限 30 次判分**;客户端压缩照片 ≤2MB |
| 11 | 时效 | 同步等待 + "AI 判分中"动画;失败自动重试 1 次,再失败允许单题重拍重交,不阻塞其它题 |
| 12 | 范围 | 解答题只进考试板块;课时练习/周测暂不加 |
## 三、数据模型
### QuizQ 扩展(`src/lib/quiz-parse.ts`)
```ts
{
type: "solve",
stem: string, // 题干
figure?: string, // 配图路径(可选,本期不用)
points: number, // 本题满分(分值跟随原卷)
reference: string, // 完整参考解答(判后展示)
rubric: { point: string; score: number }[], // 评分点,合计 = points
explain: string // 讲解
}
```
### 新表 `ai_grade_log`(迁移 0004)
判分审计 + 限频依据:child_user_id、paper_id、question_index、oss_key、status、earned、points、verdict(JSON:逐点命中+点评)、mock(是否演示判分)、created_at。
### exam_record.answers
解答题条目:`{ type:"solve", qIndex, ossKey, earned, hits:[{point,score,hit,reason}] }`;客观题保持原样。**成绩以服务端 ai_grade_log 为准,不信任客户端上报的分数**(防篡改)。
## 四、判分链路
```
孩子拍照 → 客户端压缩(≤2MB,canvas) → 服务端签发 OSS 直传 URL → PUT OSS
→ gradeSolve action:校验会话/限频(30/天) → 调百炼 Qwen-VL
prompt = 题干 + (原图) + 参考解答 + 评分点 + 孩子照片
→ 结构化 JSON:{status, hits:[{hit,reason}], earned, comment}
→ 写 ai_grade_log → 返回前端即时显示逐点得分
→ 全卷提交 takeExam:客观题即时判 + 解答题取 ai_grade_log 最近一次 ok 判定
→ 总分百分制,≥80 达标,错题(含照片)进 wrong_book,达标联动数学打卡
```
- **无 DASHSCOPE_API_KEY 时进入演示判分(mock)**:返回确定性模拟判定并标记 `mock=true`,供本地开发/测试;生产必须在服务器 .env 配置真 key。
- OSS 未配置时(本地开发)走 `/api/dev-photo` 本地落盘回退,不影响生产。
## 五、内容来源说明(本期入学卷)
本 session 网络策略拦截了对教育网站(学科网/组卷网/瑞文/51test 等)的直接抓取,无法获取原始真实卷页面。本期入学卷改为:**按真实分班考的卷面结构(填空+选择+计算+应用,满分 100)** 组卷,题目取自本地 Obsidian 数学素材库的经典题型(鸡兔同笼、工程、分数百分数、行程等,均带验算)。`sourceUrl` 置空并在 `summary` 注明。待网络恢复后可用同流程替换为真实卷转录。
## 六、上线前待办(需用户)
1. 百炼控制台开通/确认 Tokenplan 覆盖 `qwen-vl-max`,取 API Key;
2. 服务器 `/opt/services/superjump/.env` 增加 `DASHSCOPE_API_KEY=...`,重建容器;
3. 跑 `npm run db:seed-exam` 灌入新卷(容器重建时自动 migrate)。
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `ai_grade_log` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`paper_id` varchar(191) NOT NULL,
`question_index` int NOT NULL,
`oss_key` varchar(300) NOT NULL,
`status` varchar(20) NOT NULL,
`earned` int NOT NULL DEFAULT 0,
`points` int NOT NULL DEFAULT 0,
`verdict` text,
`mock` boolean NOT NULL DEFAULT false,
`model` varchar(60),
`ms` int,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `ai_grade_log_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `ai_grade_log` ADD CONSTRAINT `ai_grade_log_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX `idx_ai_grade_child_date` ON `ai_grade_log` (`child_user_id`,`created_at`);
--> statement-breakpoint
CREATE INDEX `idx_ai_grade_paper_q` ON `ai_grade_log` (`child_user_id`,`paper_id`,`question_index`);
+11
View File
@@ -0,0 +1,11 @@
-- xuchao: 补齐 task_template 的模板生效方式字段。生产库曾手工 ALTER 过这三列,
-- 故用 information_schema 判断实现幂等:已存在则跳过,保证生产与全新库都能安全执行。
SET @has_mode = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_template' AND column_name = 'mode');
--> statement-breakpoint
SET @ddl = IF(@has_mode = 0, 'ALTER TABLE `task_template` ADD COLUMN `mode` varchar(10) NOT NULL DEFAULT ''weekly'', ADD COLUMN `date_start` date, ADD COLUMN `date_end` date', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
+118
View File
@@ -0,0 +1,118 @@
-- xuchao: 修复历史迁移缺口。早期这些表是用 drizzle push/手工 DDL 建进生产的,
-- 仓库里一直没有对应迁移,导致全新数据库建不出这些表。本迁移全部幂等:
-- 表已存在则 CREATE TABLE IF NOT EXISTS 跳过,生产库重复执行无副作用。
CREATE TABLE IF NOT EXISTS `grammar_point` (
`id` bigint AUTO_INCREMENT NOT NULL,
`point_id` varchar(191) NOT NULL,
`level` varchar(20) NOT NULL,
`level_name` varchar(50) NOT NULL,
`title` varchar(200) NOT NULL,
`difficulty` int NOT NULL DEFAULT 1,
`summary` text,
`prereq` text,
`md` mediumtext NOT NULL,
`seq` int NOT NULL,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `grammar_point_id` PRIMARY KEY(`id`),
CONSTRAINT `uq_grammar_point_id` UNIQUE(`point_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `weekly_test` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`week_key` varchar(12) NOT NULL,
`score` int,
`correct` int,
`total` int,
`passed` boolean NOT NULL DEFAULT false,
`questions` mediumtext,
`taken_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `weekly_test_id` PRIMARY KEY(`id`),
CONSTRAINT `uq_weekly_child_week` UNIQUE(`child_user_id`,`week_key`),
CONSTRAINT `fk_wt_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `word_progress` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`day` int NOT NULL,
`learned` mediumtext,
`score` int,
`passed` boolean NOT NULL DEFAULT false,
`completed_at` datetime,
CONSTRAINT `word_progress_id` PRIMARY KEY(`id`),
CONSTRAINT `uq_word_child_day` UNIQUE(`child_user_id`,`day`),
CONSTRAINT `fk_wp_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `word_recording` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`word` varchar(60) NOT NULL,
`day` int,
`oss_key` varchar(300) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `word_recording_id` PRIMARY KEY(`id`),
KEY `idx_rec_child` (`child_user_id`),
CONSTRAINT `fk_wr_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `wrong_book` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`source_type` varchar(10) NOT NULL,
`source_id` varchar(60) NOT NULL,
`source_title` varchar(200),
`question` mediumtext NOT NULL,
`user_answer` mediumtext,
`correct_answer` mediumtext,
`mastered` boolean NOT NULL DEFAULT false,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `wrong_book_id` PRIMARY KEY(`id`),
KEY `idx_wrong_child` (`child_user_id`,`mastered`),
CONSTRAINT `fk_wb_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
-- grammar_progress:0002 误用 seq 列建表,生产实际是 point_id。仅在缺 point_id 时转换。
-- 顺序关键:先加 point_id 与 uq_grammar_child_point(child_user_id 为最左前缀,可承接外键),
-- 之后才能安全删除被外键依赖的旧索引 uq_grammar_child_seq,全程不动外键本身。
SET @gp_has_point = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND column_name = 'point_id');
--> statement-breakpoint
SET @ddl = IF(@gp_has_point = 0, 'ALTER TABLE `grammar_progress` ADD COLUMN `point_id` varchar(191) NOT NULL', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
--> statement-breakpoint
SET @gp_has_point_idx = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND index_name = 'uq_grammar_child_point');
--> statement-breakpoint
SET @ddl = IF(@gp_has_point_idx = 0, 'ALTER TABLE `grammar_progress` ADD CONSTRAINT `uq_grammar_child_point` UNIQUE (`child_user_id`,`point_id`)', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
--> statement-breakpoint
SET @gp_has_seq_idx = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND index_name = 'uq_grammar_child_seq');
--> statement-breakpoint
SET @ddl = IF(@gp_has_seq_idx > 0, 'ALTER TABLE `grammar_progress` DROP INDEX `uq_grammar_child_seq`', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
--> statement-breakpoint
SET @gp_has_seq = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND column_name = 'seq');
--> statement-breakpoint
SET @ddl = IF(@gp_has_seq > 0, 'ALTER TABLE `grammar_progress` DROP COLUMN `seq`', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
+12
View File
@@ -0,0 +1,12 @@
-- xuchao: 单词词库落库,供家长端按天编辑。幂等:已存在则跳过。
-- 初始数据由 scripts/seed-words.ts 从 src/content/words/words.json 灌入;
-- 表空时 lib/words.ts 自动回退读 JSON,部署顺序不影响孩子端使用。
CREATE TABLE IF NOT EXISTS `word_entry` (
`id` bigint AUTO_INCREMENT NOT NULL,
`word` varchar(60) NOT NULL,
`meaning` varchar(300) NOT NULL DEFAULT '',
`day` int NOT NULL,
`seq` int NOT NULL,
CONSTRAINT `word_entry_id` PRIMARY KEY(`id`),
KEY `idx_word_entry_day` (`day`)
);
+42
View File
@@ -0,0 +1,42 @@
-- xuchao: 积分系统——规则表 + 流水表(唯一键防刷) + 任务表加 points 列。全部幂等。
CREATE TABLE IF NOT EXISTS `point_rule` (
`id` bigint AUTO_INCREMENT NOT NULL,
`event_type` varchar(30) NOT NULL,
`points` int NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `point_rule_id` PRIMARY KEY(`id`),
UNIQUE KEY `uq_point_rule_event` (`event_type`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `point_log` (
`id` bigint AUTO_INCREMENT NOT NULL,
`child_user_id` bigint NOT NULL,
`event_type` varchar(30) NOT NULL,
`ref_key` varchar(100) NOT NULL,
`points` int NOT NULL,
`remark` varchar(200) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `point_log_id` PRIMARY KEY(`id`),
UNIQUE KEY `uq_point_log_dedup` (`child_user_id`, `event_type`, `ref_key`),
KEY `idx_point_log_child` (`child_user_id`)
);
--> statement-breakpoint
SET @has_tpl_points = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_template' AND column_name = 'points');
--> statement-breakpoint
SET @ddl = IF(@has_tpl_points = 0, 'ALTER TABLE `task_template` ADD COLUMN `points` int NOT NULL DEFAULT 10', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt FROM @ddl;
--> statement-breakpoint
EXECUTE stmt;
--> statement-breakpoint
DEALLOCATE PREPARE stmt;
--> statement-breakpoint
SET @has_inst_points = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_instance' AND column_name = 'points');
--> statement-breakpoint
SET @ddl2 = IF(@has_inst_points = 0, 'ALTER TABLE `task_instance` ADD COLUMN `points` int NOT NULL DEFAULT 10', 'SELECT 1');
--> statement-breakpoint
PREPARE stmt2 FROM @ddl2;
--> statement-breakpoint
EXECUTE stmt2;
--> statement-breakpoint
DEALLOCATE PREPARE stmt2;
+11
View File
@@ -0,0 +1,11 @@
-- xuchao: 家庭邀请码——家长生成一次性邀请码,其他家长凭码注册加入家庭。幂等。
CREATE TABLE IF NOT EXISTS `family_invite` (
`id` bigint AUTO_INCREMENT NOT NULL,
`family_id` bigint NOT NULL,
`code_hash` varchar(64) NOT NULL,
`created_by` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` datetime NOT NULL,
`used_at` datetime,
CONSTRAINT `family_invite_id` PRIMARY KEY(`id`)
);
+42
View File
@@ -29,6 +29,48 @@
"when": 1785772800000, "when": 1785772800000,
"tag": "0003_math-and-exam", "tag": "0003_math-and-exam",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1785859200000,
"tag": "0004_ai-grade-log",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1786060800000,
"tag": "0005_task-template-mode",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1786064400000,
"tag": "0006_fill-missing-tables",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1786147200000,
"tag": "0007_word-entry",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1786233600000,
"tag": "0008_points",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1786320000000,
"tag": "0009_family-invite",
"breakpoints": true
} }
] ]
} }
+2515 -599
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -11,7 +11,8 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:seed": "tsx scripts/seed.ts", "db:seed": "tsx scripts/seed.ts",
"db:seed-math": "tsx scripts/seed-math.ts", "db:seed-math": "tsx scripts/seed-math.ts",
"db:seed-exam": "tsx scripts/seed-exam.ts" "db:seed-exam": "tsx scripts/seed-exam.ts",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/plus-jakarta-sans": "^5.3.0", "@fontsource-variable/plus-jakarta-sans": "^5.3.0",
@@ -34,13 +35,18 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vitest": "^4.1.11"
} }
} }
+2 -1
View File
@@ -12,7 +12,8 @@ if (!url) {
const connection = await mysql.createConnection(url); const connection = await mysql.createConnection(url);
try { try {
await migrate(drizzle(connection), { migrationsFolder: "/app/drizzle" }); // xuchao: 容器内固定 /app/drizzle;本地测试可用 MIGRATIONS_DIR 覆盖
await migrate(drizzle(connection), { migrationsFolder: process.env.MIGRATIONS_DIR || "/app/drizzle" });
console.log("数据库迁移完成"); console.log("数据库迁移完成");
} finally { } finally {
await connection.end(); await connection.end();
+33
View File
@@ -0,0 +1,33 @@
// xuchao: 单词词库种子——words.json 灌入 word_entry;仅表空时全量灌入,已有数据视为家长已编辑,跳过
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import raw from "../src/content/words/words.json";
import { wordEntry } from "../src/db/schema";
async function main() {
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
const db = drizzle(conn);
const [cnt] = await db.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
if (cnt.n > 0) {
console.log("word_entry 已有", cnt.n, "条,跳过灌入(视为已编辑数据)");
await conn.end();
return;
}
const rows = (raw as { word: string; meaning: string; day: number; seq: number }[]).map((w) => ({
word: w.word,
meaning: w.meaning,
day: w.day,
seq: w.seq,
}));
// 分批插入,避免单条 SQL 过大
for (let i = 0; i < rows.length; i += 500) {
await db.insert(wordEntry).values(rows.slice(i, i + 500));
}
console.log("word_entry 灌入完成:", rows.length, "条");
await conn.end();
}
main();
+49
View File
@@ -0,0 +1,49 @@
// xuchao: 本地开发照片回退——OSS 未配置时,解答题照片直传/查看走本地磁盘
// 生产环境配置了 OSS 或 NODE_ENV=production 时,本路由一律 404
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { ossConfigured } from "@/lib/oss";
const BASE = path.join(process.cwd(), ".dev-uploads");
function disabled() {
return ossConfigured() || process.env.NODE_ENV === "production";
}
// xuchao: 键名白名单校验 + 防路径穿越
function safePath(key: string): string | null {
if (!/^exam-solve\/\d+\/[\w-]+\/q\d+-[0-9a-f]{16}\.jpg$/.test(key)) return null;
const p = path.normalize(path.join(BASE, key));
return p.startsWith(BASE + path.sep) ? p : null;
}
// xuchao: 直传入口:multipart file + key
export async function POST(req: NextRequest) {
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
const key = form.get("key");
if (!(file instanceof File) || typeof key !== "string") {
return NextResponse.json({ error: "缺少文件或键名" }, { status: 400 });
}
const target = safePath(key);
if (!target) return NextResponse.json({ error: "键名不合法" }, { status: 400 });
fs.mkdirSync(path.dirname(target), { recursive: true });
const buf = Buffer.from(await file.arrayBuffer());
if (buf.byteLength > 8 * 1024 * 1024) {
return NextResponse.json({ error: "照片过大" }, { status: 400 });
}
fs.writeFileSync(target, buf);
return NextResponse.json({ ok: true, key });
}
// xuchao: 查看入口:?key=xxx
export async function GET(req: NextRequest) {
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
const key = req.nextUrl.searchParams.get("key") ?? "";
const target = safePath(key);
if (!target || !fs.existsSync(target)) return NextResponse.json({ error: "不存在" }, { status: 404 });
const buf = fs.readFileSync(target);
return new NextResponse(buf, { headers: { "Content-Type": "image/jpeg" } });
}
+187 -9
View File
@@ -1,18 +1,107 @@
"use server"; "use server";
// xuchao: 考试判分——按卷面题判分,错题进错题本,达标联动数学打卡 // xuchao: 考试判分——客观题即时判分;解答题拍照后 AI 按评分点判分,成绩以服务端日志为准
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { gradeAnswers, type AnswerValue } from "@/lib/grammar"; import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { getPaper, paperQuestions, recordExam } from "@/lib/exam"; import { solveAnswerHit } from "@/lib/answer-match";
import type { SolveAnswerPayload } from "@/lib/quiz-parse";
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
import { recordWrong } from "@/lib/wrongbook"; import { recordWrong } from "@/lib/wrongbook";
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
import { ossConfigured, photoViewUrl, solveKey, uploadUrl } from "@/lib/oss";
import { db } from "@/db";
import { aiGradeLog } from "@/db/schema";
// xuchao: 每人每天最多判分次数(防滥用)
const GRADE_DAILY_LIMIT = 30;
export type SolveDetail = {
qIndex: number;
ossKey: string;
status: string;
earned: number;
points: number;
hits: GradeVerdict["hits"];
comment: string;
};
export type ExamResult = export type ExamResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | {
score: number;
passed: boolean;
correct: number;
total: number;
detail: boolean[];
solves?: SolveDetail[];
pointsEarned: number;
}
| { error: string }; | { error: string };
// 提交试卷:服务端判分、记录成绩、错题入库;可重考,取最近一次成绩 // xuchao: 签发解答题照片直传地址(或本地开发回退)
export async function takeExam(paperId: string, answers: AnswerValue[]): Promise<ExamResult> { export async function getSolveUploadUrl(
paperId: string,
questionIndex: number,
): Promise<{ mode: "oss"; url: string; key: string } | { mode: "dev"; key: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const key = solveKey(user.id, paperId, questionIndex);
if (!ossConfigured()) return { mode: "dev", key };
return { mode: "oss", url: uploadUrl(key, "image/jpeg"), key };
}
// xuchao: 单题判分——调 AI、写审计日志、返回逐评分点结果;限频 30 次/天
export async function gradeSolve(
paperId: string,
questionIndex: number,
ossKey: string,
): Promise<{ verdict: GradeVerdict; photoUrl: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const paper = await getPaper(paperId);
if (!paper) return { error: "试卷不存在" };
const questions = paperQuestions(paper);
const q = questions[questionIndex];
if (!q || q.type !== "solve") return { error: "题目不存在或不是解答题" };
// xuchao: 键名必须属于本人本卷,防止拿别人的 key 判分
const prefix = `exam-solve/${user.id}/${paperId}/`;
if (!ossKey.startsWith(prefix) || !ossKey.endsWith(".jpg")) return { error: "照片键名不合法" };
const used = await gradeCountToday(user.id);
if (used >= GRADE_DAILY_LIMIT) return { error: `今日 AI 判分次数已用完(${GRADE_DAILY_LIMIT} 次),明天再来吧` };
const points = q.points ?? 0;
// xuchao: 未配 OSS 时照片在本地磁盘,AI 模型读不到,强制演示判分
const verdict = await gradeSolvePhoto({
stem: q.stem,
points,
rubric: q.rubric ?? [],
reference: q.reference ?? "",
photoUrl: photoViewUrl(ossKey),
figureUrl: q.figure ? `/exam/${paper.paperId}/${q.figure}` : undefined,
});
await db.insert(aiGradeLog).values({
childUserId: user.id,
paperId,
questionIndex,
ossKey,
status: verdict.status,
earned: verdict.earned,
points,
verdict: JSON.stringify({ hits: verdict.hits, comment: verdict.comment }),
mock: verdict.mock,
model: verdict.model,
ms: verdict.ms,
});
return { verdict, photoUrl: photoViewUrl(ossKey) };
}
// xuchao: 提交试卷——纯客观卷走原逻辑;含解答题的卷按分值加权,解答题得分取服务端判分日志
export async function takeExam(paperId: string, answers: (AnswerValue | null)[]): Promise<ExamResult> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" }; if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
@@ -22,16 +111,105 @@ export async function takeExam(paperId: string, answers: AnswerValue[]): Promise
const questions = paperQuestions(paper); const questions = paperQuestions(paper);
if (questions.length === 0) return { error: "试卷暂无题目" }; if (questions.length === 0) return { error: "试卷暂无题目" };
const g = gradeAnswers(questions, answers); const hasSolve = questions.some((q) => q.type === "solve");
if (!hasSolve) {
// 原有客观卷判分路径,保持行为不变
const g = gradeAnswers(questions, answers.map((a) => a as AnswerValue));
questions.forEach((q, i) => { questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]); if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
}); });
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers); const pointsEarned = await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
revalidateAll(paperId);
return { ...g, pointsEarned };
}
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
const logs = await latestSolveLogs(user.id, paperId);
let earnedSum = 0;
let pointSum = 0;
let correct = 0;
const detail: boolean[] = [];
const solves: SolveDetail[] = [];
const storedAnswers: unknown[] = [];
questions.forEach((q, i) => {
const pts = q.points ?? 1;
pointSum += pts;
if (q.type === "solve") {
if (q.judgeMode === "answer") {
// xuchao: answer 模式——最终答案归一化匹配秒判,不调 AI 不写审计日志
const payload = answers[i] as SolveAnswerPayload | null | undefined;
const given = payload?.answer?.trim() ?? "";
const hit = solveAnswerHit(q, given);
const earned = hit ? pts : 0;
detail.push(hit);
if (hit) correct++;
earnedSum += earned;
const hits: GradeVerdict["hits"] = [
{ point: "最终答案", score: earned, hit, reason: hit ? "与最终答案一致" : `期望:${(q.answers ?? []).join(" / ")},实际:${given || "未作答"}` },
];
// xuchao: ossKey 前缀校验——防止客户端伪造路径
const prefix = `exam-solve/${user.id}/${paperId}/`;
const rawKey = payload?.ossKey ?? "";
const safeKey = rawKey.startsWith(prefix) && rawKey.endsWith(".jpg") ? rawKey : "";
solves.push({ qIndex: i, ossKey: safeKey, status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" });
storedAnswers.push({ type: "solve", qIndex: i, ossKey: safeKey, earned, points: pts });
if (!hit) {
void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: safeKey, earned, hits });
}
return;
}
const log = logs.get(i);
const okLog = log && log.status === "ok" ? log : null;
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
earnedSum += earned;
const full = earned >= pts;
if (full) correct++;
detail.push(full);
const hits = okLog ? (JSON.parse(okLog.verdict ?? "{}") as { hits?: GradeVerdict["hits"] }).hits ?? [] : [];
solves.push({
qIndex: i,
ossKey: log?.ossKey ?? "",
status: log?.status ?? "missing",
earned,
points: pts,
hits,
comment: okLog ? (JSON.parse(okLog.verdict ?? "{}") as { comment?: string }).comment ?? "" : "未完成判分",
});
storedAnswers.push({ type: "solve", qIndex: i, ossKey: log?.ossKey ?? "", earned, points: pts });
if (!full) {
void recordWrong(user.id, "exam", paperId, paper.title, q, null, {
ossKey: log?.ossKey ?? "",
earned,
hits,
});
}
} else {
const ok = gradeOne(q, answers[i]);
if (ok) {
correct++;
earnedSum += pts;
} else {
void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
}
detail.push(ok);
storedAnswers.push(answers[i] ?? null);
}
});
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
const passed = score >= 80;
const pointsEarned = await recordExam(user.id, paperId, paper.title, score, correct, questions.length, passed, storedAnswers);
revalidateAll(paperId);
return { score, passed, correct, total: questions.length, detail, solves, pointsEarned };
}
function revalidateAll(paperId: string) {
revalidatePath("/exam"); revalidatePath("/exam");
revalidatePath(`/exam/${paperId}`); revalidatePath(`/exam/${paperId}`);
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/wrongbook"); revalidatePath("/wrongbook");
return g; revalidatePath("/points");
} }
+3 -2
View File
@@ -8,15 +8,16 @@ import { cn } from "@/lib/utils";
import { EXAM_TYPE_LABEL, listPapers, recordsByPaper } from "@/lib/exam"; import { EXAM_TYPE_LABEL, listPapers, recordsByPaper } from "@/lib/exam";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
// xuchao: 月份分组展示顺序与标签(初一上:9月~次年1月) // xuchao: 月份分组展示顺序与标签(入学分班考 + 初一上:9月~次年1月)
const MONTH_LABEL: Record<number, string> = { const MONTH_LABEL: Record<number, string> = {
0: "入学 · 分班考",
9: "9 月 · 有理数", 9: "9 月 · 有理数",
10: "10 月 · 整式的加减", 10: "10 月 · 整式的加减",
11: "11 月 · 一元一次方程", 11: "11 月 · 一元一次方程",
12: "12 月 · 几何图形初步", 12: "12 月 · 几何图形初步",
1: "1 月 · 期末全册", 1: "1 月 · 期末全册",
}; };
const MONTH_ORDER = [9, 10, 11, 12, 1]; const MONTH_ORDER = [0, 9, 10, 11, 12, 1];
export default async function ExamHome() { export default async function ExamHome() {
const user = await getSessionUser(); const user = await getSessionUser();
+10 -2
View File
@@ -7,6 +7,7 @@ import { photos, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { photoKey, uploadUrl } from "@/lib/oss"; import { photoKey, uploadUrl } from "@/lib/oss";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { awardPoints } from "@/lib/points";
// 取当前孩子会话,非法则抛错(客户端会显示) // 取当前孩子会话,非法则抛错(客户端会显示)
async function requireChild() { async function requireChild() {
@@ -25,8 +26,8 @@ async function ownedTask(childId: number, instanceId: number) {
return task; return task;
} }
// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾) // 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾);首次勾选发积分
export async function toggleTask(instanceId: number, done: boolean) { export async function toggleTask(instanceId: number, done: boolean): Promise<{ pointsEarned: number }> {
const child = await requireChild(); const child = await requireChild();
const task = await ownedTask(child.id, instanceId); const task = await ownedTask(child.id, instanceId);
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡"); if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
@@ -45,7 +46,14 @@ export async function toggleTask(instanceId: number, done: boolean) {
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null }) .set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
.where(eq(taskInstances.id, instanceId)); .where(eq(taskInstances.id, instanceId));
// xuchao: 首次完成才发分(唯一键防刷),分值取任务快照上的设置
const pointsEarned = done
? await awardPoints(child.id, "checkin", `inst-${instanceId}`, task.points, task.title)
: 0;
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/points");
return { pointsEarned };
} }
// 签发照片直传地址(浏览器 PUT 到对象存储) // 签发照片直传地址(浏览器 PUT 到对象存储)
+13 -1
View File
@@ -14,6 +14,7 @@ import { dayTasks, childCheckStats } from "@/lib/tasks";
import { listPoints, passedByPoint } from "@/lib/grammar"; import { listPoints, passedByPoint } from "@/lib/grammar";
import { listMathPoints, mathPassedByPoint } from "@/lib/math"; import { listMathPoints, mathPassedByPoint } from "@/lib/math";
import { recordsByPaper } from "@/lib/exam"; import { recordsByPaper } from "@/lib/exam";
import { totalPoints } from "@/lib/points";
import { TaskCard } from "./task-card"; import { TaskCard } from "./task-card";
export default async function KidPage() { export default async function KidPage() {
@@ -22,10 +23,11 @@ export default async function KidPage() {
if (user.role !== "child") redirect("/parent"); if (user.role !== "child") redirect("/parent");
const today = todayStr(); const today = todayStr();
const [tasks, streak, stats] = await Promise.all([ const [tasks, streak, stats, myPoints] = await Promise.all([
dayTasks(user.id, today), dayTasks(user.id, today),
streakOf(user.id), streakOf(user.id),
childCheckStats(user.id, 7), childCheckStats(user.id, 7),
totalPoints(user.id),
]); ]);
const doneCount = tasks.filter((t) => t.status === "done").length; const doneCount = tasks.filter((t) => t.status === "done").length;
const allDone = tasks.length > 0 && doneCount === tasks.length; const allDone = tasks.length > 0 && doneCount === tasks.length;
@@ -63,11 +65,21 @@ export default async function KidPage() {
</h1> </h1>
<p className="text-sm text-muted-foreground">{today}</p> <p className="text-sm text-muted-foreground">{today}</p>
</div> </div>
<div className="flex items-center gap-2">
<Link
href="/points"
className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 px-3.5 py-2 text-white shadow-[0_4px_14px_0_rgba(245,158,11,0.35)]"
title="我的积分"
>
<span className="text-base">🪙</span>
<span className="text-xl font-extrabold">{myPoints}</span>
</Link>
<div className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 py-2 text-white shadow-[0_4px_14px_0_rgba(249,115,22,0.35)]"> <div className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 py-2 text-white shadow-[0_4px_14px_0_rgba(249,115,22,0.35)]">
<Flame className="size-5" fill="currentColor" /> <Flame className="size-5" fill="currentColor" />
<span className="text-xl font-extrabold">{streak}</span> <span className="text-xl font-extrabold">{streak}</span>
<span className="text-xs font-medium">天</span> <span className="text-xs font-medium">天</span>
</div> </div>
</div>
</header> </header>
<Link href="/learn" className="block"> <Link href="/learn" className="block">
+8 -1
View File
@@ -35,6 +35,7 @@ export function TaskCard({
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [busy, startTransition] = useTransition(); const [busy, startTransition] = useTransition();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [earned, setEarned] = useState<number | null>(null);
const done = task.status === "done"; const done = task.status === "done";
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0; const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
@@ -46,7 +47,8 @@ export function TaskCard({
} }
startTransition(async () => { startTransition(async () => {
try { try {
await toggleTask(task.id, !done); const r = await toggleTask(task.id, !done);
setEarned(!done && r.pointsEarned > 0 ? r.pointsEarned : null);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "操作失败"); setError(e instanceof Error ? e.message : "操作失败");
} }
@@ -111,6 +113,11 @@ export function TaskCard({
补卡 补卡
</span> </span>
)} )}
{earned !== null && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-600">
🪙 +{earned} 积分
</span>
)}
</div> </div>
</div> </div>
{task.requiresPhoto && task.photoUrls.length < 3 && ( {task.requiresPhoto && task.photoUrls.length < 3 && (
+4 -3
View File
@@ -6,7 +6,7 @@ import { completePoint, extractQuiz, getPoint, gradeAnswers, type AnswerValue }
import { recordWrong } from "@/lib/wrongbook"; import { recordWrong } from "@/lib/wrongbook";
export type QuizResult = export type QuizResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string }; | { error: string };
// 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡 // 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡
@@ -25,12 +25,13 @@ export async function submitQuiz(pointId: string, answers: AnswerValue[]): Promi
questions.forEach((q, i) => { questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "lesson", pointId, point.title, q, answers[i]); if (!g.detail[i]) void recordWrong(user.id, "lesson", pointId, point.title, q, answers[i]);
}); });
await completePoint(user.id, pointId, point.title, g.score, g.passed); const pointsEarned = await completePoint(user.id, pointId, point.title, g.score, g.passed);
revalidatePath("/learn"); revalidatePath("/learn");
revalidatePath(`/learn/${pointId}`); revalidatePath(`/learn/${pointId}`);
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/wrongbook"); revalidatePath("/wrongbook");
return g; revalidatePath("/points");
return { ...g, pointsEarned };
} }
+334 -13
View File
@@ -1,17 +1,30 @@
"use client"; "use client";
import { useTransition, useState } from "react"; import { useTransition, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Check, X } from "lucide-react"; import { Camera, Check, Loader2, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse"; import type { QuizQ, SolveAnswerPayload } from "@/lib/quiz-parse";
import type { AnswerValue } from "@/lib/grammar"; import type { AnswerValue } from "@/lib/grammar";
import type { GradeVerdict } from "@/lib/ai-grade";
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
import { submitQuiz, type QuizResult } from "./actions"; import { submitQuiz, type QuizResult } from "./actions";
type AV = AnswerValue | null; type AV = AnswerValue | null;
// 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡 // xuchao: 解答题拍照状态机
type SolveState =
| { phase: "idle" }
| { phase: "preview"; blob: Blob; dataUrl: string }
| { phase: "grading"; dataUrl: string }
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict }
// xuchao: answer 模式照片传完即停,填最终答案待交卷秒判
| { phase: "uploaded"; dataUrl: string; ossKey: string; answer: string };
type CellValue = AV | SolveState;
// 结课检验:选择/判断/填空/连词成句/连线/解答题(拍照),提交后服务端判分,达标点亮并联动打卡
// 文案可配置:数学课/考试卷复用此组件时传入各自的标题、打卡文案与返回地址 // 文案可配置:数学课/考试卷复用此组件时传入各自的标题、打卡文案与返回地址
export function QuizBlock({ export function QuizBlock({
pointId, pointId,
@@ -32,18 +45,25 @@ export function QuizBlock({
backHref?: string; backHref?: string;
backLabel?: string; backLabel?: string;
}) { }) {
const [answers, setAnswers] = useState<AV[]>(questions.map(() => null)); const [answers, setAnswers] = useState<CellValue[]>(questions.map(() => null));
const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null); const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const set = (i: number, v: AV) => { const set = (i: number, v: CellValue) => {
const next = [...answers]; const next = [...answers];
next[i] = v; next[i] = v;
setAnswers(next); setAnswers(next);
}; };
const isAnswered = (q: QuizQ, a: AV): boolean => { const isAnswered = (q: QuizQ, a: CellValue): boolean => {
if (q.type === "solve") {
if (q.judgeMode === "answer")
return (
!!a && typeof a === "object" && "phase" in a && a.phase === "uploaded" && (a.answer?.trim() ?? "") !== ""
);
return !!a && typeof a === "object" && "phase" in a && a.phase === "graded" && a.verdict.status === "ok";
}
if (q.type === "fill") return typeof a === "string" && a.trim() !== ""; if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
if (q.type === "order") return Array.isArray(a) && a.length === (q.words ?? []).length; if (q.type === "order") return Array.isArray(a) && a.length === (q.words ?? []).length;
if (q.type === "match") return Array.isArray(a) && a.length === (q.pairs ?? []).length && a.every((x) => x !== null && x !== ""); if (q.type === "match") return Array.isArray(a) && a.length === (q.pairs ?? []).length && a.every((x) => x !== null && x !== "");
@@ -54,9 +74,18 @@ export function QuizBlock({
const submit = () => { const submit = () => {
setError(""); setError("");
startTransition(async () => { startTransition(async () => {
// xuchao: rubric 解答题以服务端判分日志为准传 null;answer 模式传最终答案+照片 key
const payload = answers.map((a, i) => {
const q = questions[i];
if (q.type !== "solve") return a as AnswerValue;
if (q.judgeMode === "answer" && a && typeof a === "object" && "phase" in a && a.phase === "uploaded") {
return { answer: a.answer ?? "", ossKey: a.ossKey } as SolveAnswerPayload;
}
return null;
});
const r = submitter const r = submitter
? await submitter(answers.map((a) => a as AnswerValue)) ? await submitter(payload as AnswerValue[])
: await submitQuiz(pointId, answers.map((a) => a as AnswerValue)); : await submitQuiz(pointId, payload as AnswerValue[]);
if ("error" in r) setError(r.error); if ("error" in r) setError(r.error);
else setResult(r); else setResult(r);
}); });
@@ -74,8 +103,16 @@ export function QuizBlock({
<div key={qi} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4"> <div key={qi} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
<p className="font-medium text-slate-900"> <p className="font-medium text-slate-900">
{qi + 1}. {q.stem} {qi + 1}. {q.stem}
{q.type === "solve" && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">{q.points} 分 · 拍照作答</span>}
</p> </p>
{q.figure && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/exam/${pointId}/${q.figure}`} alt="题目配图" className="mt-2 max-h-56 rounded-lg border border-slate-200 object-contain" />
</>
)}
{q.type === "fill" && ( {q.type === "fill" && (
<input <input
type="text" type="text"
@@ -117,11 +154,22 @@ export function QuizBlock({
</div> </div>
)} )}
{q.type === "order" && <OrderInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />} {q.type === "order" && <OrderInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
{q.type === "match" && <MatchInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />} {q.type === "match" && <MatchInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
{result && ( {q.type === "solve" && (
<SolveInput
paperId={pointId}
qIndex={qi}
judgeMode={q.judgeMode ?? "rubric"}
value={a as SolveState | null}
disabled={!!result}
onChange={(v) => set(qi, v)}
/>
)}
{result && q.type !== "solve" && (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{q.type === "fill" && ( {q.type === "fill" && (
<span className="mr-2 font-medium text-emerald-700">参考:{(q.answers ?? []).join(" / ")}</span> <span className="mr-2 font-medium text-emerald-700">参考:{(q.answers ?? []).join(" / ")}</span>
@@ -137,7 +185,14 @@ export function QuizBlock({
解说:{q.explain} 解说:{q.explain}
</p> </p>
)} )}
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题答错了,看看上面的参考与解说。</p>} {result && q.type === "solve" && (
<div className="mt-2 space-y-1 text-sm">
<p className="font-medium text-emerald-700">参考解答:</p>
<p className="whitespace-pre-line rounded-lg bg-white px-3 py-2 text-slate-700 ring-1 ring-slate-200">{q.reference}</p>
<p className="text-muted-foreground">解说:{q.explain}</p>
</div>
)}
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题没拿满分,看看上面的参考与解说。</p>}
</div> </div>
); );
})} })}
@@ -164,6 +219,9 @@ export function QuizBlock({
答对 {result.correct} / {result.total} 答对 {result.correct} / {result.total}
{result.passed ? ` · ${passText}` : " · 看看解说,再试一次"} {result.passed ? ` · ${passText}` : " · 看看解说,再试一次"}
</p> </p>
{result.passed && result.pointsEarned > 0 && (
<p className="mt-1 text-base font-extrabold">🪙 +{result.pointsEarned} 积分</p>
)}
<div className="mt-4 flex justify-center gap-3"> <div className="mt-4 flex justify-center gap-3">
{!result.passed && ( {!result.passed && (
<Button variant="secondary" onClick={() => window.location.reload()}> <Button variant="secondary" onClick={() => window.location.reload()}>
@@ -180,6 +238,269 @@ export function QuizBlock({
); );
} }
// xuchao: 解答题拍照输入——纸上写→拍照→压缩→直传→AI 逐评分点判分,可重拍
function SolveInput({
paperId,
qIndex,
judgeMode,
value,
disabled,
onChange,
}: {
paperId: string;
qIndex: number;
judgeMode: "rubric" | "answer";
value: SolveState | null;
disabled: boolean;
onChange: (v: SolveState) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState("");
const [err, setErr] = useState("");
const st = value ?? { phase: "idle" as const };
const isAnswerMode = judgeMode === "answer";
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
const compress = (file: File) =>
new Promise<{ blob: Blob; dataUrl: string }>((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, 2000 / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("浏览器不支持图片处理"));
ctx.drawImage(img, 0, 0, w, h);
let quality = 0.85;
const attempt = () =>
canvas.toBlob(
(b) => {
if (!b) return reject(new Error("图片压缩失败"));
if (b.size > 2 * 1024 * 1024 && quality > 0.4) {
quality -= 0.15;
return attempt();
}
const r = new FileReader();
r.onload = () => resolve({ blob: b, dataUrl: String(r.result) });
r.onerror = () => reject(new Error("读取图片失败"));
r.readAsDataURL(b);
},
"image/jpeg",
quality,
);
attempt();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败,请换一张"));
};
img.src = url;
});
const onPick = async (file: File | undefined) => {
if (!file || disabled) return;
setErr("");
try {
const { blob, dataUrl } = await compress(file);
onChange({ phase: "preview", blob, dataUrl });
} catch (e) {
setErr(e instanceof Error ? e.message : "处理照片失败");
}
};
const doGrade = async (blob: Blob, dataUrl: string) => {
setErr("");
setBusy("上传中…");
onChange({ phase: "grading", dataUrl });
try {
const up = await getSolveUploadUrl(paperId, qIndex);
if ("error" in up) throw new Error(up.error);
if (up.mode === "oss") {
const resp = await fetch(up.url, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: blob });
if (!resp.ok) throw new Error("照片上传失败,请重试");
} else {
const fd = new FormData();
fd.append("file", blob, "solve.jpg");
fd.append("key", up.key);
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
if (!resp.ok) throw new Error("照片上传失败,请重试");
}
if (isAnswerMode) {
// xuchao: answer 模式不调 AI,照片存档后等待最终答案提交秒判
onChange({ phase: "uploaded", dataUrl, ossKey: up.key, answer: "" });
return;
}
setBusy("AI 判分中…");
let r = await gradeSolve(paperId, qIndex, up.key);
// xuchao: 调用失败自动重试一次
if ("error" in r && /失败|超时/.test(r.error)) r = await gradeSolve(paperId, qIndex, up.key);
if ("error" in r) throw new Error(r.error);
onChange({ phase: "graded", dataUrl, photoUrl: r.photoUrl, verdict: r.verdict });
} catch (e) {
setErr(e instanceof Error ? e.message : "判分失败,请重试");
onChange({ phase: "preview", blob, dataUrl });
} finally {
setBusy("");
}
};
if (st.phase === "idle") {
return (
<div className="mt-2">
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => fileRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-indigo-300 bg-indigo-50/50 px-4 py-6 text-sm font-semibold text-indigo-700 hover:bg-indigo-50"
>
<Camera className="size-5" /> 在纸上写好,拍照上传
</button>
<p className="mt-1 text-xs text-muted-foreground">写清每一步过程,按评分点给分;请横拍、光线充足、字迹清楚。</p>
</div>
);
}
if (st.phase === "uploaded") {
return (
<div className="mt-2 space-y-2">
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={st.dataUrl} alt="解题照片(已上传)" className="max-h-40 rounded-lg border border-slate-200 object-contain" />
<p className="text-xs text-emerald-600">✅ 解题过程已上传</p>
<input
type="text"
value={st.answer}
disabled={disabled}
onChange={(e) => onChange({ ...st, answer: e.target.value })}
placeholder="填写最终答案,如:1/2 或 0.5"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<button type="button" onClick={() => fileRef.current?.click()} disabled={disabled} className="text-xs text-indigo-600">
重新拍照
</button>
</div>
);
}
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
return (
<div className="mt-2 space-y-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dataUrl} alt="作答照片" className="max-h-64 w-full rounded-lg border border-slate-200 object-contain" />
{st.phase === "preview" && !disabled && (
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => void doGrade(st.blob, st.dataUrl)}>
<Check className="size-4" /> 就这张,开始判分
</Button>
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<RotateCcw className="size-4" /> 重拍
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
</div>
)}
{st.phase === "grading" && (
<p className="flex items-center gap-2 text-sm font-medium text-indigo-600">
<Loader2 className="size-4 animate-spin" /> {busy || "AI 判分中…"}
</p>
)}
{st.phase === "graded" && (
<>
<SolveVerdict verdict={st.verdict} />
{st.verdict.status !== "ok" && !disabled && (
<Button size="sm" variant="outline" onClick={() => onChange({ phase: "idle" })}>
<RotateCcw className="size-4" /> 重新拍照
</Button>
)}
</>
)}
{err && <p className="text-sm font-medium text-destructive">{err}</p>}
</div>
);
}
// xuchao: 判分结果展示——逐评分点命中情况 + 得分 + AI 点评
function SolveVerdict({ verdict }: { verdict: GradeVerdict }) {
if (verdict.status !== "ok") {
const tip: Record<string, string> = {
blank: "照片里没有作答内容,请写完再拍。",
blurry: "字迹太模糊啦,重新拍一张清楚点的。",
printed: "只能批改手写解答,请把过程写在纸上再拍。",
unrelated: "照片内容和这道题无关,请拍这道题的解答。",
error: "AI 判分出了点问题,请重试。",
};
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{tip[verdict.status] ?? "请重新拍照"} {verdict.comment && <span className="text-amber-700">({verdict.comment})</span>}
</div>
);
}
const total = verdict.hits.reduce((s, h) => s + h.score, 0);
return (
<div className="space-y-1.5 rounded-lg border border-indigo-100 bg-white px-3 py-2">
<p className="text-sm font-bold text-slate-900">
AI 判分:{verdict.earned} / {total} 分
</p>
<ul className="space-y-1">
{verdict.hits.map((h, i) => (
<li key={i} className="flex items-start gap-1.5 text-xs">
{h.hit ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
) : (
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
)}
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
({h.score}分){h.point}
{h.reason && <span className="text-muted-foreground"> —— {h.reason}</span>}
</span>
</li>
))}
</ul>
{verdict.comment && <p className="text-xs text-indigo-600">💬 {verdict.comment}</p>}
{verdict.mock && <p className="text-[10px] text-muted-foreground">演示判分(未配置 AI Key)</p>}
</div>
);
}
// 连词成句:点词入槽,再点槽里的词可移回 // 连词成句:点词入槽,再点槽里的词可移回
function OrderInput({ function OrderInput({
q, q,
+9
View File
@@ -65,6 +65,15 @@ export default function LoginPage() {
去输入激活码 去输入激活码
</Link> </Link>
</p> </p>
<p className="text-center text-sm text-muted-foreground">
家里另一位家长想加入?{" "}
<Link
href="/register"
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
>
用邀请码加入家庭
</Link>
</p>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
+4 -3
View File
@@ -7,7 +7,7 @@ import { completeMathPoint, extractQuiz, getMathPoint, gradeAnswers, type Answer
import { recordWrong } from "@/lib/wrongbook"; import { recordWrong } from "@/lib/wrongbook";
export type QuizResult = export type QuizResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string }; | { error: string };
// 提交数学课练习:服务端判分,达标记进度并联动数学打卡 // 提交数学课练习:服务端判分,达标记进度并联动数学打卡
@@ -26,12 +26,13 @@ export async function submitMathQuiz(pointId: string, answers: AnswerValue[]): P
questions.forEach((q, i) => { questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "mlesson", pointId, point.title, q, answers[i]); if (!g.detail[i]) void recordWrong(user.id, "mlesson", pointId, point.title, q, answers[i]);
}); });
await completeMathPoint(user.id, pointId, point.title, g.score, g.passed); const pointsEarned = await completeMathPoint(user.id, pointId, point.title, g.score, g.passed);
revalidatePath("/math"); revalidatePath("/math");
revalidatePath(`/math/${pointId}`); revalidatePath(`/math/${pointId}`);
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/wrongbook"); revalidatePath("/wrongbook");
return g; revalidatePath("/points");
return { ...g, pointsEarned };
} }
+7 -3
View File
@@ -10,9 +10,10 @@ import { recordWrong } from "@/lib/wrongbook";
import { db } from "@/db"; import { db } from "@/db";
import { taskInstances, mathWeeklyTest } from "@/db/schema"; import { taskInstances, mathWeeklyTest } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { awardRulePoints } from "@/lib/points";
export type MathWeeklyResult = export type MathWeeklyResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string }; | { error: string };
// 提交数学每周检测:判分、记录、错题入库、达标关联当日数学打卡 // 提交数学每周检测:判分、记录、错题入库、达标关联当日数学打卡
@@ -44,8 +45,10 @@ export async function takeMathWeekly(answers: AnswerValue[]): Promise<MathWeekly
}) })
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } }); .onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
// 达标关联当日数学打卡 // 达标关联当日数学打卡,并发积分(首次达标才发)
let pointsEarned = 0;
if (g.passed) { if (g.passed) {
pointsEarned = await awardRulePoints(user.id, "math_weekly", `week-${wk}`, `数学周检测 ${wk}`);
const today = todayStr(); const today = todayStr();
const title = `数学周检测 · ${wk}`; const title = `数学周检测 · ${wk}`;
const [ex] = await db const [ex] = await db
@@ -73,5 +76,6 @@ export async function takeMathWeekly(answers: AnswerValue[]): Promise<MathWeekly
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/wrongbook"); revalidatePath("/wrongbook");
return g; revalidatePath("/points");
return { ...g, pointsEarned };
} }
+4
View File
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs"; import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly"; import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly";
import { getAwardedPoints } from "@/lib/points";
import { QuizBlock } from "@/app/learn/quiz-block"; import { QuizBlock } from "@/app/learn/quiz-block";
import { takeMathWeekly } from "./actions"; import { takeMathWeekly } from "./actions";
@@ -15,6 +16,8 @@ export default async function MathWeeklyPage() {
const wk = currentMathWeekKey(); const wk = currentMathWeekKey();
const record = await getMathWeeklyRecord(user.id, wk); const record = await getMathWeeklyRecord(user.id, wk);
const questions = record ? [] : await buildMathWeeklyQuestions(user.id, wk); const questions = record ? [] : await buildMathWeeklyQuestions(user.id, wk);
// xuchao: 已有记录时从流水取回本周达标的积分,结果页刷新后仍显示
const awarded = record?.passed ? await getAwardedPoints(user.id, "math_weekly", `week-${wk}`) : 0;
return ( return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12"> <main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
@@ -42,6 +45,7 @@ export default async function MathWeeklyPage() {
<p className="mt-1 text-sm opacity-90"> <p className="mt-1 text-sm opacity-90">
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"} 答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
</p> </p>
{record.passed && awarded > 0 && <p className="mt-1 text-base font-extrabold">🪙 +{awarded} 积分</p>}
</div> </div>
) : ( ) : (
<QuizBlock <QuizBlock
+217 -1
View File
@@ -1,15 +1,19 @@
"use server"; "use server";
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema"; import { activationCodes, examPapers, examRecords, familyInvites, sessions, taskInstances, taskTemplates, users, weeklyTest } from "@/db/schema";
import { SUBJECT_VALUES } from "@/db/schema"; import { SUBJECT_VALUES } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks"; import { ensureDay } from "@/lib/tasks";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
import { validateQuestions } from "@/lib/question-validation";
function hashCode(code: string) { function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex"); return crypto.createHash("sha256").update(code).digest("hex");
@@ -50,6 +54,21 @@ export async function createActivationCode(
return { code, childName: child.displayName }; return { code, childName: child.displayName };
} }
// xuchao: 生成家庭邀请码(6 位,15 分钟一次性),对方凭码在 /register 注册加入
export async function generateFamilyInvite(): Promise<{ code?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.insert(familyInvites).values({
familyId: parent.familyId,
codeHash: hashCode(code),
createdBy: parent.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
return { code };
}
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记 // 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
export async function makeupTask(instanceId: number, done: boolean) { export async function makeupTask(instanceId: number, done: boolean) {
const parent = await getSessionUser(); const parent = await getSessionUser();
@@ -84,6 +103,8 @@ const templateSchema = z.object({
dateEnd: z.string().nullish(), dateEnd: z.string().nullish(),
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"), title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
requiresPhoto: z.boolean(), requiresPhoto: z.boolean(),
// xuchao: 该任务完成可得积分,0~9999
points: z.coerce.number().int().min(0).max(9999).catch(10),
}); });
async function requireOwnerOfChild(childId: number) { async function requireOwnerOfChild(childId: number) {
@@ -109,6 +130,7 @@ export async function addTemplate(formData: FormData) {
dateEnd: formData.get("dateEnd") || null, dateEnd: formData.get("dateEnd") || null,
title: formData.get("title"), title: formData.get("title"),
requiresPhoto: formData.get("requiresPhoto") === "on", requiresPhoto: formData.get("requiresPhoto") === "on",
points: formData.get("points") || 10,
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await requireOwnerOfChild(parsed.data.childId); await requireOwnerOfChild(parsed.data.childId);
@@ -127,12 +149,51 @@ export async function addTemplate(formData: FormData) {
dateEnd: parsed.data.dateEnd || null, dateEnd: parsed.data.dateEnd || null,
title: parsed.data.title, title: parsed.data.title,
requiresPhoto: parsed.data.requiresPhoto, requiresPhoto: parsed.data.requiresPhoto,
points: parsed.data.points,
sortOrder: maxRow.maxSort + 1, sortOrder: maxRow.maxSort + 1,
}); });
await ensureDay(parsed.data.childId, todayStr()); await ensureDay(parsed.data.childId, todayStr());
revalidatePath("/parent"); revalidatePath("/parent");
} }
// xuchao: 修改模板任务分值;当天未完成的快照同步更新,设置立即生效
export async function updateTemplatePoints(templateId: number, points: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const p = Math.max(0, Math.min(9999, Math.round(points)));
const [row] = await db
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
.from(taskTemplates)
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
.limit(1);
if (!row) throw new Error("任务不存在");
await db.update(taskTemplates).set({ points: p }).where(eq(taskTemplates.id, templateId));
await db
.update(taskInstances)
.set({ points: p })
.where(
and(
eq(taskInstances.sourceTemplateId, templateId),
eq(taskInstances.date, todayStr()),
eq(taskInstances.status, "pending"),
),
);
revalidatePath("/parent");
revalidatePath("/kid");
}
// xuchao: 保存测试类积分规则(只接受已知事件,分值 0~9999)
export async function savePointRules(values: { eventType: string; points: number }[]) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
for (const v of values) {
if (!(v.eventType in DEFAULT_RULE_POINTS)) continue;
await upsertPointRule(v.eventType, Math.max(0, Math.min(9999, Math.round(v.points))));
}
revalidatePath("/parent");
}
// 删除模板任务:删除后立即对账当天,孩子端未完成的对应任务会同步移除 // 删除模板任务:删除后立即对账当天,孩子端未完成的对应任务会同步移除
export async function deleteTemplate(templateId: number) { export async function deleteTemplate(templateId: number) {
const parent = await getSessionUser(); const parent = await getSessionUser();
@@ -200,3 +261,158 @@ export async function removeChild(childId: number): Promise<void> {
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/learn"); revalidatePath("/learn");
} }
// ===== 成绩与试卷管理:周测记录(不联动积分/打卡) =====
export async function updateWeeklyRecord(id: number, score: number, passed: boolean): Promise<string | null> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数";
const [row] = await db
.select({ familyId: users.familyId })
.from(weeklyTest)
.innerJoin(users, eq(users.id, weeklyTest.childUserId))
.where(eq(weeklyTest.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return "记录不存在";
await db.update(weeklyTest).set({ score, passed }).where(eq(weeklyTest.id, id));
revalidatePath("/parent/weekly-records");
revalidatePath("/parent");
return null;
}
export async function deleteWeeklyRecord(id: number): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db
.select({ familyId: users.familyId })
.from(weeklyTest)
.innerJoin(users, eq(users.id, weeklyTest.childUserId))
.where(eq(weeklyTest.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return;
await db.delete(weeklyTest).where(eq(weeklyTest.id, id));
revalidatePath("/parent/weekly-records");
revalidatePath("/parent");
}
// ===== 成绩与试卷管理:考试记录(不联动积分/打卡) =====
export async function updateExamRecord(id: number, score: number, passed: boolean): Promise<string | null> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数";
const [row] = await db
.select({ familyId: users.familyId })
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.where(eq(examRecords.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return "记录不存在";
await db.update(examRecords).set({ score, passed }).where(eq(examRecords.id, id));
revalidatePath("/parent/exam-records");
revalidatePath("/parent");
return null;
}
export async function deleteExamRecord(id: number): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db
.select({ familyId: users.familyId })
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.where(eq(examRecords.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return;
await db.delete(examRecords).where(eq(examRecords.id, id));
revalidatePath("/parent/exam-records");
revalidatePath("/parent");
}
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const f = (k: string) => String(formData.get(k) ?? "").trim();
const paperId = f("paperId");
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号只允许小写字母/数字/中划线,如 g7s1-m09-2" };
const title = f("title");
if (!title) return { error: "请填写试卷标题" };
const month = Number(formData.get("month"));
const seq = Number(formData.get("seq"));
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
const questions = f("questions");
const chk = validateQuestions(questions);
if (chk.error) return { error: chk.error };
await db
.insert(examPapers)
.values({
paperId,
subject: f("subject") || "math",
grade: f("grade") || "g7",
semester: f("semester") || "s1",
month,
examType: f("examType"),
title,
region: f("region") || null,
year: Number(formData.get("year")) || null,
duration: Number(formData.get("duration")) || null,
fullScore: Number(formData.get("fullScore")) || null,
sourceUrl: f("sourceUrl") || null,
summary: f("summary") || null,
questions: JSON.stringify(JSON.parse(questions)),
seq,
})
.onDuplicateKeyUpdate({
set: {
subject: f("subject") || "math",
grade: f("grade") || "g7",
semester: f("semester") || "s1",
month,
examType: f("examType"),
title,
region: f("region") || null,
year: Number(formData.get("year")) || null,
duration: Number(formData.get("duration")) || null,
fullScore: Number(formData.get("fullScore")) || null,
sourceUrl: f("sourceUrl") || null,
summary: f("summary") || null,
questions: JSON.stringify(JSON.parse(questions)),
seq,
},
});
revalidatePath("/parent/exams");
revalidatePath("/exam");
return { ok: true };
}
export async function deleteExamPaper(paperId: string): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
await db.delete(examPapers).where(eq(examPapers.paperId, paperId));
revalidatePath("/parent/exams");
revalidatePath("/exam");
}
// xuchao: 上传试卷题目配图(几何体等)——存 public/exam/<paperId>/<uuid>.<ext>,返回文件名写回 q.figure
// 生产容器 public/exam 挂卷持久化(compose volumes: exam-figures),重建不丢
export async function uploadPaperFigure(
paperId: string,
file: File,
): Promise<{ filename?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号不合法" };
if (!file.type.startsWith("image/")) return { error: "只能上传图片文件" };
const ext = file.name.toLowerCase().split(".").pop();
if (!["png", "jpg", "jpeg", "webp"].includes(ext ?? "")) return { error: "只支持 png/jpg/webp 图片" };
if (file.size > 8 * 1024 * 1024) return { error: "图片不能超过 8MB" };
const buf = Buffer.from(await file.arrayBuffer());
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(process.cwd(), "public", "exam", paperId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), buf);
return { filename };
}
+60 -65
View File
@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChildManage } from "./child-manage"; import { ChildManage } from "./child-manage";
import { DevicesTab } from "./devices-tab"; import { DevicesTab } from "./devices-tab";
import { FamilyManage } from "./family-manage";
import { OverviewTab } from "./overview-tab"; import { OverviewTab } from "./overview-tab";
import { PointsManage } from "./points-manage";
import { TemplatesTab } from "./templates-tab"; import { TemplatesTab } from "./templates-tab";
import { PlayBtn } from "@/components/play-audio";
export type DayCell = { export type DayCell = {
done: number; done: number;
@@ -40,12 +41,23 @@ export type TemplatesData = {
title: string; title: string;
subject: string; subject: string;
requiresPhoto: boolean; requiresPhoto: boolean;
points: number;
mode: string; mode: string;
dateStart: string | null; dateStart: string | null;
dateEnd: string | null; dateEnd: string | null;
}[]; }[];
}[]; }[];
}; };
// xuchao: 积分管理数据——测试类规则 + 每个孩子总积分/最近流水
export type PointsData = {
rules: { eventType: string; label: string; points: number }[];
kids: {
id: number;
name: string;
total: number;
recent: { id: number; label: string; remark: string; points: number; date: string }[];
}[];
};
export type DevicesData = { export type DevicesData = {
rows: { rows: {
id: number; id: number;
@@ -56,16 +68,11 @@ export type DevicesData = {
isCurrent: boolean; isCurrent: boolean;
}[]; }[];
}; };
export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean }; // xuchao: 家庭管理数据——家庭名 + 家长成员 + 孩子成员
export type RecordingRow = { childName: string; word: string; url: string; at: string }; export type FamilyData = {
export type GrammarTestRow = { childName: string; title: string; score: number; passed: boolean; at: string }; familyName: string;
export type WrongItemRow = { parents: { id: number; name: string }[];
childName: string; kids: { id: number; name: string }[];
stem: string;
userAnswer: string;
correctAnswer: string;
sourceTitle: string;
mastered: boolean;
}; };
const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft"; const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft";
@@ -84,18 +91,14 @@ export function ParentDashboard({
overview, overview,
templates, templates,
devices, devices,
weekly, points,
recordings, family,
grammarTests,
wrongItems,
}: { }: {
overview: OverviewData; overview: OverviewData;
templates: TemplatesData; templates: TemplatesData;
devices: DevicesData; devices: DevicesData;
weekly: WeeklyRow[]; points: PointsData;
recordings: RecordingRow[]; family: FamilyData;
grammarTests: GrammarTestRow[];
wrongItems: WrongItemRow[];
}) { }) {
return ( return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10"> <main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
@@ -130,7 +133,11 @@ export function ParentDashboard({
<TemplatesTab data={templates} /> <TemplatesTab data={templates} />
</Section> </Section>
<Section title="课程管理" desc="英语语法课程编辑与每周检测成绩"> <Section title="积分管理" desc="打卡分值在任务管理里按任务设置;测试类达标分值在下面调整,孩子首次达标才计分">
<PointsManage data={points} />
</Section>
<Section title="课程管理" desc="英语语法/单词、数学思维课程编辑与每周检测成绩">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Link href="/parent/grammar" className="block"> <Link href="/parent/grammar" className="block">
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5"> <div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
@@ -138,59 +145,47 @@ export function ParentDashboard({
<p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p> <p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p>
</div> </div>
</Link> </Link>
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft"> <Link href="/parent/words" className="block">
<p className="font-bold text-slate-900">📝 每周检测成绩</p> <div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
<div className="mt-2 space-y-1"> <p className="font-bold text-slate-900">📖 编辑英语单词</p>
{weekly.length === 0 && <p className="text-xs text-muted-foreground">暂无检测记录</p>} <p className="mt-1 text-xs text-muted-foreground">按天增删改单词与释义,保存即生效</p>
{weekly.map((w, i) => (
<p key={i} className="text-xs text-slate-600">
{w.childName} · {w.weekKey} · {w.score} 分 {w.passed ? "✅" : "❌"}
</p>
))}
</div> </div>
</Link>
<Link href="/parent/math" className="block">
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
<p className="font-bold text-slate-900">🧮 编辑数学思维课程</p>
<p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft sm:col-span-2"> </Link>
<p className="font-bold text-slate-900">🎙️ 单词跟读录音</p> <Link href="/parent/weekly-records" className="block">
<div className="mt-2 flex flex-wrap gap-2"> <div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
{recordings.length === 0 && <p className="text-xs text-muted-foreground">暂无跟读录音</p>} <p className="font-bold text-slate-900">📝 每周检测成绩管理</p>
{recordings.map((r, i) => ( <p className="mt-1 text-xs text-muted-foreground">改分数/达标,删除误生成记录</p>
<div key={i} className="flex items-center gap-1.5 rounded-full bg-slate-50 px-2.5 py-1 text-sm ring-1 ring-slate-100">
<span className="text-xs text-muted-foreground">{r.childName}</span>
<span className="font-medium text-slate-800">{r.word}</span>
<PlayBtn src={r.url} className="size-6" />
</div> </div>
))} </Link>
</div> <Link href="/parent/exam-records" className="block">
</div> <div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft"> <p className="font-bold text-slate-900">📄 考试记录管理</p>
<p className="font-bold text-slate-900">📘 英语语法检测</p> <p className="mt-1 text-xs text-muted-foreground">改考试成绩/达标,删除误生成记录</p>
<div className="mt-2 space-y-1">
{grammarTests.length === 0 && <p className="text-xs text-muted-foreground">暂无检测记录</p>}
{grammarTests.map((g, i) => (
<p key={i} className="truncate text-xs text-slate-600">
{g.childName} · {g.title} · {g.score} 分 {g.passed ? "✅" : "❌"}
</p>
))}
</div>
</div>
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<p className="font-bold text-slate-900">📒 孩子错题本</p>
<div className="mt-2 space-y-2">
{wrongItems.length === 0 && <p className="text-xs text-muted-foreground">暂无错题</p>}
{wrongItems.map((w, i) => (
<div key={i} className="rounded-lg bg-slate-50 p-2 text-xs">
<p className="font-medium text-slate-800">
{w.childName} · {w.stem}
</p>
<p className="mt-0.5 text-rose-600">答:{w.userAnswer || "未作答"}</p>
<p className="text-emerald-700">正:{w.correctAnswer}</p>
</div>
))}
</div> </div>
</Link>
<Link href="/parent/exams" className="block">
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
<p className="font-bold text-slate-900">🗂️ 考试试卷管理</p>
<p className="mt-1 text-xs text-muted-foreground">新增/编辑/删除试卷,孩子端立即生效</p>
</div> </div>
</Link>
</div> </div>
</Section> </Section>
<Section title="家庭管理" desc="添加家庭其他家长,一起查看孩子的学习情况">
<FamilyManage
familyName={family.familyName}
parents={family.parents}
kids={family.kids}
/>
</Section>
<Section title="孩子管理" desc="增加或删除孩子,删除会连同其记录一起删除"> <Section title="孩子管理" desc="增加或删除孩子,删除会连同其记录一起删除">
<ChildManage kids={overview.kids.map((k) => ({ id: k.id, name: k.name }))} /> <ChildManage kids={overview.kids.map((k) => ({ id: k.id, name: k.name }))} />
</Section> </Section>
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Pencil, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { deleteExamRecord, updateExamRecord } from "../actions";
export type ExamRowItem = {
id: number;
childName: string;
paperTitle: string;
score: number;
passed: boolean;
takenAt: string;
};
// xuchao: 考试记录行内编辑(分数+达标开关)、删除;保存后刷新服务端列表
export function ExamList({ rows }: { rows: ExamRowItem[] }) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [score, setScore] = useState("");
const [passed, setPassed] = useState(false);
const [msg, setMsg] = useState("");
const [pending, startTransition] = useTransition();
const startEdit = (r: ExamRowItem) => {
setEditingId(r.id);
setScore(String(r.score));
setPassed(r.passed);
setMsg("");
};
const save = () => {
if (editingId == null) return;
const s = Number(score);
startTransition(async () => {
const r = await updateExamRecord(editingId, s, passed);
if (r) setMsg(r);
else {
setEditingId(null);
router.refresh();
}
});
};
const remove = (id: number, label: string) => {
if (!confirm(`确定删除「${label}」这条考试记录?孩子可重新考试。`)) return;
startTransition(() => {
deleteExamRecord(id).then(() => router.refresh());
});
};
return (
<div className="space-y-2.5">
{rows.length === 0 && (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-sm text-muted-foreground">暂无考试记录</p>
</div>
)}
{rows.map((r) => (
<div key={r.id} className="rounded-xl border border-slate-100 bg-white p-3 shadow-soft">
{editingId === r.id ? (
<div className="space-y-2.5">
<p className="text-sm font-medium text-slate-800">
{r.childName} · {r.paperTitle}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-sm text-slate-600">
分数
<Input
className="h-8 w-20"
type="number"
min={0}
max={1000}
value={score}
onChange={(e) => setScore(e.target.value)}
/>
</div>
<label className="flex items-center gap-1.5 text-sm text-slate-600">
<Checkbox checked={passed} onCheckedChange={(v) => setPassed(v === true)} />
达标
</label>
{msg && <span className="text-xs font-medium text-destructive">{msg}</span>}
</div>
<div className="flex gap-2">
<Button size="sm" disabled={pending} onClick={save}>
<Check className="size-4" /> 保存
</Button>
<Button size="sm" variant="outline" disabled={pending} onClick={() => setEditingId(null)}>
<X className="size-4" /> 取消
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{r.childName}</span>
<span className="max-w-[45%] truncate rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">
{r.paperTitle}
</span>
<span className={cn("text-sm font-bold", r.passed ? "text-emerald-600" : "text-rose-600")}>
{r.score} 分 {r.passed ? "✅" : "❌"}
</span>
<span className="ml-auto text-xs text-muted-foreground">{r.takenAt}</span>
<button type="button" aria-label="编辑" className="text-muted-foreground hover:text-indigo-600" onClick={() => startEdit(r)}>
<Pencil className="size-4" />
</button>
<button
type="button"
aria-label="删除"
className="text-muted-foreground hover:text-destructive"
onClick={() => remove(r.id, `${r.childName} ${r.paperTitle}`)}
>
<Trash2 className="size-4" />
</button>
</div>
)}
</div>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db";
import { examPapers, examRecords, users } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { ExamList } from "./exam-list";
// xuchao: 考试成绩记录管理——编辑分数/达标、删除记录(卷已删时标题显示占位)
export default async function ExamRecordsPage() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const rows = await db
.select({
id: examRecords.id,
childName: users.displayName,
paperTitle: examPapers.title,
score: examRecords.score,
passed: examRecords.passed,
takenAt: examRecords.takenAt,
})
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.leftJoin(examPapers, eq(examPapers.paperId, examRecords.paperId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(examRecords.takenAt))
.limit(100);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">考试记录管理</h1>
<span className="text-xs text-muted-foreground">最近 {rows.length} 条</span>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
修改分数/达标状态或删除记录不会影响已发的积分与当日打卡;删除后孩子可重新考试。
</p>
<ExamList
rows={rows.map((r) => ({
id: r.id,
childName: r.childName,
paperTitle: r.paperTitle ?? "已删试卷",
score: r.score ?? 0,
passed: r.passed,
takenAt: r.takenAt.toLocaleDateString("zh-CN"),
}))}
/>
</main>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { getPaper } from "@/lib/exam";
import { PaperForm } from "../paper-form";
// xuchao: 编辑试卷页——元信息 + 题目 JSON,保存即刷新孩子端 /exam
export default async function EditExamPage({ params }: { params: Promise<{ paperId: string }> }) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const paper = await getPaper((await params).paperId);
if (!paper) redirect("/parent/exams");
return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="space-y-2">
<Link href="/parent/exams" className="text-sm font-medium text-indigo-600">
← 试卷列表
</Link>
<h1 className="text-xl font-extrabold break-words text-slate-900">{paper.title}</h1>
</header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<PaperForm
isNew={false}
paper={{
paperId: paper.paperId,
title: paper.title,
examType: paper.examType,
region: paper.region,
year: paper.year,
duration: paper.duration,
fullScore: paper.fullScore,
month: paper.month,
seq: paper.seq,
sourceUrl: paper.sourceUrl,
summary: paper.summary,
questions: paper.questions,
}}
/>
</div>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { PaperForm } from "../paper-form";
// xuchao: 新建试卷页(paperId 唯一;科目/年级/学期固定 math/g7/s1,孩子端立即出现)
export default async function NewExamPage() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="space-y-2">
<Link href="/parent/exams" className="text-sm font-medium text-indigo-600">
← 试卷列表
</Link>
<h1 className="text-xl font-extrabold break-words text-slate-900">新建试卷</h1>
</header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<PaperForm paper={null} isNew />
</div>
</main>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import { Plus } from "lucide-react";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getSessionUser } from "@/lib/auth";
import { EXAM_TYPE_LABEL, listPapers } from "@/lib/exam";
// xuchao: 试卷管理列表——与孩子端考试中心同分组(月份),点击进入编辑
const MONTH_LABEL: Record<number, string> = {
0: "入学 · 分班考",
9: "9 月 · 有理数",
10: "10 月 · 整式的加减",
11: "11 月 · 一元一次方程",
12: "12 月 · 几何图形初步",
1: "1 月 · 期末全册",
};
const MONTH_ORDER = [0, 9, 10, 11, 12, 1];
export default async function ExamsAdmin() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const papers = await listPapers("g7", "s1");
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">考试试卷管理</h1>
<Link href="/parent/exams/new">
<Button size="sm">
<Plus className="size-4" /> 新建试卷
</Button>
</Link>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
点击试卷可编辑元信息与题目,保存后孩子端考试中心立即生效。
</p>
{papers.length === 0 && (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-sm text-muted-foreground">还没有试卷,点右上角新建</p>
</div>
)}
{MONTH_ORDER.map((m) => {
const group = papers.filter((p) => p.month === m);
if (group.length === 0) return null;
return (
<section key={m} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<h2 className="mb-2 text-sm font-extrabold text-slate-900">{MONTH_LABEL[m]}</h2>
<div className="space-y-1.5">
{group.map((p) => (
<Link
key={p.paperId}
href={`/parent/exams/${p.paperId}`}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 transition-colors hover:border-indigo-300"
>
<span className="truncate font-medium text-slate-800">{p.title}</span>
<Badge className="shrink-0 bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
{EXAM_TYPE_LABEL[p.examType] ?? p.examType}
</Badge>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{p.seq} 号卷</span>
</Link>
))}
</div>
</section>
);
})}
</main>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { QuizQ } from "@/lib/quiz-parse";
import { deleteExamPaper, saveExamPaper } from "../actions";
import { QuestionList } from "./question-list";
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
const EXAM_TYPE_LABEL: Record<string, string> = {
entrance: "入学考",
monthly: "月考",
unit: "单元卷",
midterm: "期中",
final: "期末",
};
export type PaperMeta = {
paperId: string;
title: string;
examType: string;
region: string | null;
year: number | null;
duration: number | null;
fullScore: number | null;
month: number;
seq: number;
sourceUrl: string | null;
summary: string | null;
questions: string;
};
// xuchao: 试卷编辑器——元信息表单 + 结构化题目编辑器;保存走服务端完整校验
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
const router = useRouter();
const [f, setF] = useState({
paperId: paper?.paperId ?? "",
title: paper?.title ?? "",
examType: paper?.examType ?? "monthly",
region: paper?.region ?? "",
year: paper?.year != null ? String(paper.year) : "",
duration: paper?.duration != null ? String(paper.duration) : "",
fullScore: paper?.fullScore != null ? String(paper.fullScore) : "100",
month: String(paper?.month ?? 9),
seq: String(paper?.seq ?? 1),
sourceUrl: paper?.sourceUrl ?? "",
summary: paper?.summary ?? "",
});
// xuchao: 题目用结构化数组管理,保存时序列化为 JSON(与旧 JSON 形状一致)
const [questions, setQuestions] = useState<QuizQ[]>(() => {
try {
return paper ? (JSON.parse(paper.questions) as QuizQ[]) : [];
} catch {
return [];
}
});
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
const [pending, startTransition] = useTransition();
const save = () => {
setMsg(null);
const fd = new FormData();
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
fd.set("questions", JSON.stringify(questions));
startTransition(() => {
saveExamPaper(fd).then((r) => {
if (r.error) setMsg({ text: r.error });
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
});
});
};
const remove = () => {
if (!paper || !confirm(`确定删除试卷「${paper.title}」?孩子的历史作答记录会保留。`)) return;
startTransition(() => {
deleteExamPaper(paper.paperId).then(() => router.push("/parent/exams"));
});
};
const n = (key: keyof typeof f) => ({
value: f[key],
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setF((prev) => ({ ...prev, [key]: e.target.value })),
});
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>试卷编号(唯一,如 g7s1-m09-2)</Label>
<Input disabled={!isNew} placeholder="g7s1-m09-2" {...n("paperId")} />
</div>
<div className="space-y-1">
<Label>试卷标题</Label>
<Input placeholder="2025-2026 学年第一学期 9 月月考" {...n("title")} />
</div>
<div className="space-y-1">
<Label>考试类型</Label>
<select
value={f.examType}
onChange={(e) => setF((prev) => ({ ...prev, examType: e.target.value }))}
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
>
{Object.entries(EXAM_TYPE_LABEL).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label>月份(0=入学考,1-12)</Label>
<select
value={f.month}
onChange={(e) => setF((prev) => ({ ...prev, month: e.target.value }))}
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
>
{[0, 9, 10, 11, 12, 1].map((m) => (
<option key={m} value={m}>
{m === 0 ? "入学 · 分班考" : `${m} 月`}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label>地区(如:湖南长沙)</Label>
<Input placeholder="湖南长沙" {...n("region")} />
</div>
<div className="space-y-1">
<Label>年份</Label>
<Input type="number" placeholder="2025" {...n("year")} />
</div>
<div className="space-y-1">
<Label>考试时长(分钟)</Label>
<Input type="number" placeholder="120" {...n("duration")} />
</div>
<div className="space-y-1">
<Label>卷面满分</Label>
<Input type="number" placeholder="100" {...n("fullScore")} />
</div>
<div className="space-y-1">
<Label>展示序号(组内顺序)</Label>
<Input type="number" placeholder="1" {...n("seq")} />
</div>
<div className="space-y-1">
<Label>来源链接</Label>
<Input placeholder="https://…" {...n("sourceUrl")} />
</div>
</div>
<div className="space-y-1">
<Label>考查说明</Label>
<Input placeholder="考查范围,如:有理数、整式的加减" {...n("summary")} />
</div>
<div className="space-y-1">
<Label>题目(结构化编辑)</Label>
<QuestionList
value={questions}
onChange={(qs) => setQuestions(qs)}
paperId={paper?.paperId ?? f.paperId}
/>
</div>
{msg && <p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>{msg.text}</p>}
<div className="flex flex-wrap items-center gap-2">
<Button disabled={pending} onClick={save}>
<Save className="size-4" /> {pending ? "保存中…" : "保存"}
</Button>
{!isNew && (
<Button variant="outline" disabled={pending} onClick={remove}>
<Trash2 className="size-4 text-destructive" /> 删除试卷
</Button>
)}
{msg?.ok && <CheckCircle2 className="size-4 text-emerald-600" />}
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QuestionList } from "./question-list";
import type { QuizQ } from "@/lib/quiz-parse";
const choiceQ = { type: "choice" as const, stem: "1+1=?", options: ["1", "2"], answer: 1, explain: "e" };
beforeEach(() => {
vi.spyOn(window, "confirm").mockReturnValue(true);
});
describe("QuestionList 结构化编辑", () => {
it("渲染题目卡片(题型徽标+题干)并可删除", () => {
let saved: QuizQ[] = [{ ...choiceQ }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
expect(screen.getByText("选择题")).toBeInTheDocument();
expect(screen.getByText("1+1=?")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("删除第 1 题"));
expect(saved.length).toBe(0);
});
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
const user = userEvent.setup();
let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "choice");
await user.type(screen.getByLabelText("题干"), "2+2=?");
await user.click(screen.getByText("+ 添加选项"));
await user.type(screen.getByPlaceholderText("选项1"), "3");
await user.click(screen.getByText("+ 添加选项"));
await user.type(screen.getByPlaceholderText("选项2"), "4");
fireEvent.click(screen.getByLabelText("选 4 为正确答案"));
await user.type(screen.getByLabelText("解析"), "e1");
await user.click(screen.getByText("完成"));
expect((saved[0] as Record<string, unknown>).stem).toBe("2+2=?");
expect((saved[0] as Record<string, unknown>).answer).toBe(1);
expect((saved[0] as Record<string, unknown>).explain).toBe("e1");
});
it("新建应用题仅 answer 模式(无判分模式 select)", async () => {
const user = userEvent.setup();
render(<QuestionList value={[]} onChange={() => {}} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "solve");
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument();
});
it("编辑存量 rubric 题显示只读徽标", () => {
render(
<QuestionList
value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
onChange={() => {}}
paperId="g7s1-m09-1"
/>,
);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument();
});
it("编辑存量无 judgeMode 字段的题显示 AI 判分徽标与必填参考解答语义", () => {
render(
<QuestionList
value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
onChange={() => {}}
paperId="g7s1-m09-1"
/>,
);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument();
expect(screen.getByText("参考解答(完整解题过程,展示给孩子)")).toBeInTheDocument();
});
it("题干为空保存时报错,不写入列表", async () => {
const user = userEvent.setup();
let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("新增题目"));
await user.click(screen.getByText("完成"));
expect(saved.length).toBe(0);
expect(screen.getByText(/缺少题干/)).toBeInTheDocument();
});
it("删除选项后 answer 下标正确回落", async () => {
const user = userEvent.setup();
let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
// xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7]
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[7]);
// xuchao: 点"完成"保存,onChange 被调用
await user.click(screen.getByText("完成"));
const d = saved[0] as Record<string, unknown>;
expect(d.answer).toBe(1);
});
it("已有配图显示预览并可移除", () => {
let saved: QuizQ[] = [{ ...choiceQ, figure: "tri.png" as const }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.getByAltText("题目配图")).toHaveAttribute("src", "/exam/g7s1-m09-1/tri.png");
fireEvent.click(screen.getByText("移除配图"));
fireEvent.click(screen.getByText("完成"));
expect((saved[0] as Record<string, unknown>).figure).toBeUndefined();
});
it("无配图时显示上传配图按钮", () => {
render(<QuestionList value={[{ ...choiceQ }]} onChange={() => {}} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.getByText("上传配图")).toBeInTheDocument();
});
});
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { Fragment, useRef, useState, useTransition } from "react";
import { ArrowDown, ArrowUp, ImagePlus, Pencil, Plus, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { QuizQ } from "@/lib/quiz-parse";
import { uploadPaperFigure } from "../actions";
const TYPE_LABEL: Record<string, string> = {
judge: "判断题",
choice: "选择题",
fill: "填空题",
solve: "应用题",
};
const TYPE_ORDER = ["judge", "choice", "fill", "solve"];
// xuchao: 结构化题目编辑器——题型联动表单;产出 QuizQ JSON(与服务端校验同文案)
export function QuestionList({
value,
onChange,
paperId,
}: {
value: QuizQ[];
onChange: (qs: QuizQ[]) => void;
paperId: string;
}) {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [draft, setDraft] = useState<QuizQ | null>(null);
const [error, setError] = useState("");
const [figError, setFigError] = useState("");
const [pendingFig, startFig] = useTransition();
const figureRef = useRef<HTMLInputElement>(null);
// xuchao: 配图上传(几何体等)——成功后把文件名写入题目 draft.figure
const uploadFigure = (file: File | undefined) => {
if (!file || pendingFig) return;
setFigError("");
startFig(async () => {
const r = await uploadPaperFigure(paperId, file);
if (r.error) setFigError(r.error);
else update({ figure: r.filename });
});
};
const startNew = () => {
setError("");
setDraft({ type: "choice", stem: "", options: [""], answer: 0, explain: "" });
setEditIndex(null);
};
const startEdit = (i: number) => {
setError("");
setDraft(JSON.parse(JSON.stringify(value[i])) as QuizQ);
setEditIndex(i);
};
const update = (patch: Partial<QuizQ>) => setDraft((d) => ({ ...(d as QuizQ), ...patch }));
const commit = () => {
const d = draft as QuizQ;
const no = `第 ${editIndex != null ? editIndex + 1 : value.length + 1} 题`;
if (!d.stem.trim()) return setError(`${no}缺少题干(stem)`);
if (!d.explain.trim()) return setError(`${no}缺少解析(explain)`);
if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError(`${no}缺少选项(options)`);
if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少参考答案(answers 数组)`);
if (d.type === "solve") {
if (d.judgeMode === "answer") {
if ((d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少最终答案(answers 数组)`);
} else if (!d.reference?.trim()) {
return setError(`${no}缺少参考解答(reference)`);
}
if (!(d.points && d.points > 0)) return setError(`${no}缺少满分(points>0)`);
}
setError("");
const q = { ...d, stem: d.stem.trim(), explain: d.explain.trim() };
const next = editIndex == null ? [...value, q] : value.map((x, i) => (i === editIndex ? q : x));
onChange(next);
setDraft(null);
};
const remove = (i: number) => {
if (confirm("确定删除这道题?")) onChange(value.filter((_, idx) => idx !== i));
};
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= value.length) return;
const next = [...value];
[next[i], next[j]] = [next[j], next[i]];
onChange(next);
};
// xuchao: 编辑表单——编辑存量题渲染在该题卡片下方,新增时渲染在列表末尾
const draftForm = draft ? (
<div className="rounded-xl border border-indigo-200 bg-white p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-slate-700">
题型
<select
value={draft.type}
aria-label="题型"
onChange={(e) => {
const t = e.target.value as QuizQ["type"];
update({ type: t, options: undefined, answers: undefined, reference: undefined, rubric: undefined, judgeMode: undefined, points: undefined, ...(t === "solve" ? { judgeMode: "answer", points: 10 } : {}) });
}}
className="ml-2 h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
>
{TYPE_ORDER.map((t) => (
<option key={t} value={t}>
{TYPE_LABEL[t]}
</option>
))}
</select>
</label>
<Button size="sm" variant="ghost" onClick={() => setDraft(null)}>
<X className="size-4" /> 取消
</Button>
</div>
<div className="space-y-2">
<div className="space-y-1">
<Label>题干</Label>
<Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>题目配图(几何体等,可选)</Label>
{draft.figure && (
<Button size="sm" variant="ghost" className="h-7" onClick={() => update({ figure: undefined })}>
<X className="size-4" /> 移除配图
</Button>
)}
</div>
{draft.figure ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/exam/${paperId}/${draft.figure}`} alt="题目配图" className="max-h-48 rounded-lg border border-slate-200 object-contain" />
{figError && <p className="text-xs font-medium text-destructive">{figError}</p>}
</>
) : (
<div>
<input
ref={figureRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
uploadFigure(e.target.files?.[0]);
e.target.value = "";
}}
/>
<Button type="button" size="sm" variant="outline" className="h-8" disabled={pendingFig} onClick={() => figureRef.current?.click()}>
<ImagePlus className="size-4" /> {pendingFig ? "上传中…" : "上传配图"}
</Button>
{figError && <p className="mt-1 text-xs font-medium text-destructive">{figError}</p>}
</div>
)}
</div>
{draft.type === "choice" && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>选项(点选正确答案)</Label>
<Button size="sm" variant="outline" className="h-7" onClick={() => update({ options: [...(draft.options ?? []), ""] })}>
+ 添加选项
</Button>
</div>
{(draft.options ?? []).map((o, oi) => (
<div key={oi} className="flex items-center gap-2">
<input
type="radio"
name="opt"
checked={draft.answer === oi}
onChange={() => update({ answer: oi })}
aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`}
/>
<Input placeholder={`选项${oi + 1}`} value={o} onChange={(e) => update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} />
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => {
const newOpts = (draft.options ?? []).filter((_, xi) => xi !== oi);
const patch: Partial<QuizQ> = { options: newOpts };
if (draft.answer === oi) patch.answer = 0;
else if (typeof draft.answer === "number" && draft.answer > oi) patch.answer = draft.answer - 1;
update(patch);
}}>
<X className="size-4" />
</Button>
</div>
))}
</div>
)}
{draft.type === "judge" && (
<div className="space-y-1">
<Label>答案</Label>
<div className="flex gap-3 text-sm">
{[0, 1].map((v) => (
<label key={v} className="flex items-center gap-1">
<input type="radio" checked={draft.answer === v} onChange={() => update({ answer: v })} />
{v === 0 ? "对" : "错"}
</label>
))}
</div>
</div>
)}
{(draft.type === "fill" || (draft.type === "solve" && draft.judgeMode === "answer")) && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>{draft.type === "fill" ? "参考答案(等价答法可填多个)" : "最终答案(等价答法可填多个)"}</Label>
<Button size="sm" variant="outline" className="h-7" onClick={() => update({ answers: [...(draft.answers ?? []), ""] })}>
+ 添加答案
</Button>
</div>
{(draft.answers ?? []).map((a, ai) => (
<div key={ai} className="flex items-center gap-2">
<Input value={a} onChange={(e) => update({ answers: (draft.answers ?? []).map((x, xi) => (xi === ai ? e.target.value : x)) })} />
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ answers: (draft.answers ?? []).filter((_, xi) => xi !== ai) })}>
<X className="size-4" />
</Button>
</div>
))}
</div>
)}
{draft.type === "solve" && (
<div className="space-y-2">
<div className="space-y-1">
<Label>分值</Label>
<Input
aria-label="分值"
type="number"
min={1}
placeholder="10"
value={draft.points ?? ""}
onChange={(e) => update({ points: Number(e.target.value) || undefined })}
/>
</div>
<div className="rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-600">
{draft.judgeMode === "answer" ? "仅最终答案(秒判)" : "AI 逐评分点判分(存量题,保留原评分点)"}
</div>
<div className="space-y-1">
<Label>{draft.judgeMode === "answer" ? "参考解答(可选,给孩子看)" : "参考解答(完整解题过程,展示给孩子)"}</Label>
<Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
</div>
</div>
)}
<div className="space-y-1">
<Label>解析</Label>
<Input aria-label="解析" value={draft.explain} onChange={(e) => update({ explain: e.target.value })} />
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Button size="sm" onClick={commit}>
完成
</Button>
</div>
</div>
) : null;
return (
<div className="space-y-3">
{value.map((q, i) => {
const def = q as QuizQ;
return (
<Fragment key={i}>
<div className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
<div className="flex items-center gap-2">
<span className="shrink-0 rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">{TYPE_LABEL[def.type]}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">{def.stem}</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
<button type="button" aria-label={`上移第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => move(i, -1)}>
<ArrowUp className="size-4" />
</button>
<button type="button" aria-label={`下移第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => move(i, 1)}>
<ArrowDown className="size-4" />
</button>
<button type="button" aria-label={`编辑第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => startEdit(i)}>
<Pencil className="size-4" />
</button>
<button type="button" aria-label={`删除第 ${i + 1} 题`} className="text-muted-foreground hover:text-destructive" onClick={() => remove(i)}>
<Trash2 className="size-4" />
</button>
</span>
</div>
</div>
{draft && editIndex === i && draftForm}
</Fragment>
);
})}
{draft && editIndex == null && draftForm}
{!draft && (
<Button variant="outline" onClick={startNew}>
<Plus className="size-4" /> 新增题目
</Button>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useState, useTransition } from "react";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { generateFamilyInvite } from "./actions";
// xuchao: 家庭管理——展示成员列表,生成一次性邀请码让其他家长注册加入
export function FamilyManage({
familyName,
parents,
kids,
}: {
familyName: string;
parents: { id: number; name: string }[];
kids: { id: number; name: string }[];
}) {
const [invite, setInvite] = useState<{ code?: string; error?: string } | null>(null);
const [pending, startTransition] = useTransition();
const gen = () => {
setInvite(null);
startTransition(async () => {
setInvite(await generateFamilyInvite());
});
};
return (
<div className="space-y-3">
<p className="text-sm text-slate-700">
<span className="font-semibold">{familyName}</span> · {parents.length} 位家长 · {kids.length} 个孩子
</p>
<div className="space-y-1.5">
{parents.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{p.name}</span>
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">家长</span>
</div>
))}
{kids.map((k) => (
<div key={k.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{k.name}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">孩子</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="secondary" disabled={pending} onClick={gen}>
<UserPlus className="size-4" /> {pending ? "生成中…" : "生成邀请码"}
</Button>
{invite?.error && <p className="text-sm font-medium text-destructive">{invite.error}</p>}
{invite?.code && (
<p className="text-sm text-slate-700">
邀请码{" "}
<span className="font-mono text-lg font-bold tracking-widest text-indigo-600">{invite.code}</span>
<span className="ml-1 text-xs text-muted-foreground">15 分钟有效,一次性</span>
</p>
)}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Badge } from "@/components/ui/badge";
import { getSessionUser } from "@/lib/auth";
import { getMathPoint } from "@/lib/math";
import { EditorForm } from "../editor-form";
// xuchao: 单个数学知识点编辑页
export default async function MathEditPage({ params }: { params: Promise<{ pointId: string }> }) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const pointId = (await params).pointId;
const point = await getMathPoint(pointId);
if (!point) redirect("/parent/math");
return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex flex-wrap items-center justify-between gap-2">
<Link href="/parent/math" className="text-sm font-medium text-indigo-600">
← 课程列表
</Link>
<div className="flex items-center gap-2">
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">{point.moduleName}</Badge>
<h1 className="text-lg font-extrabold text-slate-900">{point.title}</h1>
</div>
</header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<EditorForm
pointId={point.pointId}
initialTitle={point.title}
initialSummary={point.summary}
initialMd={point.md}
/>
</div>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { mathPoints } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
// xuchao: 家长编辑数学知识点(标题/摘要/课文 markdown)
export async function updateMathPoint(
pointId: string,
data: { title: string; summary: string; md: string },
): Promise<{ ok?: true; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!data.title.trim()) return { error: "标题不能为空" };
if (!data.md.trim()) return { error: "课文内容不能为空" };
await db
.update(mathPoints)
.set({
title: data.title.trim(),
summary: data.summary,
md: data.md,
updatedAt: new Date(),
})
.where(eq(mathPoints.pointId, pointId));
revalidatePath("/math");
revalidatePath(`/math/${pointId}`);
revalidatePath("/parent/math");
revalidatePath(`/parent/math/${pointId}`);
return { ok: true };
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState, useTransition } from "react";
import { Eye, EyeOff, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Md } from "@/app/learn/md";
import { extractQuiz } from "@/lib/quiz-parse";
import { updateMathPoint } from "./actions";
// xuchao: 数学课程编辑器——结构同语法编辑器:改标题/摘要/课文 markdown,可预览,保存即生效
export function EditorForm({
pointId,
initialTitle,
initialSummary,
initialMd,
}: {
pointId: string;
initialTitle: string;
initialSummary: string | null;
initialMd: string;
}) {
const [title, setTitle] = useState(initialTitle);
const [summary, setSummary] = useState(initialSummary ?? "");
const [md, setMd] = useState(initialMd);
const [preview, setPreview] = useState(false);
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
const [pending, startTransition] = useTransition();
const quizCount = extractQuiz(md).length;
const save = () => {
setMsg(null);
startTransition(async () => {
const r = await updateMathPoint(pointId, { title, summary, md });
if (r.error) setMsg({ text: r.error });
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
});
};
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>标题</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="space-y-1">
<Label>学习目标(一句话)</Label>
<Input value={summary} onChange={(e) => setSummary(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label>课文内容(Markdown,含 ```quiz 练习块)</Label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>检测到练习题 {quizCount} 道</span>
<Button variant="outline" size="sm" type="button" onClick={() => setPreview((p) => !p)}>
{preview ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
{preview ? "编辑" : "预览"}
</Button>
</div>
</div>
{preview ? (
<div className="max-h-[480px] overflow-y-auto rounded-xl border border-slate-100 bg-white p-4">
<Md text={md} />
</div>
) : (
<textarea
value={md}
onChange={(e) => setMd(e.target.value)}
rows={18}
className="w-full rounded-lg border border-slate-200 bg-white p-3 font-mono text-xs leading-relaxed focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
)}
</div>
<div className="flex items-center gap-3">
<Button size="lg" disabled={pending} onClick={save}>
<Save className="size-4" /> 保存
</Button>
{msg && (
<p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>
{msg.text}
</p>
)}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import { Pencil } from "lucide-react";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { listMathPoints, MODULES } from "@/lib/math";
// xuchao: 数学思维课程编辑列表——按七大模块分组,点击进入单课编辑
export default async function MathAdmin() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const points = await listMathPoints();
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">数学思维课程编辑</h1>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
点击任意课程即可修改标题、目标与课文内容,保存后孩子端立即生效。
</p>
{MODULES.map((mod) => {
const group = points.filter((p) => p.module === mod.id);
if (group.length === 0) return null;
return (
<section key={mod.id} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<h2 className="mb-3 text-lg font-extrabold text-slate-900">
{mod.name}
<span className="ml-1 text-amber-500">{"★".repeat(mod.stars)}</span>
</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{group.map((p) => (
<Link
key={p.pointId}
href={`/parent/math/${p.pointId}`}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
>
<Pencil className="size-3.5 shrink-0 text-indigo-400" />
<span className="truncate">{p.title}</span>
</Link>
))}
</div>
</section>
);
})}
</main>
);
}
+6 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { addDays, dowOf, todayStr } from "@/lib/dates"; import { addDays, dowOf, todayStr } from "@/lib/dates";
import { subjectMeta } from "@/lib/subjects"; import { subjectMeta } from "@/lib/subjects";
@@ -44,10 +45,13 @@ export function OverviewTab({ data }: { data: OverviewData }) {
{kid.grammar.done}/{kid.grammar.total} {kid.grammar.done}/{kid.grammar.total}
</p> </p>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft"> <Link
href={`/parent/wrongbook?kid=${kid.id}`}
className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft transition-all duration-200 hover:-translate-y-0.5 hover:border-rose-200"
>
<p className="text-xs text-muted-foreground">待掌握错题</p> <p className="text-xs text-muted-foreground">待掌握错题</p>
<p className="text-lg font-extrabold text-rose-600">{kid.wrongOpen}</p> <p className="text-lg font-extrabold text-rose-600">{kid.wrongOpen}</p>
</div> </Link>
<div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft"> <div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft">
<p className="text-xs text-muted-foreground">最近周测</p> <p className="text-xs text-muted-foreground">最近周测</p>
<p className="text-lg font-extrabold text-violet-600"> <p className="text-lg font-extrabold text-violet-600">
+47 -98
View File
@@ -13,17 +13,25 @@ import {
weeklyTest, weeklyTest,
wrongBook, wrongBook,
wordRecording, wordRecording,
families,
} from "@/db/schema"; } from "@/db/schema";
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth"; import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
import { addDays, dowOf, todayStr } from "@/lib/dates"; import { addDays, dowOf, todayStr } from "@/lib/dates";
import { passedCount, totalPoints } from "@/lib/grammar"; import { passedCount, totalPoints } from "@/lib/grammar";
import {
POINT_EVENT_LABELS,
listPointRules,
pointHistory,
totalPoints as childPointsTotal,
} from "@/lib/points";
import { viewUrl } from "@/lib/oss"; import { viewUrl } from "@/lib/oss";
import { import {
ParentDashboard, ParentDashboard,
type DevicesData, type DevicesData,
type FamilyData,
type OverviewData, type OverviewData,
type PointsData,
type TemplatesData, type TemplatesData,
type WeeklyRow,
} from "./dashboard"; } from "./dashboard";
const SH = { timeZone: "Asia/Shanghai" } as const; const SH = { timeZone: "Asia/Shanghai" } as const;
@@ -136,6 +144,7 @@ export default async function ParentPage() {
title: t.title, title: t.title,
subject: t.subject, subject: t.subject,
requiresPhoto: t.requiresPhoto, requiresPhoto: t.requiresPhoto,
points: t.points,
mode: t.mode, mode: t.mode,
dateStart: t.dateStart, dateStart: t.dateStart,
dateEnd: t.dateEnd, dateEnd: t.dateEnd,
@@ -171,110 +180,50 @@ export default async function ParentPage() {
})), })),
}; };
// ===== 周测成绩(课程管理) ===== // ===== 积分管理(规则 + 每孩子总积分/最近流水) =====
const weeklyRows = await db const rules = await listPointRules();
.select({ const pointsData: PointsData = { rules, kids: [] };
childName: users.displayName, const eventLabel = (t: string) => (t === "checkin" ? "打卡" : POINT_EVENT_LABELS[t] ?? t);
weekKey: weeklyTest.weekKey, for (const kid of kids) {
score: weeklyTest.score, const [total, recent] = await Promise.all([childPointsTotal(kid.id), pointHistory(kid.id, 5)]);
passed: weeklyTest.passed, pointsData.kids.push({
}) id: kid.id,
.from(weeklyTest) name: kid.name,
.innerJoin(users, eq(users.id, weeklyTest.childUserId)) total,
.where(eq(users.familyId, parent.familyId)) recent: recent.map((r) => ({
.orderBy(desc(weeklyTest.takenAt)) id: r.id,
.limit(10); label: eventLabel(r.eventType),
const weekly: WeeklyRow[] = weeklyRows.map((w) => ({ remark: r.remark,
childName: w.childName, points: r.points,
weekKey: w.weekKey, date: r.createdAt.toLocaleDateString("zh-CN", SH),
score: w.score ?? 0, })),
passed: w.passed,
}));
// ===== 单词跟读录音(课程管理回放) =====
const recRows = await db
.select({
childName: users.displayName,
word: wordRecording.word,
ossKey: wordRecording.ossKey,
createdAt: wordRecording.createdAt,
})
.from(wordRecording)
.innerJoin(users, eq(users.id, wordRecording.childUserId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(wordRecording.createdAt))
.limit(20);
const recordings = recRows.map((r) => ({
childName: r.childName,
word: r.word,
url: viewUrl(r.ossKey),
at: dstr(r.createdAt),
}));
// ===== 英语语法检测明细 =====
const grammarRows = await db
.select({
childName: users.displayName,
title: grammarPoints.title,
score: grammarProgress.score,
passed: grammarProgress.passed,
completedAt: grammarProgress.completedAt,
})
.from(grammarProgress)
.innerJoin(users, eq(users.id, grammarProgress.childUserId))
.innerJoin(grammarPoints, eq(grammarPoints.pointId, grammarProgress.pointId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(grammarProgress.completedAt))
.limit(20);
const grammarTests = grammarRows.map((g) => ({
childName: g.childName,
title: g.title,
score: g.score ?? 0,
passed: g.passed,
at: g.completedAt ? dstr(g.completedAt) : "",
}));
// ===== 孩子错题数据 =====
const wrongRows = await db
.select({
childName: users.displayName,
question: wrongBook.question,
userAnswer: wrongBook.userAnswer,
correctAnswer: wrongBook.correctAnswer,
sourceTitle: wrongBook.sourceTitle,
mastered: wrongBook.mastered,
})
.from(wrongBook)
.innerJoin(users, eq(users.id, wrongBook.childUserId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(wrongBook.createdAt))
.limit(30);
const wrongItems = wrongRows.map((w) => {
let stem = "";
try {
stem = (JSON.parse(w.question) as { stem?: string }).stem ?? "";
} catch {
stem = "";
}
return {
childName: w.childName,
stem,
userAnswer: w.userAnswer ?? "",
correctAnswer: w.correctAnswer ?? "",
sourceTitle: w.sourceTitle ?? "",
mastered: w.mastered,
};
}); });
}
// ===== 家庭管理(家庭名 + 全部成员) =====
const [familyRow] = await db
.select({ name: families.name })
.from(families)
.where(eq(families.id, parent.familyId))
.limit(1);
const parentRows = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(and(eq(users.familyId, parent.familyId), eq(users.role, "parent")))
.orderBy(asc(users.id));
const family: FamilyData = {
familyName: familyRow?.name ?? "我的家庭",
parents: parentRows.map((p) => ({ id: p.id, name: p.displayName })),
kids: kids.map((k) => ({ id: k.id, name: k.name })),
};
return ( return (
<ParentDashboard <ParentDashboard
overview={overview} overview={overview}
templates={templates} templates={templates}
devices={devices} devices={devices}
weekly={weekly} points={pointsData}
recordings={recordings} family={family}
grammarTests={grammarTests}
wrongItems={wrongItems}
/> />
); );
} }
+90
View File
@@ -0,0 +1,90 @@
"use client";
// xuchao: 积分管理——测试类规则分值配置 + 每个孩子总积分/最近流水
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { savePointRules } from "./actions";
import type { PointsData } from "./dashboard";
export function PointsManage({ data }: { data: PointsData }) {
const [values, setValues] = useState<Record<string, number>>(
Object.fromEntries(data.rules.map((r) => [r.eventType, r.points])),
);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const save = () => {
setError("");
setSaved(false);
startTransition(async () => {
try {
await savePointRules(Object.entries(values).map(([eventType, points]) => ({ eventType, points })));
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
}
});
};
return (
<div className="space-y-4">
<div className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
<h4 className="text-sm font-bold text-slate-900">测试类达标分值</h4>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
{data.rules.map((r) => (
<label key={r.eventType} className="flex items-center gap-2 text-sm text-slate-700">
<span className="flex-1">{r.label}</span>
<Input
type="number"
min={0}
max={9999}
value={values[r.eventType]}
onChange={(e) => {
setValues((v) => ({ ...v, [r.eventType]: Number(e.target.value) }));
setSaved(false);
}}
className="h-8 w-20 text-right"
/>
<span className="text-xs text-muted-foreground">分</span>
</label>
))}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={pending} onClick={save}>
保存分值
</Button>
{saved && <span className="text-xs font-medium text-emerald-600">已保存,下次达标生效</span>}
{error && <span className="text-xs font-medium text-destructive">{error}</span>}
</div>
</div>
<div className="space-y-3">
{data.kids.length === 0 && <p className="text-sm text-muted-foreground">还没有孩子,先去孩子管理添加。</p>}
{data.kids.map((k) => (
<div key={k.id} className="rounded-xl border border-slate-100 bg-white p-3">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-900">{k.name}</span>
<span className="text-sm font-extrabold text-amber-600">🪙 {k.total} 积分</span>
</div>
{k.recent.length === 0 ? (
<p className="mt-1.5 text-xs text-muted-foreground">暂无积分记录</p>
) : (
<div className="mt-1.5 space-y-1">
{k.recent.map((r) => (
<p key={r.id} className="flex items-center gap-2 text-xs text-slate-600">
<span className="flex-1 truncate">
{r.label} · {r.remark} · {r.date}
</span>
<span className="shrink-0 font-bold text-amber-600">+{r.points}</span>
</p>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
+34 -2
View File
@@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { addDays, dowOf, mondayOf, todayStr } from "@/lib/dates"; import { addDays, dowOf, mondayOf, todayStr } from "@/lib/dates";
import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects"; import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects";
import { addTemplate, deleteTemplate } from "./actions"; import { addTemplate, deleteTemplate, updateTemplatePoints } from "./actions";
import type { TemplatesData } from "./dashboard"; import type { TemplatesData } from "./dashboard";
type Item = TemplatesData["kids"][number]["items"][number]; type Item = TemplatesData["kids"][number]["items"][number];
@@ -31,6 +31,7 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
const [subject, setSubject] = useState<Subject>("other"); const [subject, setSubject] = useState<Subject>("other");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [photo, setPhoto] = useState(false); const [photo, setPhoto] = useState(false);
const [points, setPoints] = useState(10);
const [rangeStart, setRangeStart] = useState(todayStr()); const [rangeStart, setRangeStart] = useState(todayStr());
const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6)); const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6));
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -59,11 +60,13 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
fd.set("dateEnd", rangeEnd); fd.set("dateEnd", rangeEnd);
} }
if (photo) fd.set("requiresPhoto", "on"); if (photo) fd.set("requiresPhoto", "on");
fd.set("points", String(points));
startTransition(async () => { startTransition(async () => {
try { try {
await addTemplate(fd); await addTemplate(fd);
setTitle(""); setTitle("");
setPhoto(false); setPhoto(false);
setPoints(10);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "添加失败"); setError(err instanceof Error ? err.message : "添加失败");
} }
@@ -136,10 +139,27 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
<span className="font-medium">{sm.label}</span> <span className="font-medium">{sm.label}</span>
<span className="text-slate-600">{t.title}</span> <span className="text-slate-600">{t.title}</span>
<span className="text-xs text-muted-foreground">({modeLabel[t.mode] ?? t.mode})</span> <span className="text-xs text-muted-foreground">({modeLabel[t.mode] ?? t.mode})</span>
<span className="ml-auto flex items-center gap-1 text-xs text-amber-600" title="完成该任务可得的积分,改后当天未完成任务立即生效">
🪙
<input
type="number"
min={0}
max={9999}
defaultValue={t.points}
aria-label={`${t.title} 积分`}
onBlur={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && Math.round(v) !== t.points)
startTransition(() => updateTemplatePoints(t.id, Math.round(v)));
}}
className="h-6 w-12 rounded-md border border-amber-200 bg-white px-1 text-right"
/>
分
</span>
<button <button
type="button" type="button"
aria-label={`删除 ${t.title}`} aria-label={`删除 ${t.title}`}
className="ml-auto text-muted-foreground hover:text-destructive" className="text-muted-foreground hover:text-destructive"
onClick={() => startTransition(() => deleteTemplate(t.id))} onClick={() => startTransition(() => deleteTemplate(t.id))}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
@@ -192,6 +212,18 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
<Checkbox id="tpl-photo" checked={photo} onCheckedChange={(v) => setPhoto(v === true)} /> <Checkbox id="tpl-photo" checked={photo} onCheckedChange={(v) => setPhoto(v === true)} />
<Label htmlFor="tpl-photo">需拍照</Label> <Label htmlFor="tpl-photo">需拍照</Label>
</div> </div>
<div className="space-y-1">
<Label>积分</Label>
<Input
type="number"
min={0}
max={9999}
value={points}
onChange={(e) => setPoints(Number(e.target.value))}
className="h-9 w-20 text-right"
title="完成该任务可得的积分"
/>
</div>
<Button type="submit" disabled={pending || title.trim() === ""}> <Button type="submit" disabled={pending || title.trim() === ""}>
添加 添加
</Button> </Button>
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db";
import { users, weeklyTest } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { WeeklyList } from "./weekly-list";
// xuchao: 周测成绩管理——编辑分数/达标、删除误生成记录(不联动积分打卡)
export default async function WeeklyRecordsPage() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const rows = await db
.select({
id: weeklyTest.id,
childName: users.displayName,
weekKey: weeklyTest.weekKey,
score: weeklyTest.score,
passed: weeklyTest.passed,
takenAt: weeklyTest.takenAt,
})
.from(weeklyTest)
.innerJoin(users, eq(users.id, weeklyTest.childUserId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(weeklyTest.takenAt))
.limit(100);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">每周检测成绩管理</h1>
<span className="text-xs text-muted-foreground">最近 {rows.length} 条</span>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
修改分数/达标状态或删除记录不会影响已发的积分与当日打卡。
</p>
<WeeklyList
rows={rows.map((r) => ({
id: r.id,
childName: r.childName,
weekKey: r.weekKey,
score: r.score ?? 0,
passed: r.passed,
takenAt: r.takenAt.toLocaleDateString("zh-CN"),
}))}
/>
</main>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Pencil, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { deleteWeeklyRecord, updateWeeklyRecord } from "../actions";
export type WeeklyRowItem = {
id: number;
childName: string;
weekKey: string;
score: number;
passed: boolean;
takenAt: string;
};
// xuchao: 周测成绩行内编辑(分数+达标开关)、删除;保存后刷新服务端列表
export function WeeklyList({ rows }: { rows: WeeklyRowItem[] }) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [score, setScore] = useState("");
const [passed, setPassed] = useState(false);
const [msg, setMsg] = useState("");
const [pending, startTransition] = useTransition();
const startEdit = (r: WeeklyRowItem) => {
setEditingId(r.id);
setScore(String(r.score));
setPassed(r.passed);
setMsg("");
};
const save = () => {
if (editingId == null) return;
const s = Number(score);
const err = (t: string) => setMsg(t);
startTransition(async () => {
const r = await updateWeeklyRecord(editingId, s, passed);
if (r) err(r);
else {
setEditingId(null);
router.refresh();
}
});
};
const remove = (id: number, label: string) => {
if (!confirm(`确定删除「${label}」的这条周测记录?孩子可重新检测。`)) return;
startTransition(() => {
deleteWeeklyRecord(id).then(() => router.refresh());
});
};
return (
<div className="space-y-2.5">
{rows.length === 0 && (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-sm text-muted-foreground">暂无周测记录</p>
</div>
)}
{rows.map((r) => (
<div key={r.id} className="rounded-xl border border-slate-100 bg-white p-3 shadow-soft">
{editingId === r.id ? (
<div className="space-y-2.5">
<p className="text-sm font-medium text-slate-800">
{r.childName} · {r.weekKey}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-sm text-slate-600">
分数
<Input
className="h-8 w-20"
type="number"
min={0}
max={1000}
value={score}
onChange={(e) => setScore(e.target.value)}
/>
</div>
<label className="flex items-center gap-1.5 text-sm text-slate-600">
<Checkbox checked={passed} onCheckedChange={(v) => setPassed(v === true)} />
达标
</label>
{msg && <span className="text-xs font-medium text-destructive">{msg}</span>}
</div>
<div className="flex gap-2">
<Button size="sm" disabled={pending} onClick={save}>
<Check className="size-4" /> 保存
</Button>
<Button size="sm" variant="outline" disabled={pending} onClick={() => setEditingId(null)}>
<X className="size-4" /> 取消
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{r.childName}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">{r.weekKey}</span>
<span className={cn("text-sm font-bold", r.passed ? "text-emerald-600" : "text-rose-600")}>
{r.score} 分 {r.passed ? "✅" : "❌"}
</span>
<span className="ml-auto text-xs text-muted-foreground">{r.takenAt}</span>
<button type="button" aria-label="编辑" className="text-muted-foreground hover:text-indigo-600" onClick={() => startEdit(r)}>
<Pencil className="size-4" />
</button>
<button
type="button"
aria-label="删除"
className="text-muted-foreground hover:text-destructive"
onClick={() => remove(r.id, `${r.childName} ${r.weekKey}`)}
>
<Trash2 className="size-4" />
</button>
</div>
)}
</div>
))}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { wordsByDay } from "@/lib/words";
import { DayForm } from "../day-form";
// xuchao: 某一天的单词编辑页
export default async function WordsDayAdmin({ params }: { params: Promise<{ day: string }> }) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const day = Number((await params).day);
if (!Number.isInteger(day) || day < 1) redirect("/parent/words");
const words = await wordsByDay(day);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent/words" className="text-sm font-medium text-indigo-600">
← 天数列表
</Link>
<h1 className="text-xl font-extrabold text-slate-900">第 {day} 天单词</h1>
</header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<DayForm day={day} initial={words.map((w) => ({ word: w.word, meaning: w.meaning }))} />
</div>
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { revalidatePath } from "next/cache";
import { eq, sql } from "drizzle-orm";
import raw from "@/content/words/words.json";
import { db } from "@/db";
import { wordEntry } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
// xuchao: 整日保存单词——事务内删旧插新;保留词沿用原 seq,新词接续全库最大 seq
export async function saveDayWords(
day: number,
words: { word: string; meaning: string }[],
): Promise<{ ok?: true; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!Number.isInteger(day) || day < 1) return { error: "天数无效" };
const cleaned = words
.map((w) => ({ word: w.word.trim(), meaning: w.meaning.trim() }))
.filter((w) => w.word);
if (cleaned.length === 0) return { error: "至少保留一个单词" };
if (new Set(cleaned.map((w) => w.word.toLowerCase())).size !== cleaned.length)
return { error: "同一天内单词不能重复" };
await db.transaction(async (tx) => {
// 首次编辑(表还没灌种子):先全量灌入打包词库,否则只存一天会丢失其余天
const [cnt] = await tx.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
if (cnt.n === 0) {
const rows = raw as { word: string; meaning: string; day: number; seq: number }[];
for (let i = 0; i < rows.length; i += 500) await tx.insert(wordEntry).values(rows.slice(i, i + 500));
}
const oldRows = await tx.select().from(wordEntry).where(eq(wordEntry.day, day));
const seqByWord = new Map(oldRows.map((r) => [r.word, r.seq]));
await tx.delete(wordEntry).where(eq(wordEntry.day, day));
const [mx] = await tx.select({ m: sql<number>`COALESCE(MAX(seq),0)` }).from(wordEntry);
let seq = mx.m;
await tx.insert(wordEntry).values(
cleaned.map((w) => ({
word: w.word,
meaning: w.meaning,
day,
seq: seqByWord.get(w.word) ?? ++seq,
})),
);
});
revalidatePath("/words");
revalidatePath(`/words/${day}`);
revalidatePath("/parent/words");
return { ok: true };
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, Save, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { saveDayWords } from "./actions";
// xuchao: 按天编辑单词——逐词行编辑,可增删行,整日保存
export function DayForm({
day,
initial,
}: {
day: number;
initial: { word: string; meaning: string }[];
}) {
const [rows, setRows] = useState(initial);
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
const [pending, startTransition] = useTransition();
const set = (i: number, key: "word" | "meaning", v: string) => {
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, [key]: v } : r)));
setMsg(null);
};
const save = () => {
setMsg(null);
startTransition(async () => {
const r = await saveDayWords(day, rows);
if (r.error) setMsg({ text: r.error });
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
});
};
return (
<div className="space-y-4">
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={r.word}
onChange={(e) => set(i, "word", e.target.value)}
placeholder="单词"
className="w-36 shrink-0 font-medium"
/>
<Input value={r.meaning} onChange={(e) => set(i, "meaning", e.target.value)} placeholder="中文释义" />
<button
type="button"
aria-label="删除该词"
onClick={() => {
setRows((rs) => rs.filter((_, j) => j !== i));
setMsg(null);
}}
className="shrink-0 rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="size-4" />
</button>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
type="button"
onClick={() => {
setRows((rs) => [...rs, { word: "", meaning: "" }]);
setMsg(null);
}}
>
<Plus className="size-4" /> 添加单词
</Button>
<Button size="lg" disabled={pending} onClick={save}>
<Save className="size-4" /> 保存第 {day} 天
</Button>
{msg && (
<p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>
{msg.text}
</p>
)}
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { allWords } from "@/lib/words";
// xuchao: 单词词库编辑列表——按天网格,格内显示当天词数,点击进入当天编辑
export default async function WordsAdmin() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const ws = await allWords();
const counts = new Map<number, number>();
let totalDays = 0;
for (const w of ws) {
counts.set(w.day, (counts.get(w.day) ?? 0) + 1);
totalDays = Math.max(totalDays, w.day);
}
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">英语单词编辑</h1>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
共 {ws.length} 词 / {totalDays} 天。点击某一天可增删改当天单词,保存后孩子端立即生效。
</p>
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-7">
{Array.from({ length: totalDays }, (_, i) => i + 1).map((d) => (
<Link
key={d}
href={`/parent/words/${d}`}
className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-white py-2 text-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
>
<span className="font-bold text-slate-800">第{d}天</span>
<span className="text-xs text-muted-foreground">{counts.get(d) ?? 0} 词</span>
</Link>
))}
</div>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { users, wrongBook } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { setMastered } from "@/lib/wrongbook";
// xuchao: 家长标记某错题已掌握/未掌握(仅限本家庭孩子,防越权)
export async function markMastered(id: number, mastered: boolean): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db.select().from(wrongBook).where(eq(wrongBook.id, id)).limit(1);
if (!row) return;
const [child] = await db.select().from(users).where(eq(users.id, row.childUserId)).limit(1);
if (!child || child.role !== "child" || child.familyId !== parent.familyId) return;
await setMastered(id, row.childUserId, mastered);
revalidatePath("/parent");
revalidatePath("/parent/wrongbook");
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { and, eq } from "drizzle-orm";
import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db";
import { users } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { listWrong } from "@/lib/wrongbook";
import { photoViewUrl } from "@/lib/oss";
import type { QuizQ } from "@/lib/quiz-parse";
import { WrongRow } from "@/app/wrongbook/wrong-row";
import { markMastered } from "./actions";
// xuchao: 家长查看某孩子待掌握错题(?kid=),家庭归属校验防越权
export default async function ParentWrongBookPage({
searchParams,
}: {
searchParams: Promise<{ kid?: string }>;
}) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const kidId = Number((await searchParams).kid);
if (!Number.isInteger(kidId)) redirect("/parent");
const [kid] = await db
.select({ id: users.id, name: users.displayName })
.from(users)
.where(and(eq(users.id, kidId), eq(users.role, "child"), eq(users.familyId, parent.familyId)))
.limit(1);
if (!kid) redirect("/parent");
const rows = (await listWrong(kid.id)).filter((r) => !r.mastered);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">待掌握错题</h1>
<span className="text-xs text-muted-foreground">{kid.name} · {rows.length} 题</span>
</header>
{rows.length === 0 ? (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-3xl">🎉</p>
<p className="mt-2 font-bold text-slate-900">没有待掌握错题!</p>
</div>
) : (
<div className="space-y-3">
{rows.map((r) => {
let q: QuizQ;
try {
q = JSON.parse(r.question) as QuizQ;
} catch {
return null;
}
// xuchao: 解答题展示当时的作答照片(OSS 签名地址;开发环境为本地回退)
const photoUrl = q.type === "solve" && q.solveSnap?.ossKey ? photoViewUrl(q.solveSnap.ossKey) : "";
return (
<WrongRow
key={r.id}
id={r.id}
sourceTitle={r.sourceTitle}
q={q}
userAnswer={r.userAnswer}
correctAnswer={r.correctAnswer}
mastered={r.mastered}
photoUrl={photoUrl}
markAction={markMastered}
/>
);
})}
</div>
)}
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
// xuchao: 孩子积分页——总积分 + 流水明细(只增不减)
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { pointHistory, totalPoints, POINT_EVENT_LABELS } from "@/lib/points";
const SH = { timeZone: "Asia/Shanghai" } as const;
// checkin 的分值在任务上,不属测试类规则,单独给个名字
const eventLabel = (eventType: string) =>
eventType === "checkin" ? "打卡" : POINT_EVENT_LABELS[eventType] ?? eventType;
export default async function PointsPage() {
const user = await getSessionUser();
if (!user) redirect("/join");
if (user.role !== "child") redirect("/parent");
const [total, rows] = await Promise.all([totalPoints(user.id), pointHistory(user.id, 50)]);
return (
<main className="relative mx-auto min-h-dvh max-w-lg space-y-5 p-4 pb-12">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/kid" className="text-sm font-medium text-indigo-600">
← 返回
</Link>
<h1 className="text-xl font-extrabold text-slate-900">我的积分</h1>
<span className="w-10" />
</header>
<div className="rounded-2xl bg-gradient-to-r from-yellow-400 to-amber-500 p-6 text-center text-white shadow-btn">
<p className="text-5xl font-extrabold">🪙 {total}</p>
<p className="mt-2 text-sm opacity-90">完成任务和达标检测都会攒积分,继续加油!</p>
</div>
<section className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<h2 className="font-extrabold text-slate-900">积分明细</h2>
{rows.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">还没有积分记录,去完成今天的任务吧!</p>
) : (
<div className="mt-2 divide-y divide-slate-100">
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-2 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-800">{r.remark || eventLabel(r.eventType)}</p>
<p className="text-xs text-muted-foreground">
{eventLabel(r.eventType)} · {r.createdAt.toLocaleDateString("zh-CN", SH)}
</p>
</div>
<span className="shrink-0 text-base font-extrabold text-amber-600">+{r.points}</span>
</div>
))}
</div>
)}
</section>
</main>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { and, eq, gt, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/db";
import { familyInvites, users } from "@/db/schema";
import { createSession } from "@/lib/auth";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
const registerSchema = z.object({
displayName: z.string().trim().min(1, "请输入称呼").max(20, "称呼最多 20 字"),
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
password: z.string().min(6, "密码至少 6 位"),
code: z.string().regex(/^\d{6}$/, "邀请码是 6 位数字"),
});
export type RegisterState = { error?: string } | null;
// xuchao: 家长凭邀请码注册:校验码→查重手机号→事务(码标记已用+建家长)→自动登录跳家长中心
export async function registerWithInvite(
_prev: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const parsed = registerSchema.safeParse({
displayName: formData.get("displayName"),
phone: formData.get("phone"),
password: formData.get("password"),
code: formData.get("code"),
});
if (!parsed.success) return { error: parsed.error.issues[0].message };
const { displayName, phone, password, code } = parsed.data;
const [invite] = await db
.select({ id: familyInvites.id, familyId: familyInvites.familyId })
.from(familyInvites)
.where(
and(
eq(familyInvites.codeHash, hashCode(code)),
isNull(familyInvites.usedAt),
gt(familyInvites.expiresAt, new Date()),
),
)
.limit(1);
if (!invite) return { error: "邀请码无效或已过期,请让家长重新生成" };
const [dup] = await db.select({ id: users.id }).from(users).where(eq(users.phone, phone)).limit(1);
if (dup) return { error: "该手机号已注册" };
const passwordHash = await bcrypt.hash(password, 10);
const newUserId = await db.transaction(async (tx) => {
await tx.update(familyInvites).set({ usedAt: new Date() }).where(eq(familyInvites.id, invite.id));
const [created] = await tx
.insert(users)
.values({ familyId: invite.familyId, role: "parent", displayName, phone, passwordHash })
.$returningId();
return created.id;
});
await createSession(newUserId, "家长设备", 30);
redirect("/parent");
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { BackgroundBlobs } from "@/components/background-blobs";
import { registerWithInvite } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const [state, formAction, pending] = useActionState(registerWithInvite, null);
return (
<main className="relative flex min-h-dvh items-center justify-center p-4">
<BackgroundBlobs />
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
<CardHeader className="items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-btn">
<UserPlus className="size-8 text-white" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight">加入家庭</h1>
<p className="text-sm text-muted-foreground">需要家里已有成员的邀请码</p>
</CardHeader>
<CardContent>
<form action={formAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">称呼</Label>
<Input id="displayName" name="displayName" placeholder="如:妈妈" required />
</div>
<div className="space-y-2">
<Label htmlFor="phone">手机号</Label>
<Input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
autoComplete="username"
placeholder="11 位手机号"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="至少 6 位"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code">邀请码</Label>
<Input
id="code"
name="code"
inputMode="numeric"
maxLength={6}
placeholder="6 位数字"
className="font-mono tracking-widest"
required
/>
</div>
{state?.error && <p className="text-sm font-medium text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? "注册中…" : "注册并加入家庭"}
</Button>
<p className="text-center text-sm text-muted-foreground">
已有账号?{" "}
<Link href="/login" className="font-semibold text-indigo-600 underline-offset-4 hover:underline">
去登录
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}
+7 -3
View File
@@ -9,9 +9,10 @@ import { recordWrong } from "@/lib/wrongbook";
import { db } from "@/db"; import { db } from "@/db";
import { taskInstances, weeklyTest } from "@/db/schema"; import { taskInstances, weeklyTest } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { awardRulePoints } from "@/lib/points";
export type WeeklyResult = export type WeeklyResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string }; | { error: string };
// 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡 // 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡
@@ -43,8 +44,10 @@ export async function takeWeekly(answers: AnswerValue[]): Promise<WeeklyResult>
}) })
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } }); .onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
// 达标关联当日英语打卡 // 达标关联当日英语打卡,并发积分(首次达标才发)
let pointsEarned = 0;
if (g.passed) { if (g.passed) {
pointsEarned = await awardRulePoints(user.id, "grammar_weekly", `week-${wk}`, `英语周检测 ${wk}`);
const today = todayStr(); const today = todayStr();
const title = `英语周检测 · ${wk}`; const title = `英语周检测 · ${wk}`;
const [ex] = await db const [ex] = await db
@@ -72,5 +75,6 @@ export async function takeWeekly(answers: AnswerValue[]): Promise<WeeklyResult>
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/wrongbook"); revalidatePath("/wrongbook");
return g; revalidatePath("/points");
return { ...g, pointsEarned };
} }
+4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs"; import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly"; import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly";
import { getAwardedPoints } from "@/lib/points";
import { QuizBlock } from "@/app/learn/quiz-block"; import { QuizBlock } from "@/app/learn/quiz-block";
import { takeWeekly } from "./actions"; import { takeWeekly } from "./actions";
@@ -14,6 +15,8 @@ export default async function WeeklyPage() {
const wk = currentWeekKey(); const wk = currentWeekKey();
const record = await getWeeklyRecord(user.id, wk); const record = await getWeeklyRecord(user.id, wk);
const questions = record ? [] : await buildWeeklyQuestions(user.id, wk); const questions = record ? [] : await buildWeeklyQuestions(user.id, wk);
// xuchao: 已有记录时从流水取回本周一达标的积分,结果页刷新后仍显示
const awarded = record?.passed ? await getAwardedPoints(user.id, "grammar_weekly", `week-${wk}`) : 0;
return ( return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12"> <main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
@@ -41,6 +44,7 @@ export default async function WeeklyPage() {
<p className="mt-1 text-sm opacity-90"> <p className="mt-1 text-sm opacity-90">
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"} 答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
</p> </p>
{record.passed && awarded > 0 && <p className="mt-1 text-base font-extrabold">🪙 +{awarded} 积分</p>}
</div> </div>
) : ( ) : (
<QuizBlock pointId="" questions={questions} submitter={takeWeekly} /> <QuizBlock pointId="" questions={questions} submitter={takeWeekly} />
+7 -5
View File
@@ -12,7 +12,7 @@ export default async function WordDayPage({ params }: { params: Promise<{ day: s
if (user.role !== "child") redirect("/parent"); if (user.role !== "child") redirect("/parent");
const day = Number((await params).day); const day = Number((await params).day);
const words = wordsByDay(day); const words = await wordsByDay(day);
if (words.length === 0) redirect("/words"); if (words.length === 0) redirect("/words");
const [prog] = await db const [prog] = await db
@@ -22,18 +22,20 @@ export default async function WordDayPage({ params }: { params: Promise<{ day: s
.limit(1); .limit(1);
const learned: string[] = prog?.learned ? JSON.parse(prog.learned) : []; const learned: string[] = prog?.learned ? JSON.parse(prog.learned) : [];
const items = words.map((w) => ({ const items = await Promise.all(
words.map(async (w) => ({
word: w.word, word: w.word,
meaning: w.meaning, meaning: w.meaning,
similar: similarWords(w.word, 3).map((s) => ({ word: s.word, meaning: s.meaning })), similar: (await similarWords(w.word, 3)).map((s) => ({ word: s.word, meaning: s.meaning })),
})); })),
);
return ( return (
<WordDayClient <WordDayClient
day={day} day={day}
items={items} items={items}
learned={learned} learned={learned}
test={buildWordTest(day)} test={await buildWordTest(day)}
passed={prog?.passed ?? false} passed={prog?.passed ?? false}
score={prog?.score ?? null} score={prog?.score ?? null}
/> />
+11 -4
View File
@@ -8,6 +8,7 @@ import { getSessionUser } from "@/lib/auth";
import { buildWordTest } from "@/lib/words"; import { buildWordTest } from "@/lib/words";
import { uploadUrl } from "@/lib/oss"; import { uploadUrl } from "@/lib/oss";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { awardRulePoints } from "@/lib/points";
// 跟读录音直传签名(按实际录音类型签名,避免签名不匹配) // 跟读录音直传签名(按实际录音类型签名,避免签名不匹配)
export async function getRecUploadUrl(word: string, contentType: string): Promise<{ url: string; key: string }> { export async function getRecUploadUrl(word: string, contentType: string): Promise<{ url: string; key: string }> {
@@ -39,14 +40,16 @@ export async function markLearned(day: number, word: string): Promise<void> {
revalidatePath("/words/[day]", "layout"); revalidatePath("/words/[day]", "layout");
} }
// 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡 // 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡并发积分
export async function submitWordTest( export async function submitWordTest(
day: number, day: number,
answers: number[], answers: number[],
): Promise<{ score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { error: string }> { ): Promise<
{ score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number } | { error: string }
> {
const u = await child(); const u = await child();
if (!u) return { error: "会话无效" }; if (!u) return { error: "会话无效" };
const qs = buildWordTest(day); const qs = await buildWordTest(day);
if (qs.length === 0) return { error: "当天暂无单词" }; if (qs.length === 0) return { error: "当天暂无单词" };
let correct = 0; let correct = 0;
const detail = qs.map((q, i) => { const detail = qs.map((q, i) => {
@@ -61,7 +64,10 @@ export async function submitWordTest(
.values({ childUserId: u.id, day, score, passed, completedAt: new Date() }) .values({ childUserId: u.id, day, score, passed, completedAt: new Date() })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } }); .onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
// xuchao: 首次达标发积分(唯一键防刷,重考不再发)
let pointsEarned = 0;
if (passed) { if (passed) {
pointsEarned = await awardRulePoints(u.id, "word_test", `day-${day}`, `英语单词 · 第${day}天`);
const today = todayStr(); const today = todayStr();
const title = `英语单词 · 第${day}天`; const title = `英语单词 · 第${day}天`;
const [ex] = await db const [ex] = await db
@@ -88,7 +94,8 @@ export async function submitWordTest(
revalidatePath("/words/[day]", "layout"); revalidatePath("/words/[day]", "layout");
revalidatePath("/kid"); revalidatePath("/kid");
revalidatePath("/parent"); revalidatePath("/parent");
return { score, passed, correct, total: qs.length, detail }; revalidatePath("/points");
return { score, passed, correct, total: qs.length, detail, pointsEarned };
} }
// 保存跟读录音(已直传 OSS,这里登记) // 保存跟读录音(已直传 OSS,这里登记)
+2 -1
View File
@@ -5,7 +5,7 @@ import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db"; import { db } from "@/db";
import { wordProgress } from "@/db/schema"; import { wordProgress } from "@/db/schema";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { TOTAL_WORD_DAYS } from "@/lib/words"; import { totalWordDays } from "@/lib/words";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default async function WordsHome() { export default async function WordsHome() {
@@ -18,6 +18,7 @@ export default async function WordsHome() {
.from(wordProgress) .from(wordProgress)
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true))); .where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
const passedSet = new Set(passed.map((p) => p.day)); const passedSet = new Set(passed.map((p) => p.day));
const TOTAL_WORD_DAYS = await totalWordDays();
return ( return (
<main className="relative mx-auto min-h-dvh max-w-2xl space-y-5 p-4 pb-10"> <main className="relative mx-auto min-h-dvh max-w-2xl space-y-5 p-4 pb-10">
+5 -2
View File
@@ -94,7 +94,9 @@ export function WordDayClient({
const [example, setExample] = useState<Record<string, string>>({}); const [example, setExample] = useState<Record<string, string>>({});
const [testOpen, setTestOpen] = useState(false); const [testOpen, setTestOpen] = useState(false);
const [answers, setAnswers] = useState<(number | null)[]>(test.map(() => null)); const [answers, setAnswers] = useState<(number | null)[]>(test.map(() => null));
const [result, setResult] = useState<{ score: number; passed: boolean } | null>(passed ? { score: score ?? 0, passed } : null); const [result, setResult] = useState<{ score: number; passed: boolean; pointsEarned: number } | null>(
passed ? { score: score ?? 0, passed, pointsEarned: 0 } : null,
);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const toggleLearn = (word: string) => { const toggleLearn = (word: string) => {
@@ -126,7 +128,7 @@ export function WordDayClient({
const submit = () => { const submit = () => {
startTransition(async () => { startTransition(async () => {
const r = await submitWordTest(day, answers.map((a) => a as number)); const r = await submitWordTest(day, answers.map((a) => a as number));
if (!("error" in r)) setResult({ score: r.score, passed: r.passed }); if (!("error" in r)) setResult({ score: r.score, passed: r.passed, pointsEarned: r.pointsEarned });
}); });
}; };
@@ -196,6 +198,7 @@ export function WordDayClient({
{result && ( {result && (
<span className={cn("text-sm font-bold", result.passed ? "text-emerald-600" : "text-rose-600")}> <span className={cn("text-sm font-bold", result.passed ? "text-emerald-600" : "text-rose-600")}>
{result.score} 分 {result.passed ? "· 已点亮" : ""} {result.score} 分 {result.passed ? "· 已点亮" : ""}
{result.passed && result.pointsEarned > 0 ? ` · 🪙 +${result.pointsEarned} 积分` : ""}
</span> </span>
)} )}
</div> </div>
+4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs"; import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth"; import { getSessionUser } from "@/lib/auth";
import { listWrong } from "@/lib/wrongbook"; import { listWrong } from "@/lib/wrongbook";
import { photoViewUrl } from "@/lib/oss";
import type { QuizQ } from "@/lib/quiz-parse"; import type { QuizQ } from "@/lib/quiz-parse";
import { WrongRow } from "./wrong-row"; import { WrongRow } from "./wrong-row";
@@ -39,6 +40,8 @@ export default async function WrongBookPage() {
} catch { } catch {
return null; return null;
} }
// xuchao: 解答题展示当时的作答照片(OSS 签名地址;开发环境为本地回退)
const photoUrl = q.type === "solve" && q.solveSnap?.ossKey ? photoViewUrl(q.solveSnap.ossKey) : "";
return ( return (
<WrongRow <WrongRow
key={r.id} key={r.id}
@@ -48,6 +51,7 @@ export default async function WrongBookPage() {
userAnswer={r.userAnswer} userAnswer={r.userAnswer}
correctAnswer={r.correctAnswer} correctAnswer={r.correctAnswer}
mastered={r.mastered} mastered={r.mastered}
photoUrl={photoUrl}
/> />
); );
})} })}
+42 -4
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useTransition } from "react"; import { useTransition } from "react";
import { CheckCircle2, RotateCcw } from "lucide-react"; import { Check, CheckCircle2, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse"; import type { QuizQ } from "@/lib/quiz-parse";
import { toggleMastered } from "./actions"; import { toggleMastered } from "./actions";
// xuchao: markAction 供家长版错题页注入专属标记动作,缺省走孩子端 toggleMastered
export function WrongRow({ export function WrongRow({
id, id,
sourceTitle, sourceTitle,
@@ -14,6 +15,8 @@ export function WrongRow({
userAnswer, userAnswer,
correctAnswer, correctAnswer,
mastered, mastered,
photoUrl = "",
markAction,
}: { }: {
id: number; id: number;
sourceTitle: string | null; sourceTitle: string | null;
@@ -21,8 +24,11 @@ export function WrongRow({
userAnswer: string | null; userAnswer: string | null;
correctAnswer: string | null; correctAnswer: string | null;
mastered: boolean; mastered: boolean;
photoUrl?: string;
markAction?: (id: number, mastered: boolean) => Promise<void>;
}) { }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const isSolve = q.type === "solve";
return ( return (
<div <div
className={cn( className={cn(
@@ -31,14 +37,46 @@ export function WrongRow({
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="font-medium text-slate-900">{q.stem}</p> <p className="font-medium text-slate-900">
{q.stem}
{isSolve && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">解答题</span>}
</p>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground"> <span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">
{sourceTitle ?? "检测"} {sourceTitle ?? "检测"}
</span> </span>
</div> </div>
{isSolve && photoUrl && (
// xuchao: 当时的作答照片(签名地址 5 分钟有效,页面刷新即重签)
<div className="mt-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl} alt="作答照片" className="max-h-56 rounded-lg border border-slate-200 object-contain" />
</div>
)}
{isSolve && (q.solveSnap?.hits?.length ?? 0) > 0 && (
<ul className="mt-2 space-y-1">
{q.solveSnap!.hits.map((h, i) => (
<li key={i} className="flex items-start gap-1.5 text-xs">
{h.hit ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
) : (
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
)}
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
({h.score}分){h.point}
</span>
</li>
))}
</ul>
)}
<div className="mt-2 space-y-1 text-sm"> <div className="mt-2 space-y-1 text-sm">
<p className="text-rose-600">你的答案:{userAnswer ?? "未作答"}</p> <p className="text-rose-600">你的答案:{userAnswer ?? "未作答"}</p>
<p className="text-emerald-700">正确答案:{correctAnswer ?? ""}</p> <p className={cn("text-emerald-700", isSolve && "whitespace-pre-line")}>
{isSolve ? "参考解答:" : "正确答案:"}
{correctAnswer ?? ""}
</p>
</div> </div>
<p className="mt-2 rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-800">💡 {q.explain}</p> <p className="mt-2 rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-800">💡 {q.explain}</p>
<div className="mt-3"> <div className="mt-3">
@@ -46,7 +84,7 @@ export function WrongRow({
variant={mastered ? "outline" : "secondary"} variant={mastered ? "outline" : "secondary"}
size="sm" size="sm"
disabled={pending} disabled={pending}
onClick={() => startTransition(() => toggleMastered(id, !mastered))} onClick={() => startTransition(() => (markAction ?? toggleMastered)(id, !mastered))}
> >
{mastered ? ( {mastered ? (
<> <>
+336
View File
@@ -0,0 +1,336 @@
{
"papers": [
{
"id": "g7s1-m00-entrance",
"grade": "g7",
"semester": "s1",
"month": 0,
"examType": "entrance",
"title": "初一入学分班考数学模拟卷(含解答题)",
"region": "通用",
"year": 2026,
"fullScore": 100,
"sourceUrl": "",
"summary": "按真实分班考卷面结构组卷:填空 8 题×3 分、选择 6 题×3 分、计算解答 4 题×5 分、应用解答 4 题共 38 分,满分 100 分。题目取自本地教研素材的经典分班考题型(鸡兔同笼、工程、分数百分数、行程等),经双模型独立验算;因抓取受限未用原卷页面转录。解答题需纸上手写后拍照,由 AI 按评分点逐步判分。",
"questions": [
{
"type": "fill",
"stem": "24 和 36 的最大公因数是____。",
"answers": [
"12"
],
"explain": "24=2×2×2×3,36=2×2×3×3,公有质因数相乘:2×2×3=12。",
"points": 3
},
{
"type": "fill",
"stem": "8 和 12 的最小公倍数是____。",
"answers": [
"24"
],
"explain": "8=2×2×2,12=2×2×3,最小公倍数=2×2×2×3=24。",
"points": 3
},
{
"type": "fill",
"stem": "一根绳子长 20 米,用去它的 3/5,还剩____米。",
"answers": [
"8"
],
"explain": "用去 20×3/5=12 米,还剩 20-12=8 米(或 20×(1-3/5)=20×2/5=8 米)。",
"points": 3
},
{
"type": "fill",
"stem": "一个两位数,个位上的数字是十位上数字的 3 倍,且两个数位上的数字之和是 8,这个两位数是____。",
"answers": [
"26"
],
"explain": "设十位为 a,则个位为 3a,a+3a=8,a=2,个位是 6,这个数是 26。",
"points": 3
},
{
"type": "fill",
"stem": "一个数除以 5,商是 8,余数是 2,这个数是____。",
"answers": [
"42"
],
"explain": "被除数=商×除数+余数=8×5+2=42。",
"points": 3
},
{
"type": "fill",
"stem": "按规律填数:1,1,2,3,5,8,____。",
"answers": [
"13"
],
"explain": "从第三项起,每个数等于前两个数的和:5+8=13。",
"points": 3
},
{
"type": "fill",
"stem": "把 120 按 2:3 分成两份,较大的一份是____。",
"answers": [
"72"
],
"explain": "总份数 2+3=5,较大一份占 3/5:120×3/5=72。",
"points": 3
},
{
"type": "fill",
"stem": "一个圆的周长是 31.4 厘米,它的直径是____厘米。(π 取 3.14)",
"answers": [
"10"
],
"explain": "直径=周长÷π=31.4÷3.14=10 厘米。",
"points": 3
},
{
"type": "choice",
"stem": "1 元和 5 元的纸币共 12 张,合计 28 元,其中 5 元纸币有( )张。",
"options": [
"A. 3",
"B. 4",
"C. 5",
"D. 6"
],
"answer": 1,
"explain": "设 5 元 x 张,则 1 元 (12-x) 张:5x+(12-x)=28,4x=16,x=4。",
"points": 3
},
{
"type": "choice",
"stem": "一件衣服原价 200 元,打七五折出售,现价是( )元。",
"options": [
"A. 50",
"B. 150",
"C. 160",
"D. 175"
],
"answer": 1,
"explain": "七五折即按原价的 75% 出售:200×75%=150 元。",
"points": 3
},
{
"type": "choice",
"stem": "路程一定,行驶的速度和所需的时间( )。",
"options": [
"A. 成正比例",
"B. 成反比例",
"C. 不成比例"
],
"answer": 1,
"explain": "速度×时间=路程(一定),乘积一定,成反比例。",
"points": 3
},
{
"type": "choice",
"stem": "下面各数中,是质数的是( )。",
"options": [
"A. 51",
"B. 91",
"C. 97",
"D. 117"
],
"answer": 2,
"explain": "51=3×17,91=7×13,117=3×39=9×13,都不是质数;97 只有 1 和它本身两个因数,是质数。",
"points": 3
},
{
"type": "choice",
"stem": "一个圆的半径扩大到原来的 2 倍,它的面积扩大到原来的( )倍。",
"options": [
"A. 2",
"B. 4",
"C. 6",
"D. 8"
],
"answer": 1,
"explain": "面积=πr²,半径扩大 2 倍,面积扩大 2²=4 倍。",
"points": 3
},
{
"type": "choice",
"stem": "爸爸今年 40 岁,儿子今年 12 岁,( )年后爸爸的年龄是儿子的 3 倍。",
"options": [
"A. 1",
"B. 2",
"C. 3",
"D. 4"
],
"answer": 1,
"explain": "设 x 年后:40+x=3×(12+x),40+x=36+3x,2x=4,x=2。",
"points": 3
},
{
"type": "solve",
"stem": "用简便方法计算:25×32×125",
"points": 5,
"reference": "25×32×125\n=25×(4×8)×125(把 32 拆成 4×8)\n=(25×4)×(8×125)(乘法交换律、结合律)\n=100×1000\n=100000",
"rubric": [
{
"point": "把 32 拆成 4×8,并合理运用乘法交换律/结合律重组算式",
"score": 2
},
{
"point": "正确算出 25×4=100 与 8×125=1000",
"score": 2
},
{
"point": "得出正确结果 100000",
"score": 1
}
],
"explain": "看到 25 想 4,看到 125 想 8:把 32 拆成 4×8,凑成 25×4 与 8×125 两组整百整千。"
},
{
"type": "solve",
"stem": "用简便方法计算:3/4×5/6+5/6×1/4",
"points": 5,
"reference": "3/4×5/6+5/6×1/4\n=5/6×(3/4+1/4)(乘法分配律逆用)\n=5/6×1\n=5/6",
"rubric": [
{
"point": "发现公因数 5/6,逆用乘法分配律写成 5/6×(3/4+1/4)",
"score": 2
},
{
"point": "正确算出 3/4+1/4=1",
"score": 1
},
{
"point": "得出正确结果 5/6",
"score": 2
}
],
"explain": "两个乘积都有因数 5/6,逆用分配律:5/6×(3/4+1/4)=5/6×1=5/6。"
},
{
"type": "solve",
"stem": "解方程:3x+5=2x+9",
"points": 5,
"reference": "3x+5=2x+9\n3x-2x=9-5(两边同时减 2x、减 5)\nx=4",
"rubric": [
{
"point": "正确移项(含未知数的项移到一边,常数项移到另一边,变号正确)",
"score": 2
},
{
"point": "得出 x=4",
"score": 2
},
{
"point": "写出结论或代入检验",
"score": 1
}
],
"explain": "移项:3x-2x=9-5,即 x=4。检验:左边=3×4+5=17,右边=2×4+9=17,相等。"
},
{
"type": "solve",
"stem": "计算:4.8÷(1.2×2)+3.5",
"points": 5,
"reference": "4.8÷(1.2×2)+3.5\n=4.8÷2.4+3.5(先算括号里的)\n=2+3.5\n=5.5",
"rubric": [
{
"point": "按运算顺序先算括号内 1.2×2=2.4",
"score": 1
},
{
"point": "正确算出 4.8÷2.4=2",
"score": 2
},
{
"point": "得出正确结果 5.5",
"score": 2
}
],
"explain": "运算顺序:先括号,再乘除,最后加减。4.8÷2.4=2,2+3.5=5.5。"
},
{
"type": "solve",
"stem": "笼子里有鸡和兔共 35 只,数一数共有 94 条腿。鸡和兔各有多少只?",
"points": 8,
"reference": "假设全是鸡:35×2=70(条腿)\n比实际少:94-70=24(条腿)\n每把一只鸡换成兔多 2 条腿:24÷2=12(只)\n兔有 12 只,鸡有 35-12=23(只)\n答:鸡有 23 只,兔有 12 只。",
"rubric": [
{
"point": "方法正确完整(假设法、方程法等任一种,数量关系列对)",
"score": 3
},
{
"point": "求出兔 12 只",
"score": 3
},
{
"point": "求出鸡 23 只并写出答句",
"score": 2
}
],
"explain": "鸡兔同笼经典题:假设全是鸡得 70 条腿,差的 24 条腿由每只换兔补 2 条,兔=24÷2=12 只。也可设兔 x 只:4x+2(35-x)=94。"
},
{
"type": "solve",
"stem": "一件工作,甲单独做 20 天完成,乙单独做 30 天完成。两人合作,多少天可以完成?",
"points": 10,
"reference": "把工作总量看作 1。\n甲每天完成 1/20,乙每天完成 1/30。\n两人每天共完成:1/20+1/30=3/60+2/60=5/60=1/12\n完成天数:1÷1/12=12(天)\n答:两人合作 12 天可以完成。",
"rubric": [
{
"point": "设工作总量为 1,正确表示甲、乙工作效率 1/20 与 1/30",
"score": 3
},
{
"point": "正确求出效率和 1/20+1/30=1/12",
"score": 4
},
{
"point": "得出 12 天并写出答句",
"score": 3
}
],
"explain": "工程问题:工作总量÷效率和=合作时间。1÷(1/20+1/30)=1÷1/12=12 天。"
},
{
"type": "solve",
"stem": "商店运来一批水果,上午卖出总数的 1/3,下午卖出总数的 1/4,还剩 50 千克。这批水果共有多少千克?",
"points": 10,
"reference": "把水果总数看作单位“1”。\n50 千克对应的分率:1-1/3-1/4=12/12-4/12-3/12=5/12\n总数:50÷5/12=50×12/5=120(千克)\n答:这批水果共有 120 千克。",
"rubric": [
{
"point": "找准单位“1”,正确求出 50 千克对应的分率 1-1/3-1/4=5/12",
"score": 4
},
{
"point": "正确列式 50÷5/12 并计算",
"score": 3
},
{
"point": "得出 120 千克并写出答句",
"score": 3
}
],
"explain": "分数应用题:已知剩余量求单位“1”,用除法。剩余对应分率 5/12,总数=50÷5/12=120 千克。"
},
{
"type": "solve",
"stem": "甲、乙两地相距 480 千米,一辆客车从甲地出发,每小时行 60 千米;同时一辆货车从乙地出发,每小时行 40 千米,两车相向而行。经过几小时两车相遇?相遇时客车行了多少千米?",
"points": 10,
"reference": "速度和:60+40=100(千米/时)\n相遇时间:480÷100=4.8(小时)\n客车行驶路程:60×4.8=288(千米)\n答:经过 4.8 小时两车相遇,相遇时客车行了 288 千米。",
"rubric": [
{
"point": "正确求出速度和 60+40=100 千米/时",
"score": 2
},
{
"point": "得出相遇时间 480÷100=4.8 小时",
"score": 4
},
{
"point": "得出客车行了 288 千米并写出答句",
"score": 4
}
],
"explain": "相遇问题:相遇时间=总路程÷速度和=480÷100=4.8 小时;客车路程=60×4.8=288 千米。"
}
]
}
]
}
+94
View File
@@ -78,6 +78,21 @@ export const activationCodes = mysqlTable("activation_code", {
usedAt: datetime("used_at"), usedAt: datetime("used_at"),
}); });
// ===== 家庭邀请码(家长生成,其他家长凭码注册加入;一次性,15 分钟)=====
export const familyInvites = mysqlTable("family_invite", {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
familyId: bigint("family_id", { mode: "number" })
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
codeHash: varchar("code_hash", { length: 64 }).notNull(),
createdBy: bigint("created_by", { mode: "number" })
.notNull()
.references(() => users.id),
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: datetime("expires_at").notNull(),
usedAt: datetime("used_at"),
});
// ===== 周模板(每个孩子 × 每周几 × 每条任务)===== // ===== 周模板(每个孩子 × 每周几 × 每条任务)=====
export const taskTemplates = mysqlTable( export const taskTemplates = mysqlTable(
"task_template", "task_template",
@@ -91,6 +106,8 @@ export const taskTemplates = mysqlTable(
title: varchar("title", { length: 100 }).notNull(), title: varchar("title", { length: 100 }).notNull(),
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"), subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
requiresPhoto: boolean("requires_photo").notNull().default(false), requiresPhoto: boolean("requires_photo").notNull().default(false),
// xuchao: 该任务完成可得积分(家长按任务难度单独设置)
points: int("points").notNull().default(10),
// 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周 // 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周
mode: varchar("mode", { length: 10 }).notNull().default("weekly"), mode: varchar("mode", { length: 10 }).notNull().default("weekly"),
dateStart: date("date_start", { mode: "string" }), dateStart: date("date_start", { mode: "string" }),
@@ -114,6 +131,8 @@ export const taskInstances = mysqlTable(
title: varchar("title", { length: 100 }).notNull(), title: varchar("title", { length: 100 }).notNull(),
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"), subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
requiresPhoto: boolean("requires_photo").notNull().default(false), requiresPhoto: boolean("requires_photo").notNull().default(false),
// xuchao: 该任务完成可得积分(生成快照时从模板复制)
points: int("points").notNull().default(10),
status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"), status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"),
// 家长补卡标记,永久保留 // 家长补卡标记,永久保留
isMakeup: boolean("is_makeup").notNull().default(false), isMakeup: boolean("is_makeup").notNull().default(false),
@@ -181,6 +200,19 @@ export const wrongBook = mysqlTable(
(t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)], (t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)],
); );
// ===== 单词词库(内容存库,家长端可按天编辑;初始由 words.json 灌入)=====
export const wordEntry = mysqlTable(
"word_entry",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
word: varchar("word", { length: 60 }).notNull(),
meaning: varchar("meaning", { length: 300 }).notNull().default(""),
day: int("day").notNull(), // 第几天学习
seq: int("seq").notNull(), // 全局顺序(组卷干扰项取材用)
},
(t) => [index("idx_word_entry_day").on(t.day)],
);
// ===== 单词每日进度(学会的词+检测成绩)===== // ===== 单词每日进度(学会的词+检测成绩)=====
export const wordProgress = mysqlTable( export const wordProgress = mysqlTable(
"word_progress", "word_progress",
@@ -346,3 +378,65 @@ export const examRecords = mysqlTable(
}, },
(t) => [uniqueIndex("uq_exam_child_paper").on(t.childUserId, t.paperId)], (t) => [uniqueIndex("uq_exam_child_paper").on(t.childUserId, t.paperId)],
); );
// ===== AI 判分审计日志(限频依据 + 家长端可查 + 成绩溯源)=====
export const aiGradeLog = mysqlTable(
"ai_grade_log",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
childUserId: bigint("child_user_id", { mode: "number" })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
paperId: varchar("paper_id", { length: 191 }).notNull(),
questionIndex: int("question_index").notNull(),
ossKey: varchar("oss_key", { length: 300 }).notNull(),
// xuchao: ok=正常判分 blank=空白 blurry=模糊 printed=印刷体拒判 unrelated=无关内容 error=调用失败
status: varchar("status", { length: 20 }).notNull(),
earned: int("earned").notNull().default(0),
points: int("points").notNull().default(0),
verdict: text("verdict"), // JSON:逐评分点命中与点评
mock: boolean("mock").notNull().default(false), // 演示判分标记(无 API key)
model: varchar("model", { length: 60 }),
ms: int("ms"), // 判分耗时
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(t) => [
index("idx_ai_grade_child_date").on(t.childUserId, t.createdAt),
index("idx_ai_grade_paper_q").on(t.childUserId, t.paperId, t.questionIndex),
],
);
// ===== 积分规则(测试类事件分值,家长可调;打卡分值随任务本身设置)=====
export const pointRule = mysqlTable(
"point_rule",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
// word_test/math_lesson/grammar_point/grammar_weekly/math_weekly/exam_pass
eventType: varchar("event_type", { length: 30 }).notNull(),
points: int("points").notNull().default(0),
updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(t) => [uniqueIndex("uq_point_rule_event").on(t.eventType)],
);
// ===== 积分流水(只增不减;唯一键即防刷:同一事件实例只发一次)=====
export const pointLog = mysqlTable(
"point_log",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
childUserId: bigint("child_user_id", { mode: "number" })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// checkin/word_test/math_lesson/grammar_point/grammar_weekly/math_weekly/exam_pass
eventType: varchar("event_type", { length: 30 }).notNull(),
// 事件实例标识,如 inst-123 / day-5 / point-calc-01 / week-2026-W33 / paper-xxx
refKey: varchar("ref_key", { length: 100 }).notNull(),
points: int("points").notNull(),
remark: varchar("remark", { length: 200 }).notNull().default(""),
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(t) => [
uniqueIndex("uq_point_log_dedup").on(t.childUserId, t.eventType, t.refKey),
index("idx_point_log_child").on(t.childUserId),
],
);
+174
View File
@@ -0,0 +1,174 @@
// xuchao: 解答题 AI 判分引擎——阿里云百炼 Qwen-VL,按评分点(rubric)部分给分
// 无 DASHSCOPE_API_KEY 时进入演示判分(mock),供本地开发;生产必须配真 key
export type SolveStatus = "ok" | "blank" | "blurry" | "printed" | "unrelated" | "error";
export type RubricItem = { point: string; score: number };
export type RubricHit = { point: string; score: number; hit: boolean; reason: string };
export type GradeVerdict = {
status: SolveStatus;
hits: RubricHit[];
earned: number; // 由 hits 重算,不信任模型自报
comment: string;
mock: boolean;
model: string;
ms: number;
};
const ENDPOINT = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
const MODEL = process.env.AI_GRADE_MODEL || "qwen-vl-max";
const TIMEOUT_MS = 60_000;
// xuchao: 判分提示词——题干+参考解答+评分点+学生手写照片,只允许离散给分
function buildPrompt(stem: string, points: number, reference: string, rubric: RubricItem[], hasFigure: boolean) {
const rubricText = rubric.map((r, i) => `${i + 1}. ${r.point}(${r.score} 分)`).join("\n");
return `你是一位经验丰富的数学阅卷老师,正在批改学生手写的解答题照片。
【题目】${stem}${hasFigure ? "\n(题目配有示意图,以题意为准)" : ""}
【本题满分】${points} 分
【参考解答】
${reference}
【评分点】
${rubricText}
请按以下步骤评判:
1. 判断照片状态,status 只能是以下之一:
- ok:正常的手写解答
- blank:空白或没有作答内容
- blurry:字迹模糊无法辨认
- printed:印刷体内容(非手写,拒绝批改)
- unrelated:照片内容与本题无关
2. 仅当 status=ok 时逐条评判评分点。学生方法可以与参考解答不同,只要该评分点的要求确实达成就 hit=true 并给该评分点满分,否则 hit=false 给 0 分。不允许给某个评分点部分分数。
3. earned = 所有 hit=true 评分点分数之和,不得超过满分 ${points}。
4. comment 给一句面向小学生的总体点评(指出亮点或主要问题)。
只输出一个 JSON 对象,不要输出任何其他文字:
{"status":"ok","hits":[{"hit":true,"reason":"简短理由"}],"earned":数字,"comment":"点评"}
hits 数组元素个数必须等于评分点个数(${rubric.length}),顺序与评分点一致。status 非 ok 时 hits 为空数组、earned 为 0。`;
}
// xuchao: 演示判分(无 key):确定性模拟——最后一个评分点不得分,便于本地验证部分给分与错题链路
function mockVerdict(stem: string, points: number, rubric: RubricItem[]): GradeVerdict {
const hits: RubricHit[] = rubric.map((r, i) => ({
point: r.point,
score: r.score,
hit: i < rubric.length - 1 || rubric.length === 1,
reason: i < rubric.length - 1 || rubric.length === 1 ? "演示判分:达成" : "演示判分:未达成(模拟扣分)",
}));
const earned = hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0);
return {
status: "ok",
hits,
earned: rubric.length === 1 ? points : Math.min(earned, points),
comment: "这是演示判分(未配置百炼 API Key),结果仅用于流程测试。",
mock: true,
model: "mock",
ms: 0,
};
}
// xuchao: 从模型回复里稳健提取 JSON(容忍代码块包裹与多余文字)
function extractJson(text: string): Record<string, unknown> | null {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const body = fenced ? fenced[1] : text;
const start = body.indexOf("{");
const end = body.lastIndexOf("}");
if (start < 0 || end <= start) return null;
try {
return JSON.parse(body.slice(start, end + 1));
} catch {
return null;
}
}
const VALID_STATUS = new Set(["ok", "blank", "blurry", "printed", "unrelated"]);
// xuchao: 校验并归一化模型判定;earned 一律按 hits 重算
function normalizeVerdict(raw: Record<string, unknown>, rubric: RubricItem[], model: string, ms: number): GradeVerdict | null {
const status = typeof raw.status === "string" && VALID_STATUS.has(raw.status) ? (raw.status as SolveStatus) : null;
if (!status) return null;
let hits: RubricHit[] = [];
if (status === "ok") {
const arr = Array.isArray(raw.hits) ? raw.hits : null;
if (!arr || arr.length !== rubric.length) return null;
hits = rubric.map((r, i) => {
const h = (arr[i] ?? {}) as Record<string, unknown>;
return { point: r.point, score: r.score, hit: h.hit === true, reason: typeof h.reason === "string" ? h.reason : "" };
});
}
const earned = Math.min(
points_cap(rubric),
hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0),
);
return {
status,
hits,
earned,
comment: typeof raw.comment === "string" ? raw.comment : "",
mock: false,
model,
ms,
};
}
const points_cap = (rubric: RubricItem[]) => rubric.reduce((s, r) => s + r.score, 0);
// xuchao: 判分主入口;photoUrl 必须是公网可达地址(OSS 签名 URL)
export async function gradeSolvePhoto(opts: {
stem: string;
points: number;
rubric: RubricItem[];
reference: string;
photoUrl: string;
figureUrl?: string;
}): Promise<GradeVerdict> {
const { stem, points, rubric, reference, photoUrl, figureUrl } = opts;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) return mockVerdict(stem, points, rubric);
const content: Record<string, unknown>[] = [
{ type: "text", text: buildPrompt(stem, points, reference, rubric, !!figureUrl) },
{ type: "image_url", image_url: { url: photoUrl } },
];
if (figureUrl) content.push({ type: "image_url", image_url: { url: figureUrl } });
let lastErr = "";
for (let attempt = 0; attempt < 2; attempt++) {
const t0 = Date.now();
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const resp = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content }],
temperature: 0.1,
}),
signal: ctrl.signal,
});
clearTimeout(timer);
if (!resp.ok) {
lastErr = `HTTP ${resp.status}`;
continue;
}
const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content ?? "";
const raw = extractJson(text);
if (!raw) {
lastErr = "JSON 解析失败";
continue;
}
const v = normalizeVerdict(raw, rubric, MODEL, Date.now() - t0);
if (!v) {
lastErr = "判定结构不符";
continue;
}
return v;
} catch (e) {
lastErr = e instanceof Error ? e.message : String(e);
}
}
// xuchao: 两次均失败——返回 error 状态,前端提示重拍,不写入得分
return { status: "error", hits: [], earned: 0, comment: `判分失败(${lastErr}),请重试`, mock: false, model: MODEL, ms: 0 };
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { answersMatch, normalizeAnswer, solveAnswerHit } from "./answer-match";
describe("normalizeAnswer", () => {
it("分数转小数", () => {
expect(normalizeAnswer("1/2")).toBe("0.5");
expect(normalizeAnswer("2/4")).toBe("0.5");
});
it("百分数转小数", () => {
expect(normalizeAnswer("50%")).toBe("0.5");
});
it("小数去尾零", () => {
expect(normalizeAnswer("0.50")).toBe("0.5");
});
it("整数原样", () => {
expect(normalizeAnswer("10")).toBe("10");
});
it("文本去空白/全角/大小写", () => {
expect(normalizeAnswer("ABC")).toBe("abc");
expect(normalizeAnswer("10 个")).toBe("10个");
});
});
describe("answersMatch", () => {
it("等值命中:1/2 ≡ 0.5", () => {
expect(answersMatch(["0.5", "1/2"], "1/2")).toBe(true);
expect(answersMatch(["0.5"], "0.50")).toBe(true);
});
it("百分数命中", () => {
expect(answersMatch(["0.5"], "50%")).toBe(true);
});
it("文本大小写/全角不敏感", () => {
expect(answersMatch(["across"], "ACROSS")).toBe(true);
});
it("单位严格:10个 ≠ 10", () => {
expect(answersMatch(["10个"], "10")).toBe(false);
expect(answersMatch(["10", "10个"], "10个")).toBe(true);
});
it("空白答案不判对", () => {
expect(answersMatch(["0.5"], " ")).toBe(false);
});
it("空期望数组不判对", () => {
expect(answersMatch([], "0.5")).toBe(false);
});
});
describe("solveAnswerHit", () => {
const q = { answers: ["2", "x=2"] };
it("命中最终答案", () => {
expect(solveAnswerHit({ answers: ["2"] }, "2")).toBe(true);
expect(solveAnswerHit(q, " x = 2 ")).toBe(true);
});
it("未命中", () => {
expect(solveAnswerHit(q, "3")).toBe(false);
expect(solveAnswerHit({ answers: [] }, "2")).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
// xuchao: 答案等价判定——数值归一(分数/小数/百分数互转),文本归一(去空白/全角/小写)
import type { QuizQ } from "@/lib/quiz-parse";
const toHalf = (s: string) =>
s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/ /g, " ");
function stripWs(s: string): string {
return toHalf(s).replace(/\s+/g, "").toLowerCase();
}
export function normalizeAnswer(s: string): string {
const t = stripWs(s);
const frac = t.match(/^(-?\d+)\/(-?\d+)$/);
if (frac && Number(frac[2]) !== 0) {
const v = Number(frac[1]) / Number(frac[2]);
if (Number.isFinite(v)) return String(v);
}
const pct = t.match(/^(-?\d+(?:\.\d+)?)%$/);
if (pct) return String(Number(pct[1]) / 100);
if (/^-?\d+\.\d+$/.test(t)) return String(Number(t));
return t;
}
// xuchao: 任一期望答案等值命中即判对;空白答案一律不判对
export function answersMatch(expected: string[], given: string): boolean {
const g = normalizeAnswer(given);
if (!g) return false;
return expected.some((x) => normalizeAnswer(x) === g);
}
// xuchao: 应用题 answer 模式判定——最终答案等值即判对
export function solveAnswerHit(q: Pick<QuizQ, "answers">, given: string): boolean {
return answersMatch(q.answers ?? [], given);
}
+33 -5
View File
@@ -1,12 +1,14 @@
// xuchao: 考试板块——试卷查询与作答记录 // xuchao: 考试板块——试卷查询与作答记录
import { and, eq, sql } from "drizzle-orm"; import { and, desc, eq, gte, sql } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { examPapers, examRecords, taskInstances } from "@/db/schema"; import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import type { QuizQ } from "@/lib/quiz-parse"; import type { QuizQ } from "@/lib/quiz-parse";
import { awardRulePoints } from "@/lib/points";
// xuchao: 试卷题型标签(展示用) // xuchao: 试卷题型标签(展示用)
export const EXAM_TYPE_LABEL: Record<string, string> = { export const EXAM_TYPE_LABEL: Record<string, string> = {
entrance: "入学考",
monthly: "月考", monthly: "月考",
unit: "单元卷", unit: "单元卷",
midterm: "期中", midterm: "期中",
@@ -85,7 +87,7 @@ export async function recordsByPaper(childId: number): Promise<Map<string, { sco
return new Map(rows.map((r) => [r.paperId, { score: r.score ?? 0, passed: r.passed }])); return new Map(rows.map((r) => [r.paperId, { score: r.score ?? 0, passed: r.passed }]));
} }
// xuchao: 记录作答;达标联动当天数学打卡(允许重考覆盖成绩) // xuchao: 记录作答;达标联动当天数学打卡并发积分(首次达标才发);返回实发积分
export async function recordExam( export async function recordExam(
childId: number, childId: number,
paperId: string, paperId: string,
@@ -95,7 +97,7 @@ export async function recordExam(
total: number, total: number,
passed: boolean, passed: boolean,
answers: unknown[], answers: unknown[],
) { ): Promise<number> {
await db await db
.insert(examRecords) .insert(examRecords)
.values({ .values({
@@ -111,7 +113,8 @@ export async function recordExam(
set: { score, correct, total, passed, answers: JSON.stringify(answers), takenAt: sql`CURRENT_TIMESTAMP` }, set: { score, correct, total, passed, answers: JSON.stringify(answers), takenAt: sql`CURRENT_TIMESTAMP` },
}); });
if (!passed) return; if (!passed) return 0;
const pointsEarned = await awardRulePoints(childId, "exam_pass", `paper-${paperId}`, paperTitle);
const today = todayStr(); const today = todayStr();
const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`; const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`;
const [existing] = await db const [existing] = await db
@@ -138,4 +141,29 @@ export async function recordExam(
sortOrder: 96, sortOrder: 96,
}); });
} }
return pointsEarned;
}
// xuchao: 当日 AI 判分次数(限频用,含全部状态)
export async function gradeCountToday(childId: number): Promise<number> {
const midnight = new Date(`${todayStr()}T00:00:00`);
const [r] = await db
.select({ n: sql<number>`COUNT(*)` })
.from(aiGradeLog)
.where(and(eq(aiGradeLog.childUserId, childId), gte(aiGradeLog.createdAt, midnight)));
return Number(r?.n ?? 0);
}
// xuchao: 某孩子某卷每道解答题最近一次判分日志(交卷计分用)
export async function latestSolveLogs(childId: number, paperId: string) {
const rows = await db
.select()
.from(aiGradeLog)
.where(and(eq(aiGradeLog.childUserId, childId), eq(aiGradeLog.paperId, paperId)))
.orderBy(desc(aiGradeLog.id));
const byQ = new Map<number, (typeof rows)[number]>();
for (const r of rows) {
if (!byQ.has(r.questionIndex)) byQ.set(r.questionIndex, r);
}
return byQ;
} }
+18 -12
View File
@@ -3,6 +3,7 @@ import { db } from "@/db";
import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema"; import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse"; import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
import { awardRulePoints } from "@/lib/points";
export { extractQuiz, type QuizQ }; export { extractQuiz, type QuizQ };
@@ -63,19 +64,22 @@ export async function passedCount(childId: number): Promise<number> {
const norm = (s: unknown) => String(s ?? "").trim().toLowerCase(); const norm = (s: unknown) => String(s ?? "").trim().toLowerCase();
// 服务端判分:五种题型 choice/judge/fill/order/match // 服务端判分:六种题型 choice/judge/fill/order/match/solve(solve 不在此处判,走 AI)
export type AnswerValue = number | string | string[]; export type AnswerValue = number | string | string[];
// xuchao: 单题客观判分(solve 返回 false,由 AI 判分链路单独处理)
export function gradeOne(q: QuizQ, a: AnswerValue | null | undefined): boolean {
if (q.type === "fill") return (q.answers ?? []).some((x) => norm(x) === norm(a));
if (q.type === "order") return Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
if (q.type === "match") return Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
if (q.type === "solve") return false;
return Number(a) === (q.answer as number);
}
export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) { export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) {
let correct = 0; let correct = 0;
const detail = questions.map((q, i) => { const detail = questions.map((q, i) => {
const a = answers[i]; const ok = gradeOne(q, answers[i]);
let ok = false;
if (q.type === "fill") ok = (q.answers ?? []).some((x) => norm(x) === norm(a));
else if (q.type === "order")
ok = Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
else if (q.type === "match")
ok = Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
else ok = Number(a) === (q.answer as number);
if (ok) correct++; if (ok) correct++;
return ok; return ok;
}); });
@@ -83,20 +87,21 @@ export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) {
return { score, passed: score >= 80, correct, total: questions.length, detail }; return { score, passed: score >= 80, correct, total: questions.length, detail };
} }
// 记录结果;通过则同步完成当天英语打卡 // 记录结果;通过则同步完成当天英语打卡;返回首次达标所发积分
export async function completePoint( export async function completePoint(
childId: number, childId: number,
pointId: string, pointId: string,
title: string, title: string,
score: number, score: number,
passed: boolean, passed: boolean,
) { ): Promise<number> {
await db await db
.insert(grammarProgress) .insert(grammarProgress)
.values({ childUserId: childId, pointId, score, passed }) .values({ childUserId: childId, pointId, score, passed })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } }); .onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
if (!passed) return; if (!passed) return 0;
const pointsEarned = await awardRulePoints(childId, "grammar_point", `point-${pointId}`, title);
const today = todayStr(); const today = todayStr();
const ckTitle = `英语语法 · ${title}`; const ckTitle = `英语语法 · ${title}`;
const [existing] = await db const [existing] = await db
@@ -123,4 +128,5 @@ export async function completePoint(
sortOrder: 99, sortOrder: 99,
}); });
} }
return pointsEarned;
} }
+6 -3
View File
@@ -5,6 +5,7 @@ import { mathPoints, mathProgress, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse"; import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
import { gradeAnswers, type AnswerValue } from "@/lib/grammar"; import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { awardRulePoints } from "@/lib/points";
export { extractQuiz, type QuizQ }; export { extractQuiz, type QuizQ };
export { gradeAnswers, type AnswerValue }; export { gradeAnswers, type AnswerValue };
@@ -64,20 +65,21 @@ export async function mathPassedByPoint(childId: number): Promise<Set<string>> {
return new Set(rows.map((r) => r.pointId)); return new Set(rows.map((r) => r.pointId));
} }
// xuchao: 记录结果;通过则同步完成当天数学打卡 // xuchao: 记录结果;通过则同步完成当天数学打卡;返回首次达标所发积分
export async function completeMathPoint( export async function completeMathPoint(
childId: number, childId: number,
pointId: string, pointId: string,
title: string, title: string,
score: number, score: number,
passed: boolean, passed: boolean,
) { ): Promise<number> {
await db await db
.insert(mathProgress) .insert(mathProgress)
.values({ childUserId: childId, pointId, score, passed }) .values({ childUserId: childId, pointId, score, passed })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } }); .onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
if (!passed) return; if (!passed) return 0;
const pointsEarned = await awardRulePoints(childId, "math_lesson", `point-${pointId}`, title);
const today = todayStr(); const today = todayStr();
const ckTitle = `数学思维 · ${title}`; const ckTitle = `数学思维 · ${title}`;
const [existing] = await db const [existing] = await db
@@ -104,4 +106,5 @@ export async function completeMathPoint(
sortOrder: 97, sortOrder: 97,
}); });
} }
return pointsEarned;
} }
+16
View File
@@ -36,3 +36,19 @@ export function viewUrl(key: string) {
export function photoKey(childId: number, date: string) { export function photoKey(childId: number, date: string) {
return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`; return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`;
} }
// xuchao: 是否已配置 OSS(未配置时本地开发走 /api/dev-photo 落盘回退)
export function ossConfigured() {
return !!(BUCKET && ID && SECRET);
}
// xuchao: 解答题作答照片键名:exam-solve/孩子ID/试卷/题号-随机.jpg
export function solveKey(childId: number, paperId: string, questionIndex: number) {
return `exam-solve/${childId}/${paperId}/q${questionIndex}-${crypto.randomBytes(8).toString("hex")}.jpg`;
}
// xuchao: 作答照片展示地址:OSS 签名 GET;开发环境返回本地回退路由地址
export function photoViewUrl(key: string) {
if (ossConfigured()) return viewUrl(key);
return `/api/dev-photo?key=${encodeURIComponent(key)}`;
}
+114
View File
@@ -0,0 +1,114 @@
// xuchao: 积分系统——纯累计,首次达标才发(唯一键防刷);打卡分值随任务,测试类分值在 point_rule 可调
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { pointLog, pointRule } from "@/db/schema";
// 测试类事件中文名(家长规则页展示用);checkin 分值在任务上,不进规则表
export const POINT_EVENT_LABELS: Record<string, string> = {
word_test: "单词检测达标",
math_lesson: "数学课达标",
grammar_point: "语法知识点达标",
grammar_weekly: "英语周检测达标",
math_weekly: "数学周检测达标",
exam_pass: "考试通过",
};
// 规则表无记录时的默认分值(家长改过即落库覆盖)
export const DEFAULT_RULE_POINTS: Record<string, number> = {
word_test: 20,
math_lesson: 15,
grammar_point: 15,
grammar_weekly: 50,
math_weekly: 50,
exam_pass: 100,
};
// xuchao: drizzle 会把 mysql2 错误包进 cause,沿错误链识别重复键
function isDupEntry(e: unknown): boolean {
let cur: unknown = e;
for (let i = 0; cur && i < 5; i++) {
const c = cur as { code?: string; errno?: number };
if (c.code === "ER_DUP_ENTRY" || c.errno === 1062) return true;
cur = (cur as { cause?: unknown }).cause;
}
return false;
}
// 发放积分;同一 (child,eventType,refKey) 重复插入命中唯一键→静默跳过返回 0
export async function awardPoints(
childId: number,
eventType: string,
refKey: string,
points: number,
remark: string,
): Promise<number> {
if (!points || points <= 0) return 0;
try {
await db.insert(pointLog).values({ childUserId: childId, eventType, refKey, points, remark });
return points;
} catch (e: unknown) {
if (isDupEntry(e)) return 0;
throw e;
}
}
// 测试类事件发放:按规则表取分值(缺省用默认值),返回实发分值(0=已发过)
export async function awardRulePoints(
childId: number,
eventType: string,
refKey: string,
remark: string,
): Promise<number> {
const pts = await getRulePoints(eventType);
return awardPoints(childId, eventType, refKey, pts, remark);
}
export async function getRulePoints(eventType: string): Promise<number> {
const [row] = await db.select().from(pointRule).where(eq(pointRule.eventType, eventType)).limit(1);
return row?.points ?? DEFAULT_RULE_POINTS[eventType] ?? 0;
}
// 家长规则页:DB 值优先,缺的用默认值补齐
export async function listPointRules(): Promise<{ eventType: string; label: string; points: number }[]> {
const rows = await db.select().from(pointRule);
const map = new Map(rows.map((r) => [r.eventType, r.points]));
return Object.keys(DEFAULT_RULE_POINTS).map((eventType) => ({
eventType,
label: POINT_EVENT_LABELS[eventType],
points: map.get(eventType) ?? DEFAULT_RULE_POINTS[eventType],
}));
}
export async function upsertPointRule(eventType: string, points: number): Promise<void> {
await db
.insert(pointRule)
.values({ eventType, points })
.onDuplicateKeyUpdate({ set: { points, updatedAt: new Date() } });
}
export async function totalPoints(childId: number): Promise<number> {
const [r] = await db
.select({ n: sql<number>`COALESCE(SUM(points),0)` })
.from(pointLog)
.where(eq(pointLog.childUserId, childId));
return r.n;
}
// xuchao: 查询某事件已发积分(周测结果页刷新后仍要显示「+N 积分」)
export async function getAwardedPoints(childId: number, eventType: string, refKey: string): Promise<number> {
const [row] = await db
.select({ points: pointLog.points })
.from(pointLog)
.where(and(eq(pointLog.childUserId, childId), eq(pointLog.eventType, eventType), eq(pointLog.refKey, refKey)))
.limit(1);
return row?.points ?? 0;
}
export async function pointHistory(childId: number, limit = 50) {
return db
.select()
.from(pointLog)
.where(eq(pointLog.childUserId, childId))
.orderBy(desc(pointLog.id))
.limit(limit);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { validateQuestions } from "./question-validation";
const base = {
type: "choice",
stem: "x?",
options: ["a", "b"],
answer: 0,
explain: "e",
};
describe("validateQuestions 基础", () => {
it("解析失败报错", () => {
expect(validateQuestions("{bad").error).toContain("解析失败");
});
it("非数组报错", () => {
expect(validateQuestions("{}").error).toContain("必须是 JSON 数组");
});
it("空数组报错", () => {
expect(validateQuestions("[]").error).toContain("至少需要 1 道题");
});
it("合法 choice 通过并返回题数", () => {
const r = validateQuestions(JSON.stringify([base]));
expect(r.error).toBeUndefined();
expect(r.count).toBe(1);
});
it("choice 缺 options 报错", () => {
const { stem, answer, explain } = base;
expect(validateQuestions(JSON.stringify([{ type: "choice", stem, answer, explain }])).error).toContain("缺少选项");
});
it("fill 缺 answers 报错", () => {
expect(
validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error,
).toContain("缺少参考答案");
});
it("figure 合法/非法文件名", () => {
expect(validateQuestions(JSON.stringify([{ ...base, figure: "tri.png" }])).error).toBeUndefined();
expect(validateQuestions(JSON.stringify([{ ...base, figure: "a b.png" }])).error).toContain("配图文件名不合法");
expect(validateQuestions(JSON.stringify([{ ...base, figure: " " }])).error).toContain("配图文件名不合法");
});
});
describe("validateQuestions solve / judgeMode", () => {
const solveRubric = {
type: "solve",
stem: "解方程",
points: 10,
reference: "x=2",
rubric: [{ point: "过程", score: 6 }, { point: "答案", score: 4 }],
explain: "e",
};
it("合法 rubric solve(缺省 judgeMode)通过", () => {
expect(validateQuestions(JSON.stringify([solveRubric])).error).toBeUndefined();
});
it("rubric 模式缺 reference 报错", () => {
const { reference: _ref, ...rest } = solveRubric;
expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少参考解答");
});
it("rubric 模式缺 rubric 评分点报错", () => {
const { rubric: _rub, ...rest } = solveRubric;
expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少评分点");
});
it("answer 模式缺 answers 报错,不要求 reference", () => {
const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined };
delete q.rubric;
expect(validateQuestions(JSON.stringify([q])).error).toContain("缺少最终答案");
const ok = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: undefined, reference: undefined };
delete ok.rubric;
delete ok.reference;
expect(validateQuestions(JSON.stringify([ok])).error).toBeUndefined();
});
it("answer 模式带 rubric 报错", () => {
const q = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: [solveRubric.rubric[0]] };
expect(validateQuestions(JSON.stringify([q])).error).toContain("不应携带 rubric");
});
it("judgeMode 非法值报错", () => {
const q = { ...solveRubric, judgeMode: "auto" };
expect(validateQuestions(JSON.stringify([q])).error).toContain("judgeMode 非法");
});
it("存量 86 题形状全通过(48 choice/30 fill/8 solve 代表用例)", () => {
const fill = { type: "fill", stem: "f?", answers: ["a", "b"], explain: "e" };
const arr: unknown[] = Array.from({ length: 48 }, () => base);
arr.push(...Array.from({ length: 30 }, () => fill));
arr.push(...Array.from({ length: 8 }, () => solveRubric));
const r = validateQuestions(JSON.stringify(arr));
expect(r.error).toBeUndefined();
expect(r.count).toBe(86);
});
});
+52
View File
@@ -0,0 +1,52 @@
// xuchao: 试卷题目 JSON 形状校验(服务端保存前唯一写入门禁),纯函数便于单测
export const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"] as const;
const SOLVE_JUDGE_MODES = ["rubric", "answer"] as const;
export type ValidationResult = { error?: string; count?: number };
export function validateQuestions(raw: string): ValidationResult {
let arr: unknown;
try {
arr = JSON.parse(raw);
} catch {
return { error: "题目 JSON 解析失败,请检查语法" };
}
if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" };
if (arr.length === 0) return { error: "试卷至少需要 1 道题" };
for (let i = 0; i < arr.length; i++) {
const q = arr[i] as Record<string, unknown>;
const no = `第 ${i + 1} 题`;
if (!q || typeof q !== "object") return { error: `${no}不是对象` };
if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` };
if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` };
const t = q.type as (typeof EXAM_TYPES)[number];
if (!EXAM_TYPES.includes(t)) return { error: `${no}的 type 非法:${String(q.type)}` };
if (typeof q.figure === "string" && (q.figure.trim() === "" || !/^[\w.-]+$/.test(q.figure))) {
return { error: `${no}配图文件名不合法(figure)` };
}
if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` };
if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` };
if (t === "choice" && (typeof q.answer !== "number" || q.answer < 0 || q.answer >= (q.options as unknown[]).length)) return { error: `${no}(choice)答案下标越界` };
if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
if (t === "order") {
if (!Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
if (!Array.isArray(q.words) || q.words.length === 0) return { error: `${no}(order)缺少词块(words)` };
}
if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` };
if (t === "solve") {
if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` };
const mode = q.judgeMode;
if (mode !== undefined && !SOLVE_JUDGE_MODES.includes(mode as (typeof SOLVE_JUDGE_MODES)[number])) {
return { error: `${no}(solve)judgeMode 非法:${String(mode)}` };
}
if (mode === "answer") {
if (!Array.isArray(q.answers) || q.answers.length === 0) return { error: `${no}(solve·answer 模式)缺少最终答案(answers 数组)` };
if (Array.isArray(q.rubric) && q.rubric.length > 0) return { error: `${no}(solve·answer 模式)不应携带 rubric 评分点` };
} else {
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` };
if (!Array.isArray(q.rubric) || q.rubric.length === 0) return { error: `${no}(solve·rubric 模式)缺少评分点(rubric)` };
}
}
}
return { count: arr.length };
}
+14 -1
View File
@@ -1,13 +1,23 @@
// 纯解析工具:不依赖数据库,可安全在客户端组件中使用 // 纯解析工具:不依赖数据库,可安全在客户端组件中使用
export type QuizQ = { export type QuizQ = {
type: "choice" | "fill" | "judge" | "order" | "match"; type: "choice" | "fill" | "judge" | "order" | "match" | "solve";
stem: string; stem: string;
options?: string[]; options?: string[];
answer?: number | string[]; answer?: number | string[];
answers?: string[]; answers?: string[];
words?: string[]; // order:打乱的词 words?: string[]; // order:打乱的词
pairs?: [string, string][]; // match:左右配对 pairs?: [string, string][]; // match:左右配对
// xuchao: solve 解答题(拍照 + AI 按评分点判分)
figure?: string; // 题目配图路径(public/exam/<卷>/xxx)
points?: number; // solve:本题满分
reference?: string; // solve:完整参考解答
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
// xuchao: 应用题判定模式——rubric=AI 逐评分点(缺省,存量兼容);answer=最终答案数组匹配秒判
judgeMode?: "rubric" | "answer";
// judgeMode==="answer" 时复用上面 answers 数组(等价答法都填)
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
explain: string; explain: string;
}; };
@@ -26,3 +36,6 @@ export function extractQuiz(md: string): QuizQ[] {
} }
return out; return out;
} }
// xuchao: 应用题 answer 模式的提交载荷(最终答案 + 照片 key,供秒判与错题本快照)
export type SolveAnswerPayload = { answer: string; ossKey: string };
+2
View File
@@ -44,6 +44,7 @@ export async function ensureDay(childId: number, date: string) {
title: t.title, title: t.title,
subject: t.subject, subject: t.subject,
requiresPhoto: t.requiresPhoto, requiresPhoto: t.requiresPhoto,
points: t.points,
sortOrder: t.sortOrder, sortOrder: t.sortOrder,
sourceTemplateId: t.id, sourceTemplateId: t.id,
})), })),
@@ -71,6 +72,7 @@ export async function ensureDay(childId: number, date: string) {
title: t.title, title: t.title,
subject: t.subject, subject: t.subject,
requiresPhoto: t.requiresPhoto, requiresPhoto: t.requiresPhoto,
points: t.points,
sortOrder: t.sortOrder, sortOrder: t.sortOrder,
sourceTemplateId: t.id, sourceTemplateId: t.id,
}); });
+35 -12
View File
@@ -1,13 +1,27 @@
import { asc, sql } from "drizzle-orm";
import raw from "@/content/words/words.json"; import raw from "@/content/words/words.json";
import { db } from "@/db";
import { wordEntry } from "@/db/schema";
export type Word = { word: string; meaning: string; day: number; seq: number }; export type Word = { word: string; meaning: string; day: number; seq: number };
export const WORDS = raw as Word[]; // xuchao: 词库以 word_entry 表为准(家长端可编辑);表空回退打包的 words.json,
export const TOTAL_WORD_DAYS = WORDS.reduce((m, w) => Math.max(m, w.day), 0); // 保证未灌种子数据的部署孩子端照常可用
export async function allWords(): Promise<Word[]> {
const rows = await db.select().from(wordEntry).orderBy(asc(wordEntry.seq));
if (rows.length > 0) return rows.map((r) => ({ word: r.word, meaning: r.meaning, day: r.day, seq: r.seq }));
return raw as Word[];
}
export async function totalWordDays(): Promise<number> {
const ws = await allWords();
return ws.reduce((m, w) => Math.max(m, w.day), 0);
}
// 某一天的单词 // 某一天的单词
export function wordsByDay(day: number): Word[] { export async function wordsByDay(day: number): Promise<Word[]> {
return WORDS.filter((w) => w.day === day); const ws = await allWords();
return ws.filter((w) => w.day === day);
} }
// 标准发音(有道公共语音,美式 type=2 / 英式 type=1) // 标准发音(有道公共语音,美式 type=2 / 英式 type=1)
@@ -21,21 +35,22 @@ function lev(a: string, b: string): number {
n = b.length; n = b.length;
if (Math.abs(m - n) > 2) return 99; if (Math.abs(m - n) > 2) return 99;
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++) for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
return dp[m][n]; return dp[m][n];
} }
// 每日检测组卷(确定性):选最多 10 词,选中文释义,干扰项用相邻词的释义 // 每日检测组卷(确定性):选最多 10 词,选中文释义,干扰项按全库位置偏移取释义
export type WordTestQ = { word: string; options: string[]; answer: number }; export type WordTestQ = { word: string; options: string[]; answer: number };
export function buildWordTest(day: number): WordTestQ[] { export async function buildWordTest(day: number): Promise<WordTestQ[]> {
const ws = wordsByDay(day); const ws = await allWords();
const pick = ws.slice(0, 10); const dayWords = ws.filter((w) => w.day === day);
const pick = dayWords.slice(0, 10);
return pick.map((w) => { return pick.map((w) => {
const idx = ws.indexOf(w);
const distractors = [1, 2, 3] const distractors = [1, 2, 3]
.map((off) => WORDS[(w.seq - 1 + off * 37) % WORDS.length].meaning) .map((off) => ws[(idx + off * 37) % ws.length].meaning)
.filter((m) => m && m !== w.meaning) .filter((m) => m && m !== w.meaning)
.slice(0, 3); .slice(0, 3);
const options = [w.meaning || w.word, ...distractors]; const options = [w.meaning || w.word, ...distractors];
@@ -46,10 +61,18 @@ export function buildWordTest(day: number): WordTestQ[] {
}); });
} }
export function similarWords(word: string, n = 3): Word[] { export async function similarWords(word: string, n = 3): Promise<Word[]> {
return WORDS.filter((w) => w.word !== word) const ws = await allWords();
return ws
.filter((w) => w.word !== word)
.map((w) => ({ w, d: lev(word, w.word) })) .map((w) => ({ w, d: lev(word, w.word) }))
.sort((a, b) => a.d - b.d) .sort((a, b) => a.d - b.d)
.slice(0, n) .slice(0, n)
.map((x) => x.w); .map((x) => x.w);
} }
// 词库总数(家长端展示用)
export async function countWordEntries(): Promise<number> {
const [r] = await db.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
return r.n;
}
+8 -3
View File
@@ -6,6 +6,7 @@ import type { AnswerValue } from "@/lib/grammar";
// 各类题型的"用户作答"与"标准答案"可读文本 // 各类题型的"用户作答"与"标准答案"可读文本
export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string { export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string {
if (q.type === "solve") return `拍照作答,得 ${q.solveSnap?.earned ?? 0} / ${q.points ?? 0} 分`;
if (a == null) return "未作答"; if (a == null) return "未作答";
if (q.type === "fill") return String(a); if (q.type === "fill") return String(a);
if (q.type === "order") return Array.isArray(a) ? a.join(" ") : String(a); if (q.type === "order") return Array.isArray(a) ? a.join(" ") : String(a);
@@ -14,6 +15,7 @@ export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string
return opts[Number(a)] ?? String(a); return opts[Number(a)] ?? String(a);
} }
export function correctText(q: QuizQ): string { export function correctText(q: QuizQ): string {
if (q.type === "solve") return q.reference ?? "";
if (q.type === "fill") return (q.answers ?? []).join(" / "); if (q.type === "fill") return (q.answers ?? []).join(" / ");
if (q.type === "order") return (q.answer as string[]).join(" "); if (q.type === "order") return (q.answer as string[]).join(" ");
if (q.type === "match") return (q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、"); if (q.type === "match") return (q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、");
@@ -23,6 +25,7 @@ export function correctText(q: QuizQ): string {
// 错题入库(同一孩子同一来源同一题干去重) // 错题入库(同一孩子同一来源同一题干去重)
// sourceType:lesson/weekly=英语,mlesson/mweekly=数学,exam=考试卷 // sourceType:lesson/weekly=英语,mlesson/mweekly=数学,exam=考试卷
// solveExtra:解答题的作答快照(照片 key + 逐评分点判定),并入题目 JSON 供错题本回看
export async function recordWrong( export async function recordWrong(
childId: number, childId: number,
sourceType: "lesson" | "weekly" | "mlesson" | "mweekly" | "exam", sourceType: "lesson" | "weekly" | "mlesson" | "mweekly" | "exam",
@@ -30,7 +33,9 @@ export async function recordWrong(
sourceTitle: string, sourceTitle: string,
q: QuizQ, q: QuizQ,
user: AnswerValue | null | undefined, user: AnswerValue | null | undefined,
solveExtra?: NonNullable<QuizQ["solveSnap"]>,
) { ) {
const stored: QuizQ = q.type === "solve" ? { ...q, solveSnap: solveExtra } : q;
const rows = await db const rows = await db
.select({ question: wrongBook.question }) .select({ question: wrongBook.question })
.from(wrongBook) .from(wrongBook)
@@ -48,9 +53,9 @@ export async function recordWrong(
sourceType, sourceType,
sourceId, sourceId,
sourceTitle, sourceTitle,
question: JSON.stringify(q), question: JSON.stringify(stored),
userAnswer: answerText(q, user), userAnswer: answerText(stored, user),
correctAnswer: correctText(q), correctAnswer: correctText(stored),
}); });
} }
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// xuchao: 组件测试后自动清理 DOM,避免跨测试污染
afterEach(() => cleanup());
+13
View File
@@ -0,0 +1,13 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
// xuchao: Vitest 配置——@ 别名对齐 tsconfig;组件测试文件用 // @vitest-environment jsdom 标注
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
oxc: { jsx: { runtime: "automatic" } },
test: {
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["src/test-setup.ts"],
},
});