From 6ed4d5a61ef08a8bcb8636c18f5d99658d53e19b Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Mon, 3 Aug 2026 01:20:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=9C=9F:=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E4=BF=A1=E4=BB=BB=E9=A3=8E=20UI=20+=20=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E7=A7=91=E7=9B=AE=E5=88=86=E7=B1=BB=20+=20=E8=8B=B1=E8=AF=AD?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=E5=AD=A6=E4=B9=A0=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全站按 Corporate Trust 设计规范重做:靛蓝/紫罗兰渐变、Plus Jakarta Sans 自托管字体、带色阴影、卡片悬浮、氛围光斑 - 打卡任务增加科目分类(语文/数学/英语/运动/其他),孩子端按科目分组 - 英语语法自学模块:线性解锁课程 + 课后/周/月测验引擎,测验达标 自动联动当日英语打卡;家长概览显示语法进度 - 课程内容:A1→B1 完整大纲,9 周 54 课 + 9 周测 + 2 月测,讲解/例句/ 练习/测验均原创,仅参考两份语法书的主题编排 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + drizzle/0001_add-subject.sql | 2 + drizzle/0002_grammar-progress.sql | 12 + drizzle/meta/0001_snapshot.json | 635 ++++++++++++++++++++++++ drizzle/meta/0002_snapshot.json | 719 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + package-lock.json | 10 + package.json | 1 + src/app/globals.css | 70 ++- src/app/join/page.tsx | 22 +- src/app/kid/page.tsx | 86 +++- src/app/kid/task-card.tsx | 26 +- src/app/layout.tsx | 3 +- src/app/learn/[seq]/page.tsx | 72 +++ src/app/learn/actions.ts | 39 ++ src/app/learn/page.tsx | 122 +++++ src/app/learn/practice.tsx | 70 +++ src/app/learn/quiz-form.tsx | 149 ++++++ src/app/learn/section-block.tsx | 69 +++ src/app/login/page.tsx | 20 +- src/app/parent/actions.ts | 4 + src/app/parent/dashboard.tsx | 36 +- src/app/parent/overview-tab.tsx | 17 + src/app/parent/page.tsx | 8 +- src/app/parent/templates-tab.tsx | 59 ++- src/components/background-blobs.tsx | 9 + src/components/ui/button.tsx | 10 +- src/components/ui/card.tsx | 2 +- src/content/grammar/index.ts | 83 ++++ src/content/grammar/types.ts | 48 ++ src/content/grammar/week01.ts | 307 ++++++++++++ src/content/grammar/week02.ts | 440 +++++++++++++++++ src/content/grammar/week03.ts | 430 +++++++++++++++++ src/content/grammar/week04.ts | 374 +++++++++++++++ src/content/grammar/week05.ts | 341 +++++++++++++ src/content/grammar/week06.ts | 358 ++++++++++++++ src/content/grammar/week07.ts | 361 ++++++++++++++ src/content/grammar/week08.ts | 375 +++++++++++++++ src/content/grammar/week09.ts | 361 ++++++++++++++ src/db/schema.ts | 22 + src/lib/cefr.ts | 10 + src/lib/grammar.ts | 89 ++++ src/lib/md.ts | 6 + src/lib/subjects.ts | 18 + src/lib/tasks.ts | 3 + 45 files changed, 5824 insertions(+), 92 deletions(-) create mode 100644 drizzle/0001_add-subject.sql create mode 100644 drizzle/0002_grammar-progress.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/app/learn/[seq]/page.tsx create mode 100644 src/app/learn/actions.ts create mode 100644 src/app/learn/page.tsx create mode 100644 src/app/learn/practice.tsx create mode 100644 src/app/learn/quiz-form.tsx create mode 100644 src/app/learn/section-block.tsx create mode 100644 src/components/background-blobs.tsx create mode 100644 src/content/grammar/index.ts create mode 100644 src/content/grammar/types.ts create mode 100644 src/content/grammar/week01.ts create mode 100644 src/content/grammar/week02.ts create mode 100644 src/content/grammar/week03.ts create mode 100644 src/content/grammar/week04.ts create mode 100644 src/content/grammar/week05.ts create mode 100644 src/content/grammar/week06.ts create mode 100644 src/content/grammar/week07.ts create mode 100644 src/content/grammar/week08.ts create mode 100644 src/content/grammar/week09.ts create mode 100644 src/lib/cefr.ts create mode 100644 src/lib/grammar.ts create mode 100644 src/lib/md.ts create mode 100644 src/lib/subjects.ts diff --git a/.gitignore b/.gitignore index e97a96e..970d1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ next-env.d.ts # 系统文件 .DS_Store + +# 参考资料(含版权 PDF,不入库) +materials/ +*.pdf diff --git a/drizzle/0001_add-subject.sql b/drizzle/0001_add-subject.sql new file mode 100644 index 0000000..1f39756 --- /dev/null +++ b/drizzle/0001_add-subject.sql @@ -0,0 +1,2 @@ +ALTER TABLE `task_instance` ADD `subject` enum('chinese','math','english','exercise','other') DEFAULT 'other' NOT NULL;--> statement-breakpoint +ALTER TABLE `task_template` ADD `subject` enum('chinese','math','english','exercise','other') DEFAULT 'other' NOT NULL; \ No newline at end of file diff --git a/drizzle/0002_grammar-progress.sql b/drizzle/0002_grammar-progress.sql new file mode 100644 index 0000000..79380e3 --- /dev/null +++ b/drizzle/0002_grammar-progress.sql @@ -0,0 +1,12 @@ +CREATE TABLE `grammar_progress` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `seq` int NOT NULL, + `score` int, + `passed` boolean NOT NULL DEFAULT false, + `completed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `grammar_progress_id` PRIMARY KEY(`id`), + CONSTRAINT `uq_grammar_child_seq` UNIQUE(`child_user_id`,`seq`) +); +--> statement-breakpoint +ALTER TABLE `grammar_progress` ADD CONSTRAINT `grammar_progress_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6378497 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,635 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "344969b3-8a75-4900-b08f-c3407340d1c4", + "prevId": "a044f535-beea-4fe6-8db5-3ae0019977de", + "tables": { + "activation_code": { + "name": "activation_code", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "activation_code_child_user_id_user_id_fk": { + "name": "activation_code_child_user_id_user_id_fk", + "tableFrom": "activation_code", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activation_code_created_by_user_id_fk": { + "name": "activation_code_created_by_user_id_fk", + "tableFrom": "activation_code", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "activation_code_id": { + "name": "activation_code_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "family": { + "name": "family", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "family_id": { + "name": "family_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "task_instance_id": { + "name": "task_instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oss_key": { + "name": "oss_key", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_photo_instance": { + "name": "idx_photo_instance", + "columns": [ + "task_instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_task_instance_id_task_instance_id_fk": { + "name": "photo_task_instance_id_task_instance_id_fk", + "tableFrom": "photo", + "tableTo": "task_instance", + "columnsFrom": [ + "task_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "photo_id": { + "name": "photo_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_label": { + "name": "device_label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_session_token": { + "name": "uq_session_token", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "idx_session_user": { + "name": "idx_session_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_id": { + "name": "session_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "task_instance": { + "name": "task_instance", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "enum('chinese','math','english','exercise','other')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "requires_photo": { + "name": "requires_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "enum('pending','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "is_makeup": { + "name": "is_makeup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "done_at": { + "name": "done_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_template_id": { + "name": "source_template_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_instance_child_date": { + "name": "idx_instance_child_date", + "columns": [ + "child_user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_instance_child_user_id_user_id_fk": { + "name": "task_instance_child_user_id_user_id_fk", + "tableFrom": "task_instance", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_instance_id": { + "name": "task_instance_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "task_template": { + "name": "task_template", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dow": { + "name": "dow", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "enum('chinese','math','english','exercise','other')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "requires_photo": { + "name": "requires_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_template_child_dow": { + "name": "idx_template_child_dow", + "columns": [ + "child_user_id", + "dow" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_template_child_user_id_user_id_fk": { + "name": "task_template_child_user_id_user_id_fk", + "tableFrom": "task_template", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_template_id": { + "name": "task_template_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "family_id": { + "name": "family_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('parent','child')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uq_user_phone": { + "name": "uq_user_phone", + "columns": [ + "phone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_family_id_family_id_fk": { + "name": "user_family_id_family_id_fk", + "tableFrom": "user", + "tableTo": "family", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..e411d3c --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,719 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "384a621e-e735-46d7-80a5-8c925bb0ce44", + "prevId": "344969b3-8a75-4900-b08f-c3407340d1c4", + "tables": { + "activation_code": { + "name": "activation_code", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "activation_code_child_user_id_user_id_fk": { + "name": "activation_code_child_user_id_user_id_fk", + "tableFrom": "activation_code", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activation_code_created_by_user_id_fk": { + "name": "activation_code_created_by_user_id_fk", + "tableFrom": "activation_code", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "activation_code_id": { + "name": "activation_code_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "family": { + "name": "family", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "family_id": { + "name": "family_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "grammar_progress": { + "name": "grammar_progress", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uq_grammar_child_seq": { + "name": "uq_grammar_child_seq", + "columns": [ + "child_user_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": { + "grammar_progress_child_user_id_user_id_fk": { + "name": "grammar_progress_child_user_id_user_id_fk", + "tableFrom": "grammar_progress", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grammar_progress_id": { + "name": "grammar_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "task_instance_id": { + "name": "task_instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oss_key": { + "name": "oss_key", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_photo_instance": { + "name": "idx_photo_instance", + "columns": [ + "task_instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_task_instance_id_task_instance_id_fk": { + "name": "photo_task_instance_id_task_instance_id_fk", + "tableFrom": "photo", + "tableTo": "task_instance", + "columnsFrom": [ + "task_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "photo_id": { + "name": "photo_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_label": { + "name": "device_label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_session_token": { + "name": "uq_session_token", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "idx_session_user": { + "name": "idx_session_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_id": { + "name": "session_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "task_instance": { + "name": "task_instance", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "enum('chinese','math','english','exercise','other')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "requires_photo": { + "name": "requires_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "enum('pending','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "is_makeup": { + "name": "is_makeup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "done_at": { + "name": "done_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_template_id": { + "name": "source_template_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_instance_child_date": { + "name": "idx_instance_child_date", + "columns": [ + "child_user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_instance_child_user_id_user_id_fk": { + "name": "task_instance_child_user_id_user_id_fk", + "tableFrom": "task_instance", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_instance_id": { + "name": "task_instance_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "task_template": { + "name": "task_template", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "child_user_id": { + "name": "child_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dow": { + "name": "dow", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "enum('chinese','math','english','exercise','other')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "requires_photo": { + "name": "requires_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_template_child_dow": { + "name": "idx_template_child_dow", + "columns": [ + "child_user_id", + "dow" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_template_child_user_id_user_id_fk": { + "name": "task_template_child_user_id_user_id_fk", + "tableFrom": "task_template", + "tableTo": "user", + "columnsFrom": [ + "child_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_template_id": { + "name": "task_template_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "family_id": { + "name": "family_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('parent','child')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uq_user_phone": { + "name": "uq_user_phone", + "columns": [ + "phone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_family_id_family_id_fk": { + "name": "user_family_id_family_id_fk", + "tableFrom": "user", + "tableTo": "family", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c5a8385..24b9241 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1785682170922, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1785687829667, + "tag": "0001_add-subject", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1785689264881, + "tag": "0002_grammar-progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 16ee0b3..27a5892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "superjump", "version": "0.1.0", "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "^5.3.0", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -981,6 +982,15 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@fontsource-variable/plus-jakarta-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz", + "integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index d0a6a2e..4bda6d7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:seed": "tsx scripts/seed.ts" }, "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "^5.3.0", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/globals.css b/src/app/globals.css index 45e2318..7ae9287 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "@fontsource-variable/plus-jakarta-sans"; @custom-variant dark (&:is(.dark *)); @@ -25,28 +26,41 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + + /* 品牌字体:拉丁用 Plus Jakarta Sans,中文回落系统字体 */ + --font-sans: "Plus Jakarta Sans Variable", "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", system-ui, sans-serif; + + /* 带色阴影(企业信任风的核心视觉签名) */ + --shadow-soft: 0 4px 20px -2px rgb(79 70 229 / 0.1); + --shadow-lift: 0 10px 25px -5px rgb(79 70 229 / 0.15), + 0 8px 10px -6px rgb(79 70 229 / 0.1); + --shadow-btn: 0 4px 14px 0 rgb(79 70 229 / 0.3); } +/* Corporate Trust 色板:靛蓝主色 + 紫罗兰辅色 + 冷灰中性色 */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --radius: 0.75rem; + --background: #f8fafc; /* slate-50 */ + --foreground: #0f172a; /* slate-900 */ + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #4f46e5; /* indigo-600 */ + --primary-foreground: #ffffff; + --secondary: #f1f5f9; /* slate-100 */ + --secondary-foreground: #334155; /* slate-700 */ + --muted: #f1f5f9; + --muted-foreground: #64748b; /* slate-500 */ + --accent: #eef2ff; /* indigo-50 */ + --accent-foreground: #4f46e5; + --destructive: #ef4444; + --border: #e2e8f0; /* slate-200 */ + --input: #e2e8f0; + --ring: #6366f1; /* indigo-500 */ + --violet: #7c3aed; /* violet-600,渐变辅色 */ + --emerald: #10b981; /* 成功色 */ } @layer base { @@ -54,6 +68,22 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-sans antialiased; } } + +/* 渐变文字:靛蓝 → 紫罗兰,用于重点标题 */ +@utility text-gradient { + background-image: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* 背景氛围光斑:大号模糊渐变圆,营造纵深 */ +@utility bg-blob { + position: absolute; + border-radius: 9999px; + filter: blur(64px); + pointer-events: none; +} diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx index bd7f33b..5cb0ac0 100644 --- a/src/app/join/page.tsx +++ b/src/app/join/page.tsx @@ -2,9 +2,11 @@ import { useActionState } from "react"; import Link from "next/link"; +import { KeyRound } from "lucide-react"; +import { BackgroundBlobs } from "@/components/background-blobs"; import { activate } from "./actions"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,12 +14,16 @@ export default function JoinPage() { const [state, formAction, pending] = useActionState(activate, null); return ( -
- - - 激活这台设备 +
+ + + +
+ +
+

激活这台设备

- 请让家长在家长端生成激活码,然后输入下面的框里 + 请让家长在家长端生成激活码,输入下面的框里

@@ -32,7 +38,7 @@ export default function JoinPage() { pattern="\d{6}" maxLength={6} autoComplete="one-time-code" - className="text-center text-3xl tracking-[0.5em] font-bold" + className="text-center text-3xl font-bold tracking-[0.5em] text-indigo-600" placeholder="······" required /> @@ -47,7 +53,7 @@ export default function JoinPage() { 我是家长,{" "} 去登录 diff --git a/src/app/kid/page.tsx b/src/app/kid/page.tsx index b08a8be..631a73f 100644 --- a/src/app/kid/page.tsx +++ b/src/app/kid/page.tsx @@ -1,9 +1,15 @@ -import { Flame } from "lucide-react"; +import { BookOpen, Flame } from "lucide-react"; +import Link from "next/link"; import { redirect } from "next/navigation"; +import { BackgroundBlobs } from "@/components/background-blobs"; +import { cn } from "@/lib/utils"; +import { SUBJECT_ORDER, subjectMeta } from "@/lib/subjects"; import { todayStr } from "@/lib/dates"; import { getSessionUser } from "@/lib/auth"; import { streakOf } from "@/lib/streak"; import { dayTasks } from "@/lib/tasks"; +import { entryBySeq, TOTAL } from "@/content/grammar"; +import { nextSeq, passedSet } from "@/lib/grammar"; import { TaskCard } from "./task-card"; export default async function KidPage() { @@ -19,40 +25,84 @@ export default async function KidPage() { const doneCount = tasks.filter((t) => t.status === "done").length; const allDone = tasks.length > 0 && doneCount === tasks.length; + // 英语语法入口:下一课标题 + 已完成课数 + const gNext = await nextSeq(user.id); + const gGoal = gNext <= TOTAL ? entryBySeq(gNext) : null; + const gDone = (await passedSet(user.id)).size; + return ( -
+
+
-

你好,{user.displayName}

+

+ 你好,{user.displayName} +

{today}

-
- - {streak} - 天 +
+ + {streak} + 天
+ +
+
+ +
+
+

英语语法 · 已学 {gDone} 课

+

+ {gGoal ? gGoal.title : "全部完成,真棒!"} +

+
+ → +
+ + {tasks.length === 0 ? ( -
-

🎉

-

今天没有任务

+
+

🎉

+

今天没有任务

连击自动保持,去玩吧!

) : ( <> -

- 已完成 {doneCount} / {tasks.length} 项 +

+ 已完成{" "} + {doneCount} /{" "} + {tasks.length} 项

-
- {tasks.map((t) => ( - - ))} +
+ {SUBJECT_ORDER.map((key) => { + const group = tasks.filter((t) => t.subject === key); + if (group.length === 0) return null; + const meta = subjectMeta(key); + const gDone = group.filter((t) => t.status === "done").length; + return ( +
+
+ + {meta.label} + + {gDone}/{group.length} + +
+
+ {group.map((t) => ( + + ))} +
+
+ ); + })}
{allDone && ( -
+

💪

-

今天全部完成,太棒了!

+

今天全部完成,太棒了!

)} diff --git a/src/app/kid/task-card.tsx b/src/app/kid/task-card.tsx index 4512d3c..84e5b2f 100644 --- a/src/app/kid/task-card.tsx +++ b/src/app/kid/task-card.tsx @@ -77,8 +77,8 @@ export function TaskCard({ return (
@@ -87,26 +87,36 @@ export function TaskCard({ onClick={onToggle} disabled={busy} aria-label={done ? "取消完成" : "标记完成"} - className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${ - done ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/40" + className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${ + done + ? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-[0_0_16px_rgba(16,185,129,0.4)]" + : "border-slate-300 hover:border-indigo-400" }`} > {done && }
-

+

{task.title}

{task.requiresPhoto && } {task.requiresPhoto && 需拍照凭证} - {task.isMakeup && 补卡} + {task.isMakeup && ( + + 补卡 + + )}
{task.requiresPhoto && task.photoUrls.length < 3 && (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 64e1f82..240002d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -17,9 +17,8 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - // 系统字体栈,不加载任何网络字体(避免构建与访问时请求境外服务) - {children} + {children} ); } diff --git a/src/app/learn/[seq]/page.tsx b/src/app/learn/[seq]/page.tsx new file mode 100644 index 0000000..6e2643e --- /dev/null +++ b/src/app/learn/[seq]/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { BackgroundBlobs } from "@/components/background-blobs"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { CEFR_STYLE } from "@/lib/cefr"; +import { TOTAL, entryBySeq } from "@/content/grammar"; +import { isUnlocked, passedSet } from "@/lib/grammar"; +import { getSessionUser } from "@/lib/auth"; +import { SectionBlock } from "../section-block"; +import { Practice } from "../practice"; +import { QuizForm } from "../quiz-form"; + +export default async function LessonPage({ params }: { params: Promise<{ seq: string }> }) { + const user = await getSessionUser(); + if (!user) redirect("/join"); + if (user.role !== "child") redirect("/parent"); + + const seq = Number((await params).seq); + const entry = entryBySeq(seq); + if (!entry) redirect("/learn"); + + const passed = await passedSet(user.id); + if (!isUnlocked(seq, passed)) redirect("/learn"); + + const alreadyPassed = passed.has(seq); + const nextSeq = seq < TOTAL ? seq + 1 : null; + const cefr = CEFR_STYLE[entry.cefr]; + + return ( +
+ +
+ + ← 课程地图 + +
+ + {cefr.label} + + + {entry.type === "lesson" ? `第 ${seq} 课` : entry.type === "weekly" ? "周测" : "月测"} + + {alreadyPassed && ( + 已通过 + )} +
+

{entry.title}

+ {entry.type === "lesson" ? ( +

🎯 {entry.goal}

+ ) : ( +

{entry.intro}

+ )} +
+ + {entry.type === "lesson" && ( + <> +
+ {entry.sections.map((s, i) => ( + + ))} +
+ + + )} + +
+ +
+
+ ); +} diff --git a/src/app/learn/actions.ts b/src/app/learn/actions.ts new file mode 100644 index 0000000..8960258 --- /dev/null +++ b/src/app/learn/actions.ts @@ -0,0 +1,39 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSessionUser } from "@/lib/auth"; +import { entryBySeq } from "@/content/grammar"; +import { completeSeq, grade, isUnlocked, passedSet } from "@/lib/grammar"; + +export type QuizResult = + | { + score: number; + passed: boolean; + correct: number; + total: number; + detail: boolean[]; + } + | { error: string }; + +// 提交测验:服务端按标准答案计分(防篡改),达标即记进度并联动英语打卡 +export async function submitQuiz(_prev: QuizResult | null, formData: FormData): Promise { + const user = await getSessionUser(); + if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" }; + + const seq = Number(formData.get("seq")); + const entry = entryBySeq(seq); + if (!entry) return { error: "课程不存在" }; + + const passed = await passedSet(user.id); + if (!isUnlocked(seq, passed)) return { error: "请先完成前面的课程" }; + + const answers = entry.quiz.map((_, i) => Number(formData.get(`a${i}`))); + const g = grade(entry.quiz, answers); + + await completeSeq(user.id, seq, g.score, g.passed); + revalidatePath("/learn"); + revalidatePath(`/learn/${seq}`); + revalidatePath("/kid"); + revalidatePath("/parent"); + return g; +} diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx new file mode 100644 index 0000000..c4a5d97 --- /dev/null +++ b/src/app/learn/page.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { BookOpen, Check, Lock } from "lucide-react"; +import { redirect } from "next/navigation"; +import { BackgroundBlobs } from "@/components/background-blobs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CEFR_STYLE } from "@/lib/cefr"; +import { SYLLABUS, TOTAL, entryBySeq } from "@/content/grammar"; +import { isUnlocked, nextSeq, passedSet } from "@/lib/grammar"; +import { getSessionUser } from "@/lib/auth"; + +export default async function LearnHome() { + const user = await getSessionUser(); + if (!user) redirect("/join"); + if (user.role !== "child") redirect("/parent"); + + const passed = await passedSet(user.id); + const done = passed.size; + const next = await nextSeq(user.id); + const current = next <= TOTAL ? entryBySeq(next) : null; + const pct = Math.round((done / TOTAL) * 100); + + return ( +
+ +
+ + ← 返回打卡 + + + {done} / {TOTAL} 课 + +
+ + {current ? ( +
+ + 今日目标 · 第 {current.seq} 课 + +

{current.title}

+

+ {current.type === "lesson" ? current.goal : current.intro} +

+ + + +
+ ) : ( +
+

🏆

+

语法课程全部完成!

+

太厉害了,继续保持每天复习。

+
+ )} + +
+
+ 总进度 + {pct}% +
+
+
+
+
+ +
+ {SYLLABUS.map((e) => { + const doneIt = passed.has(e.seq); + const cur = e.seq === next; + const unlocked = isUnlocked(e.seq, passed); + const isMilestone = e.type !== "lesson"; + const inner = ( +
+ {doneIt ? ( + + ) : !unlocked ? ( + + ) : isMilestone ? ( + + ) : ( + e.seq + )} +
+ ); + const style = e.type === "lesson" ? "" : "ring-1 ring-amber-200"; + return unlocked ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+

+ 方块带书图标的是周测/月测;灰锁需先通过前一课 +

+

+ 级别:{[...new Set(SYLLABUS.map((e) => e.cefr))].map((c) => ( + + {c} + + ))} +

+
+ ); +} diff --git a/src/app/learn/practice.tsx b/src/app/learn/practice.tsx new file mode 100644 index 0000000..b5ba343 --- /dev/null +++ b/src/app/learn/practice.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { QuizQ } from "@/content/grammar/types"; + +// 课内随练:点选后立即显示对错与解析,不计分 +export function Practice({ items }: { items: QuizQ[] }) { + const [picked, setPicked] = useState<(number | null)[]>(items.map(() => null)); + + return ( +
+

练一练

+ {items.map((q, qi) => { + const p = picked[qi]; + const revealed = p !== null; + return ( +
+

+ {qi + 1}. {q.q} +

+
+ {q.options.map((opt, oi) => { + const isRight = revealed && oi === q.answer; + const isWrong = revealed && oi === p && oi !== q.answer; + return ( + + ); + })} +
+ {revealed && ( +

+ 解析:{q.explain} +

+ )} +
+ ); + })} + +
+ ); +} diff --git a/src/app/learn/quiz-form.tsx b/src/app/learn/quiz-form.tsx new file mode 100644 index 0000000..47da768 --- /dev/null +++ b/src/app/learn/quiz-form.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useActionState, useState } from "react"; +import Link from "next/link"; +import { CheckCircle2, XCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { QuizQ } from "@/content/grammar/types"; +import { submitQuiz, type QuizResult } from "./actions"; + +// 计分测验:选中后提交,服务端判分;达标才算通过并解锁下一课 +export function QuizForm({ + seq, + quiz, + nextSeq, + alreadyPassed, +}: { + seq: number; + quiz: QuizQ[]; + nextSeq: number | null; + alreadyPassed: boolean; +}) { + const [state, formAction, pending] = useActionState(submitQuiz, null); + const [picked, setPicked] = useState<(number | null)[]>(quiz.map(() => null)); + const result = state && "score" in state ? state : null; + const error = state && "error" in state ? state.error : null; + const allAnswered = picked.every((p) => p !== null); + + return ( +
+

+ {alreadyPassed ? "再测一次" : "课后测验"} + + 满分通过线 80%,达标即完成本课并自动打卡 + +

+ +
+ + {quiz.map((q, qi) => ( +
+

+ {qi + 1}. {q.q} +

+
+ {q.options.map((opt, oi) => { + const sel = picked[qi] === oi; + const isRight = result && oi === q.answer; + const isWrong = result && sel && oi !== q.answer; + return ( + + ); + })} +
+ {result && ( +

解析:{q.explain}

+ )} +
+ ))} + + {!result && ( + + )} + {error &&

{error}

} +
+ + {result && ( +
+

{result.score} 分

+

+ 答对 {result.correct} / {result.total} + {result.passed ? " · 通过 🎉" : " · 再复习一遍吧"} +

+
+ {!result.passed && ( + + )} + {result.passed && nextSeq && ( + + + + )} + {result.passed && !nextSeq && ( + + + + )} +
+
+ )} +
+ ); +} diff --git a/src/app/learn/section-block.tsx b/src/app/learn/section-block.tsx new file mode 100644 index 0000000..bc2ae73 --- /dev/null +++ b/src/app/learn/section-block.tsx @@ -0,0 +1,69 @@ +import { renderBold } from "@/lib/md"; +import type { Section } from "@/content/grammar/types"; + +// 渲染一个讲解小节:英文讲解 + 中文要点 + 例句 + 结构表 + 易错提示 +export function SectionBlock({ s }: { s: Section }) { + return ( +
+
+

{s.heading}

+

{s.zh}

+
+

+ {renderBold(s.body).map((part, i) => + typeof part === "string" ? ( + {part} + ) : ( + + {part.b} + + ), + )} +

+ + {s.structure && ( +
+ + + + {s.structure.cols.map((c) => ( + + ))} + + + + {s.structure.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {c} +
+ {cell} +
+
+ )} + + {s.examples && ( +
    + {s.examples.map((ex, i) => ( +
  • + › + {ex} +
  • + ))} +
+ )} + + {s.note && ( +

+ 💡 {s.note} +

+ )} +
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d55fbb7..729e276 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,9 +2,11 @@ import { useActionState } from "react"; import Link from "next/link"; +import { GraduationCap } from "lucide-react"; +import { BackgroundBlobs } from "@/components/background-blobs"; import { login } from "./actions"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,10 +14,16 @@ export default function LoginPage() { const [state, formAction, pending] = useActionState(login, null); return ( -
- - - SuperJump +
+ + + +
+ +
+

+ SuperJump +

家长登录

@@ -52,7 +60,7 @@ export default function LoginPage() { 孩子的设备第一次使用?{" "} 去输入激活码 diff --git a/src/app/parent/actions.ts b/src/app/parent/actions.ts index 2dc92e8..631e61d 100644 --- a/src/app/parent/actions.ts +++ b/src/app/parent/actions.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema"; +import { SUBJECT_VALUES } from "@/db/schema"; import { todayStr } from "@/lib/dates"; import { getSessionUser } from "@/lib/auth"; @@ -76,6 +77,7 @@ export async function makeupTask(instanceId: number, done: boolean) { const templateSchema = z.object({ childId: z.coerce.number().int().positive(), dow: z.coerce.number().int().min(0).max(6), + subject: z.enum(SUBJECT_VALUES).catch("other"), title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"), requiresPhoto: z.boolean(), }); @@ -97,6 +99,7 @@ export async function addTemplate(formData: FormData) { const parsed = templateSchema.safeParse({ childId: formData.get("childId"), dow: formData.get("dow"), + subject: formData.get("subject"), title: formData.get("title"), requiresPhoto: formData.get("requiresPhoto") === "on", }); @@ -116,6 +119,7 @@ export async function addTemplate(formData: FormData) { await db.insert(taskTemplates).values({ childUserId: parsed.data.childId, dow: parsed.data.dow, + subject: parsed.data.subject, title: parsed.data.title, requiresPhoto: parsed.data.requiresPhoto, sortOrder: maxRow.maxSort + 1, diff --git a/src/app/parent/dashboard.tsx b/src/app/parent/dashboard.tsx index fd85d83..9bb5e42 100644 --- a/src/app/parent/dashboard.tsx +++ b/src/app/parent/dashboard.tsx @@ -1,6 +1,7 @@ "use client"; import { logout } from "@/app/login/actions"; +import { BackgroundBlobs } from "@/components/background-blobs"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { DevicesTab } from "./devices-tab"; @@ -13,10 +14,11 @@ export type OverviewData = { name: string; streak: number; today: { done: number; total: number }; + grammar: { done: number; total: number }; week: { date: string; isToday: boolean; - tasks: { id: number; title: string; status: "pending" | "done"; isMakeup: boolean }[]; + tasks: { id: number; title: string; subject: string; status: "pending" | "done"; isMakeup: boolean }[]; }[]; }[]; }; @@ -25,7 +27,7 @@ export type TemplatesData = { kids: { id: number; name: string; - items: { id: number; dow: number; title: string; requiresPhoto: boolean }[]; + items: { id: number; dow: number; title: string; subject: string; requiresPhoto: boolean }[]; }[]; }; @@ -52,9 +54,12 @@ export function ParentDashboard({ const kids = overview.kids.map((k) => ({ id: k.id, name: k.name })); return ( -
+
+
-

家长端

+

+ 家长中心 +

- - 今日概览 - 任务模板 - 设备管理 + + + 今日概览 + + + 任务模板 + + + 设备管理 + diff --git a/src/app/parent/overview-tab.tsx b/src/app/parent/overview-tab.tsx index a3200b0..8333a45 100644 --- a/src/app/parent/overview-tab.tsx +++ b/src/app/parent/overview-tab.tsx @@ -5,6 +5,8 @@ import { Flame } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { subjectMeta } from "@/lib/subjects"; import { makeupTask } from "./actions"; import type { OverviewData } from "./dashboard"; @@ -35,6 +37,20 @@ export function OverviewTab({ data }: { data: OverviewData }) {

今日完成 {kid.today.done} / {kid.today.total} 项

+
+ 英语语法 +
+
+
+ + {kid.grammar.done}/{kid.grammar.total} + +
{kid.week.map((day) => (
@@ -49,6 +65,7 @@ export function OverviewTab({ data }: { data: OverviewData }) { {day.tasks.map((t) => ( + {t.title} {t.isMakeup && " · 补"} diff --git a/src/app/parent/page.tsx b/src/app/parent/page.tsx index 3a3ebfc..778a4f4 100644 --- a/src/app/parent/page.tsx +++ b/src/app/parent/page.tsx @@ -8,6 +8,8 @@ import { SESSION_COOKIE, getSessionUser } from "@/lib/auth"; import { daysAgo, todayStr } from "@/lib/dates"; import { ensureDay } from "@/lib/tasks"; import { streakOf } from "@/lib/streak"; +import { passedSet } from "@/lib/grammar"; +import { TOTAL } from "@/content/grammar"; import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard"; const SHANGHAI = { timeZone: "Asia/Shanghai" } as const; @@ -32,13 +34,15 @@ export default async function ParentPage() { const overview: OverviewData = { kids: [] }; for (const kid of kids) { await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照 - const [streak, rows] = await Promise.all([ + const [streak, gDone, rows] = await Promise.all([ streakOf(kid.id), + passedSet(kid.id).then((s) => s.size), db .select({ id: taskInstances.id, date: taskInstances.date, title: taskInstances.title, + subject: taskInstances.subject, status: taskInstances.status, isMakeup: taskInstances.isMakeup, sortOrder: taskInstances.sortOrder, @@ -63,6 +67,7 @@ export default async function ParentPage() { total: todayTasks.length, done: todayTasks.filter((t) => t.status === "done").length, }, + grammar: { done: gDone, total: TOTAL }, week, }); } @@ -85,6 +90,7 @@ export default async function ParentPage() { id: t.id, dow: t.dow, title: t.title, + subject: t.subject, requiresPhoto: t.requiresPhoto, })), })), diff --git a/src/app/parent/templates-tab.tsx b/src/app/parent/templates-tab.tsx index 2719b1a..c4eca0d 100644 --- a/src/app/parent/templates-tab.tsx +++ b/src/app/parent/templates-tab.tsx @@ -7,6 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects"; import { addTemplate, deleteTemplate } from "./actions"; import type { TemplatesData } from "./dashboard"; @@ -32,6 +34,7 @@ function KidTemplateCard({ kid: TemplatesData["kids"][number]; }) { const [dow, setDow] = useState(1); + const [subject, setSubject] = useState("other"); const [title, setTitle] = useState(""); const [requiresPhoto, setRequiresPhoto] = useState(false); const [error, setError] = useState(""); @@ -43,6 +46,7 @@ function KidTemplateCard({ const fd = new FormData(); fd.set("childId", String(kid.id)); fd.set("dow", String(dow)); + fd.set("subject", subject); fd.set("title", title); if (requiresPhoto) fd.set("requiresPhoto", "on"); startTransition(async () => { @@ -74,23 +78,32 @@ function KidTemplateCard({ {items.length === 0 && ( 休息日(自动保持连击) )} - {items.map((item) => ( - - {item.title} - {item.requiresPhoto && " · 拍照"} - - - ))} + + {sm.label} + {item.title} + {item.requiresPhoto && · 拍照} + + + ); + })}
); @@ -98,6 +111,20 @@ function KidTemplateCard({
+
+ + +