feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

@@ -0,0 +1,186 @@
-- 学生从「账号」变成「名单记录」:新建 Student 表,个人数据外键改指向它
--
-- 为什么要有这一版:
-- 在此之前学生是 `User` 表里 role='STUDENT' 的行 —— 与教师登录名共用同一个全局唯一
-- 命名空间、靠 passwordHash='' 占位、靠"登录时先判 role 再 403"来阻止登录。
-- 「学生不是账号」这条不变量只靠约定维持,任何一处漏判都会让一个学生变成可登录账号。
-- 本迁移把它变成**结构性保证**:学生只存在于 Student 表,那里没有 passwordHash、没有 role。
--
-- 语义变化:
-- 1) `User` 收缩为纯账号表(ADMIN / TEACHER / CLASS_DEVICE),去掉 classId 与 role='STUDENT';
-- 2) `Student.studentNo`(学号)= 原 `User.username`,是学生唯一标识与查询键;
-- 3) `Grade.userId` / `HomeworkStatus.userId` / `NotificationRead.userId` → `studentId`,
-- 指向 Student(值就是原来那个 User.id,因此历史数据一条不丢);
-- 4) 删除 `Enrollment`(`Student.classId` 已是唯一归属,两处双写必然漂移)
-- 与 `ClassTeacher`(没有科目的协作关系;任课关系的唯一来源改为 `Course.teacherId`);
-- 5) `User.phone`(教师联系方式)。
--
-- 数据保全:
-- - 学生行按 id 原样搬进 Student,因此 Grade / HomeworkStatus / NotificationRead 里
-- 旧 `userId` 直接就是新的 `studentId`,无需任何映射表;
-- - 三张个人数据表回填时用 `IN (SELECT id FROM Student)` 兜底:历史上若有教师/管理员
-- 误留下个人记录(例如教师给自己勾过作业完成),这些行会被丢弃而不是撞外键失败;
-- - 成绩的 `level` 按旧口径(得分率)回填,保持升级后界面显示不变。
-- DropIndex
DROP INDEX "ClassTeacher_classId_teacherId_key";
DROP INDEX "ClassTeacher_teacherId_idx";
DROP INDEX "Enrollment_userId_classId_key";
DROP INDEX "Enrollment_classId_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "ClassTeacher";
DROP TABLE "Enrollment";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- 把原「学生账号」整行搬成名单记录:id 保持不变,这样下面三张表的 userId 可以直接当 studentId 用。
INSERT INTO "Student" ("id", "studentNo", "name", "classId", "gender", "guardianPhone", "status", "createdAt", "updatedAt")
SELECT "id", "username", "name", "classId", '', '', 'active', "createdAt", "updatedAt"
FROM "User"
WHERE "role" = 'STUDENT';
CREATE TABLE "StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE TABLE "new_Grade" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT,
"studentId" TEXT NOT NULL,
"examName" TEXT NOT NULL,
"score" REAL NOT NULL,
"totalScore" REAL NOT NULL DEFAULT 100,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Grade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Grade_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Grade" ("classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "studentId", "totalScore", "updatedAt")
SELECT "classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "userId", "totalScore", "updatedAt"
FROM "Grade"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "Grade";
ALTER TABLE "new_Grade" RENAME TO "Grade";
CREATE INDEX "Grade_studentId_publishedAt_idx" ON "Grade"("studentId", "publishedAt");
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
CREATE TABLE "new_HomeworkStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"homeworkId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "HomeworkStatus_homeworkId_fkey" FOREIGN KEY ("homeworkId") REFERENCES "Homework" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "HomeworkStatus_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_HomeworkStatus" ("completed", "homeworkId", "id", "studentId", "updatedAt")
SELECT "completed", "homeworkId", "id", "userId", "updatedAt"
FROM "HomeworkStatus"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "HomeworkStatus";
ALTER TABLE "new_HomeworkStatus" RENAME TO "HomeworkStatus";
CREATE INDEX "HomeworkStatus_studentId_idx" ON "HomeworkStatus"("studentId");
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_studentId_key" ON "HomeworkStatus"("homeworkId", "studentId");
CREATE TABLE "new_NotificationRead" (
"id" TEXT NOT NULL PRIMARY KEY,
"notificationId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"readAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationRead_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "NotificationRead_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_NotificationRead" ("id", "notificationId", "readAt", "studentId")
SELECT "id", "notificationId", "readAt", "userId"
FROM "NotificationRead"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "NotificationRead";
ALTER TABLE "new_NotificationRead" RENAME TO "NotificationRead";
CREATE INDEX "NotificationRead_studentId_idx" ON "NotificationRead"("studentId");
CREATE UNIQUE INDEX "NotificationRead_notificationId_studentId_key" ON "NotificationRead"("notificationId", "studentId");
-- User 收缩成纯账号表:只留 ADMIN / TEACHER(+ 未来的 CLASS_DEVICE),去掉 classId,加 phone。
-- 学生行在这里被丢弃 —— 它们已经在上面搬进 Student 了。
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'TEACHER',
"phone" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username")
SELECT "createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username"
FROM "User"
WHERE "role" <> 'STUDENT';
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE INDEX "User_role_idx" ON "User"("role");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId");
@@ -0,0 +1,8 @@
-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
--
-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
-- 由教师或管理员开关控制」。
--
-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,26 @@
-- 成绩等级:落到库里,教师可单个/批量手动编辑
--
-- 在此之前等级是**前端按得分率现算**的(`@classhelper/shared` 的 `gradeLevel(percent)`),
-- 教师既看不到存下来的等级,也无法手动调整 —— 而需求要求
-- 「发布成绩时可选百分制 / 等级制 A·B·C·D / 自定义等级,且可手动编辑等级(单个 + 批量)」。
--
-- `levelType`:
-- percent 百分制 —— 等级由得分率自动换算(教师仍可手改并覆盖)
-- letter 等级制 —— 直接给 A / B / C / D
-- custom 自定义等级 —— 教师填任意文本(优 / 良 / 合格 / 待提高…)
--
-- `level` 永远有值:percent 下写入换算结果,其余两种按教师指定写入。
ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent';
ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT '';
-- 回填旧数据:按升级前的同一套口径(得分率 ≥90 A / ≥80 B / ≥70 C / ≥60 D / 其余 E)算,
-- 保证升级前后界面上看到的等级完全一致。总分非法(≤0)一律落到 E。
UPDATE "Grade"
SET "level" = CASE
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 90 THEN 'A'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 80 THEN 'B'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 70 THEN 'C'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 60 THEN 'D'
ELSE 'E'
END
WHERE "level" = '';
@@ -0,0 +1,15 @@
-- ClassHelper 班级端心跳时间:把「在线判定」和「最后带状态的上报」拆成两个字段
--
-- 原先只有一个 `lastSeenAt`(每次上报都刷)。但同一台设备的上报其实有两种:
-- - **心跳**:插件每隔 60 秒打一次,只表明"我还在线";
-- - **状态上报**:带运行状态 / 课表,表明"我拿到了新东西"。
-- 合成一个字段后,教师网页无法区分「设备在线但一直没有新状态」和「设备整个失联」。
--
-- 于是:
-- `lastHeartbeatAt` 每次上报都刷新 —— **在线判定看它**(60 秒窗口,见 shared 的
-- `CLASS_HELPER_ONLINE_WINDOW_MS`);
-- `lastSeenAt` 只在带运行状态的上报时刷新 —— 展示为「最后在线时间」。
--
-- 回填:老库只有 lastSeenAt,就把它当作心跳时间的初值(否则所有设备升级后都会显示"从未心跳")。
ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME;
UPDATE "IntegrationDevice" SET "lastHeartbeatAt" = "lastSeenAt" WHERE "lastHeartbeatAt" IS NULL;
@@ -0,0 +1,145 @@
-- 毕业归档:届别档案 + 班级/学生的归档标记
--
-- 背景(需求):管理员要有一个「归档」页,按**年度**记录毕业的班级与学生、当年转出的学生,
-- 以及毕业班级发过的作业与通知;**未毕业而升级的班级不归档、数据沿用**。
--
-- 关键设计:**归档是打标记 + 只读,不删任何数据**。
-- `ArchivedYear` 是届别档案(入学年份 + 毕业年份 + 归档时的统计快照);
-- `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
-- 毕业班级的作业与通知原样留在库里(`Homework.classId` / `Notification.classId` 不变),
-- 归档详情页只读地查它们 —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
--
-- 升级 ≠ 归档:高一 → 高二 只改 `Class.grade`,班级记录、学生名单、作业成绩全部沿用,
-- 不碰 archivedYearId。因此归档只能由管理员在归档页显式执行。
--
-- 班级称呼改成「XXXX级X班」:新列 `enrollmentYear`(入学年份)+ `classIndex`(班号),
-- `Class.name` 由服务端按二者生成。下面会对**已有班级**做一次尽力而为的推断回填。
-- CreateTable
CREATE TABLE "ArchivedYear" (
"id" TEXT NOT NULL PRIMARY KEY,
"enrollmentYear" INTEGER NOT NULL,
"graduationYear" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"classCount" INTEGER NOT NULL DEFAULT 0,
"studentCount" INTEGER NOT NULL DEFAULT 0,
"transferredCount" INTEGER NOT NULL DEFAULT 0,
"homeworkCount" INTEGER NOT NULL DEFAULT 0,
"notificationCount" INTEGER NOT NULL DEFAULT 0,
"archivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"enrollmentYear" INTEGER,
"classIndex" INTEGER,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true,
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE INDEX "Class_enrollmentYear_idx" ON "Class"("enrollmentYear");
CREATE INDEX "Class_archivedYearId_idx" ON "Class"("archivedYearId");
-- 已有班级的届别推断(尽力而为):
-- 入学年份 = 当前学年起始年 − 该班现在的年级偏移。学年在 9 月切换,
-- 所以 9 月及以后算今年起始、之前算去年起始;高一偏移 0、高二 1、高三 2,
-- 认不出的年级(初中 / 四年制等)按高三处理(宁可偏早,管理员可在班级管理页改正)。
UPDATE "Class"
SET "enrollmentYear" = (
CASE WHEN CAST(strftime('%m', 'now') AS INTEGER) >= 9
THEN CAST(strftime('%Y', 'now') AS INTEGER)
ELSE CAST(strftime('%Y', 'now') AS INTEGER) - 1 END
) - CASE "grade" WHEN '高二' THEN 1 WHEN '高三' THEN 2 ELSE 0 END
WHERE "enrollmentYear" IS NULL;
-- 班号按「同一届内按创建先后」编号(老名字里的括号数字没法跨库 reliably 解析,这是等价的近似)。
UPDATE "Class"
SET "classIndex" = (
SELECT COUNT(*) FROM "Class" c2
WHERE c2."enrollmentYear" = "Class"."enrollmentYear"
AND (c2."createdAt" < "Class"."createdAt"
OR (c2."createdAt" = "Class"."createdAt" AND c2."id" <= "Class"."id"))
)
WHERE "classIndex" IS NULL;
-- 按新口径重写称呼:「2026级1班」。管理员若觉得推断不对,可在班级管理页改入学年份/班号。
UPDATE "Class"
SET "name" = CAST("enrollmentYear" AS TEXT) || '级' || CAST("classIndex" AS TEXT) || '班'
WHERE "enrollmentYear" IS NOT NULL AND "classIndex" IS NOT NULL;
CREATE TABLE "new_Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"transferredAt" DATETIME,
"transferNote" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Student_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Student" ("classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt") SELECT "classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt" FROM "Student";
DROP TABLE "Student";
ALTER TABLE "new_Student" RENAME TO "Student";
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "Student_status_idx" ON "Student"("status");
CREATE INDEX "Student_archivedYearId_idx" ON "Student"("archivedYearId");
CREATE TABLE "new_StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_StudentClassTransfer" ("createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName") SELECT "createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName" FROM "StudentClassTransfer";
DROP TABLE "StudentClassTransfer";
ALTER TABLE "new_StudentClassTransfer" RENAME TO "StudentClassTransfer";
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "ArchivedYear_enrollmentYear_key" ON "ArchivedYear"("enrollmentYear");
@@ -0,0 +1,79 @@
-- 学期周次配置 + 课表同步方向反转
--
-- 一、学期周次(需求:「第几周的周次数据可以由 admin 修改」)
-- `TermWeek` 让管理员为**每一个教学周**指定实际的起止日期(调休、周末补课、错峰开学),
-- `classId = ''` 表示全校默认;没配到的周次仍按学期开始日期线性兜底。
-- `Class.termStartDate` 允许某个班单独覆盖全局的开学日期(它随镜像课表下发给教室的 ClassIsland)。
--
-- 二、课表同步方向反转(需求:「课表不再自动从教室的 ClassIsland 获取,改为手动获取;
-- ClassHelper 班级端的课表由服务端自动下发」)
-- `syncScheduleToServer` 默认 true → **false**:插件不再自动把教室的课表推上来
-- `mirrorScheduleToClassIsland` 默认 false → **true**:服务端的课表自动下发给教室
-- `requestScheduleReport` 新增:Web 端点「从教室机器获取课表」时置位,
-- 插件下次心跳看到就把当前课表推上来并清除标记
--
-- 下面会把**已有设备**也改成新方向:这正是需求要的行为变化(否则升级后教室里
-- 仍然在自动回传,老师手排的课会被教室的旧课表悄悄覆盖)。设备管理员可以逐台改回去。
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "termStartDate" TEXT;
-- CreateTable
CREATE TABLE "TermWeek" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL DEFAULT '',
"weekNumber" INTEGER NOT NULL,
"startDate" TEXT NOT NULL,
"endDate" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_IntegrationDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"deviceKey" TEXT NOT NULL,
"mode" TEXT NOT NULL DEFAULT 'plugin',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"tokenHash" TEXT NOT NULL,
"tokenHint" TEXT NOT NULL,
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT false,
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT true,
"requestScheduleReport" BOOLEAN NOT NULL DEFAULT false,
"classIslandVersion" TEXT,
"pluginVersion" TEXT,
"lastSeenAt" DATETIME,
"lastHeartbeatAt" DATETIME,
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
"currentSubject" TEXT,
"currentTimeState" TEXT,
"currentPeriodStart" TEXT,
"currentPeriodEnd" TEXT,
"nextSubject" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_IntegrationDevice" ("classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt") SELECT "classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt" FROM "IntegrationDevice";
-- 已有设备切到新方向:不再自动回传课表,改为由服务端自动下发。
UPDATE "new_IntegrationDevice" SET "syncScheduleToServer" = false, "mirrorScheduleToClassIsland" = true;
DROP TABLE "IntegrationDevice";
ALTER TABLE "new_IntegrationDevice" RENAME TO "IntegrationDevice";
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "TermWeek_classId_idx" ON "TermWeek"("classId");
-- CreateIndex
CREATE UNIQUE INDEX "TermWeek_classId_weekNumber_key" ON "TermWeek"("classId", "weekNumber");
+232 -71
View File
@@ -1,4 +1,4 @@
// 班级小助手 - 数据模型
// ClassHelper - 数据模型
//
// 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
@@ -6,10 +6,22 @@
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
//
// **学生不是账号**(2026-10-06 重构的核心不变量):
// `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号,
// 没有 passwordHash、没有 role、没有登录字段 —— 「学生不能登录」由表结构保证,
// 不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
// `User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
//
// **任课关系唯一来源是 `Course`**:`Course(classId, name, teacherId)` 就是
// 「班级 + 科目 + 教师」这条关系本身。班主任另由 `Class.teacherId` 表示(每班 1 人)。
// 历史上那张没有科目的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
// 同一教师可以在同一个班同时是班主任和某科科任老师:两处都指向同一个 `User`,
// 不重复建号,权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
generator client {
provider = "prisma-client"
@@ -20,101 +32,229 @@ datasource db {
provider = "sqlite"
}
/// 用户:教师 / 管理员 / 学生统一存储
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
passwordHash String
name String
role String @default("STUDENT")
classId String?
/// ADMIN | TEACHER | CLASS_DEVICE
role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 学生的主班级(一个学生属于一个主班级)
class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
// 教师创建(负责)的班级
ownedClasses Class[] @relation("ClassOwner")
// 教师被分配协作的班级
classAssignments ClassTeacher[]
/// 教师担任班主任的班级(每班 1 人)
ownedClasses Class[] @relation("ClassOwner")
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
courses Course[]
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
enrollments Enrollment[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
@@index([classId])
@@index([role])
}
/// 学生:**只是班级名单里的一条记录**,没有账号、没有密码、不能登录。
///
/// `studentNo`(学号)是学生在系统里的唯一标识与查询键:
/// 名单、成绩、作业提交、导入模板、导出、查询显示一律用它。
/// 历史作业与成绩挂在 `Homework.classId` / `Grade.classId` 上,
/// 因此**调班只改本表的 classId,历史数据保留原归属**。
model Student {
id String @id @default(cuid())
/// 学号:学生唯一标识(全校唯一)
studentNo String @unique
name String
/// 当前班级;null = 未分班(转出/毕业后班级归属仍保留,靠 status 区分)
classId String?
/// 性别:'' | 'MALE' | 'FEMALE'(字符串存,跨库通用)
gender String @default("")
/// 家长手机号(可空)
guardianPhone String @default("")
/// active = 在读 / graduated = 已毕业 / transferred = 已转出 / inactive = 停用
status String @default("active")
/// 毕业归档所属届别(`ArchivedYear.id`);null = 未归档
archivedYearId String?
archivedAt DateTime?
/// 转出时间(status='transferred' 时写入)
transferredAt DateTime?
/// 转出去向 / 备注
transferNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
transfers StudentClassTransfer[]
@@index([classId])
@@index([name])
@@index([status])
@@index([archivedYearId])
}
/// 调班 / 转出历史:原班级 / 新班级 / 操作人 / 时间。
///
/// 班级 id 与班级名**都存快照且不建外键** —— 班级被删除后这条历史仍要能读出来,
/// 而外键的 Cascade 会把历史一起带走。
model StudentClassTransfer {
id String @id @default(cuid())
studentId String
/// 学号快照(学生被删除后历史仍可读)
studentNo String
studentName String
fromClassId String?
fromClassName String @default("")
toClassId String?
toClassName String @default("")
/// 操作人(User.id);账号被删后留空,姓名快照保留
operatorId String?
operatorName String @default("")
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
mode String @default("single")
note String @default("")
createdAt DateTime @default(now())
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, createdAt])
@@index([createdAt])
}
/// 届别档案:一次「毕业归档」的留痕(管理员在「归档管理」页执行)。
///
/// 归档是**打标记 + 只读**,不删任何数据:
/// - `Class.archivedYearId` / `Student.archivedYearId` 指向本届档案,
/// 常规的班级/学生列表按它过滤掉归档项;
/// - 毕业班级的作业与通知原样留在库里,通过归档详情只读查看
/// (需求「记录每个年度毕业班级的消息和作业」就是这么满足的)。
///
/// 「未毕业而升级的班级不归档」:升级只改 `Class.grade`(高一→高二→高三),
/// 班级记录、学生名单、作业成绩全部沿用,不碰 `archivedYearId`。
model ArchivedYear {
id String @id @default(cuid())
/// 届别 = 入学年份(2026 表示 2026 级,班级称呼因此是「2026级1班」)
enrollmentYear Int @unique
/// 毕业年份(= 入学年份 + 学制年数)
graduationYear Int
/// 届别名,如「2026 级」
name String
note String @default("")
operatorId String?
operatorName String @default("")
/// 归档时的统计快照(给人看的一行汇总;明细实时查班级与学生)
classCount Int @default(0)
studentCount Int @default(0)
transferredCount Int @default(0)
homeworkCount Int @default(0)
notificationCount Int @default(0)
archivedAt DateTime @default(now())
classes Class[]
students Student[]
}
/// 学期周次区间:管理员可以为**每一个教学周**指定实际的起止日期。
///
/// 为什么不能只靠"学期开始日期 + 每周七天"线性推算:法定节假日调休、周末补课、
/// 错峰开学都会让某一周变长或变短(甚至整周不上课),线性推算出来的"第几周"
/// 会与教室里的实际情况整体错位一周以上 —— 而课表、作业、成绩都按周次组织。
///
/// 因此支持逐周设置:
/// `classId = ''` 表示**全校默认**(没有为某个班单独配置时用它);
/// 有班级维度的行则覆盖默认。
/// 没配置的周次仍按学期开始日期线性兜底(见 shared 的 `resolveCurrentWeek`)。
model TermWeek {
id String @id @default(cuid())
/// 归属班级;**空串 = 全校默认**(用空串而不是 null,唯一约束才在两个库上都生效)
classId String @default("")
/// 教学周次(1 起)
weekNumber Int
/// 该周的起始日期(YYYY-MM-DD,本地日期)
startDate String
/// 该周的结束日期(YYYY-MM-DD)
endDate String
note String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([classId, weekNumber])
@@index([classId])
}
/// 班级
///
/// **班级称呼是「XXXX级X班」**(入学年份 + 班号),不是「高一(1)班」:
/// 后者每年都要改,前者一路跟着这届学生到毕业。`name` 由服务端按
/// `enrollmentYear` + `classIndex` 生成(见 shared 的 `formatClassName()`),
/// 保留成列是为了兼容既有引用,不在别处手写。
///
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
/// 当前年级(高一 / 高二 / 高三),学年升级时更新
grade String
/// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
enrollmentYear Int?
/// 班号(同一届内的序号)
classIndex Int?
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
teacherId String
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 通知显示位置(由教室的班级客户端在设置页里选):
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
termStartDate String?
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true)
/// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
archivedYearId String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
students User[] @relation("ClassStudents")
teachers ClassTeacher[]
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
students Student[]
courses Course[]
schedules Schedule[]
homeworks Homework[]
notifications Notification[]
grades Grade[]
enrollments Enrollment[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@index([teacherId])
@@index([enrollmentYear])
@@index([archivedYearId])
}
/// 教师-班级 分配关系(一个教师可管理多个班级)
model ClassTeacher {
id String @id @default(cuid())
classId String
teacherId String
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade)
@@unique([classId, teacherId])
@@index([teacherId])
}
/// 学生-班级 选课/归属记录(与 User.classId 保持同步,支持未来多班)
model Enrollment {
id String @id @default(cuid())
userId String
classId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([userId, classId])
@@index([classId])
}
/// 课程
/// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
///
/// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
/// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
/// 就是查 `Course.teacherId`—— 不需要第二张任课表。
model Course {
id String @id @default(cuid())
name String
/// 该班该科的任课老师
teacherId String
classId String
createdAt DateTime @default(now())
@@ -127,6 +267,7 @@ model Course {
@@unique([classId, name])
@@index([classId])
@@index([teacherId])
}
/// 课表条目
@@ -190,19 +331,19 @@ model Homework {
@@index([dueAt])
}
/// 学生作业完成状态
/// 学生作业完成状态(按**学生名单记录**记,学生没有账号)
model HomeworkStatus {
id String @id @default(cuid())
homeworkId String
userId String
studentId String
completed Boolean @default(false)
updatedAt DateTime @updatedAt
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([homeworkId, userId])
@@index([userId])
@@unique([homeworkId, studentId])
@@index([studentId])
}
/// 通知
@@ -227,14 +368,14 @@ model Notification {
model NotificationRead {
id String @id @default(cuid())
notificationId String
userId String
studentId String
readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([notificationId, userId])
@@index([userId])
@@unique([notificationId, studentId])
@@index([studentId])
}
@@ -256,34 +397,44 @@ model TimeLayout {
}
/// 成绩
///
/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
model Grade {
id String @id @default(cuid())
classId String
courseId String?
userId String
/// 学生(名单记录)id
studentId String
examName String
score Float
totalScore Float @default(100)
/// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
levelType String @default("percent")
/// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
level String @default("")
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
student User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([userId, publishedAt])
@@index([studentId, publishedAt])
@@index([classId, examName])
@@index([courseId])
}
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
model IntegrationDevice {
id String @id @default(cuid())
classId String
@@ -300,12 +451,22 @@ model IntegrationDevice {
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
syncScheduleToServer Boolean @default(true)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
mirrorScheduleToClassIsland Boolean @default(false)
///
/// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime?
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
lastHeartbeatAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
+208 -42
View File
@@ -2,15 +2,25 @@
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
* 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
* 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
*
* 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
* student01..student15 / student123
* teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
* teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
* (学生只有名单、没有账号:ClassHelper 班级端统一用班级码 + 班级密码登录)
*/
import { SUBJECT_CATALOG, dayKeyLocal } from '@classhelper/shared';
import {
SUBJECT_CATALOG,
dayKeyLocal,
formatClassName,
formatYearLabel,
gradeLevel,
gradePercent,
} from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
@@ -67,11 +77,13 @@ async function resetDatabase(): Promise<void> {
await prisma.notification.deleteMany();
await prisma.schedule.deleteMany();
await prisma.course.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.classTeacher.deleteMany();
await prisma.user.updateMany({ data: { classId: null } });
await prisma.studentClassTransfer.deleteMany();
// 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
await prisma.student.deleteMany();
await prisma.user.deleteMany();
// 班级归到届别上,所以先删班级再删届别
await prisma.class.deleteMany();
await prisma.archivedYear.deleteMany();
}
async function main(): Promise<void> {
@@ -86,11 +98,11 @@ async function main(): Promise<void> {
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
// 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
// 班级账号默认密码(学生端「班级登录」)
// 班级账号默认密码(ClassHelper 班级端「班级登录」)
hashPassword('123456'),
]);
@@ -105,19 +117,24 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
// 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
{ enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: {
name: seed.name,
name: formatClassName(seed.enrollmentYear, seed.classIndex),
grade: seed.grade,
enrollmentYear: seed.enrollmentYear,
classIndex: seed.classIndex,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
@@ -126,37 +143,41 @@ async function main(): Promise<void> {
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
// 高二(3)班:李老师作为协作教师
await prisma.classTeacher.create({
data: { classId: classes[2]!.id, teacherId: teacher2.id },
});
// ---------------------------------------------------------------- 学生
// ---------------------------------------------------------------- 学生(只是名单,不是账号)
const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
const classIndex = Math.floor(index / 5);
const targetClass = classes[classIndex] ?? classes[0]!;
const student = await prisma.user.create({
const classSlot = Math.floor(index / 5);
const targetClass = classes[classSlot] ?? classes[0]!;
// 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
const student = await prisma.student.create({
data: {
username: `student${String(index + 1).padStart(2, '0')}`,
studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
passwordHash: '',
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
},
});
students.push(student);
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
}
// ---------------------------------------------------------------- 课程
// ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
//
// 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
// 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
// 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
const OTHER_TEACHER_SUBJECT = '语文';
const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) {
const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
const created = [];
for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId },
data: {
name: courseName,
classId: targetClass.id,
teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId,
},
});
created.push({ id: course.id, name: course.name });
}
@@ -280,7 +301,7 @@ async function main(): Promise<void> {
const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue;
await prisma.homeworkStatus.create({
data: { homeworkId: homework.id, userId: student.id, completed },
data: { homeworkId: homework.id, studentId: student.id, completed },
});
}
}
@@ -331,7 +352,7 @@ async function main(): Promise<void> {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) {
await prisma.notificationRead.create({
data: { notificationId: notification.id, userId: student.id },
data: { notificationId: notification.id, studentId: student.id },
});
}
}
@@ -339,23 +360,46 @@ async function main(): Promise<void> {
}
// ---------------------------------------------------------------- 成绩
//
// 三种等级口径各演示一门,方便在界面上看出差别:
// 数学(percent)—— 按得分率自动换算 A~E
// 语文(custom) —— 自定义等级文本
// 英语(letter) —— A / B / C / D
const examNames = ['第一次月考', '期中考试'];
const customLevels = ['优', '良', '合格'] as const;
const letterLevels = ['A', 'B', 'C', 'D'] as const;
const levelPlan = [
{ type: 'percent' as const },
{ type: 'custom' as const },
{ type: 'letter' as const },
];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? [];
for (const [examIndex, examName] of examNames.entries()) {
for (const course of classCourses.slice(0, 3)) {
for (const [courseIndex, course] of classCourses.slice(0, 3).entries()) {
const plan = levelPlan[courseIndex % levelPlan.length]!;
for (const student of classmates) {
const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
const percent = gradePercent(score, 100);
const level =
plan.type === 'percent'
? gradeLevel(percent)
: plan.type === 'letter'
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D')
: (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格');
await prisma.grade.create({
data: {
classId: targetClass.id,
courseId: course.id,
userId: student.id,
studentId: student.id,
examName,
score,
totalScore: 100,
levelType: plan.type,
level,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
@@ -364,24 +408,146 @@ async function main(): Promise<void> {
}
}
// ---------------------------------------------------------------- 调班 / 转出历史
//
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
await prisma.student.update({
where: { id: movedStudent.id },
data: { classId: classes[0]!.id },
});
await prisma.studentClassTransfer.create({
data: {
studentId: movedStudent.id,
studentNo: movedStudent.studentNo,
studentName: movedStudent.name,
fromClassId: classes[0]!.id,
fromClassName: classes[0]!.name,
toClassId: classes[1]!.id,
toClassName: classes[1]!.name,
operatorId: admin.id,
operatorName: admin.name,
mode: 'batch',
note: '军训后按分班考试成绩重新均衡',
createdAt: new Date(now - 30 * day),
},
});
// 转出:学籍离开本校(不是调班),班级归属保留作历史
const transferredStudent = students[13]!; // 朱佳琪
await prisma.student.update({
where: { id: transferredStudent.id },
data: {
status: 'transferred',
transferredAt: new Date(now - 12 * day),
transferNote: '随家长工作调动转学至外地',
},
});
await prisma.studentClassTransfer.create({
data: {
studentId: transferredStudent.id,
studentNo: transferredStudent.studentNo,
studentName: transferredStudent.name,
fromClassId: transferredStudent.classId,
fromClassName: classes[2]!.name,
toClassId: null,
toClassName: '转出本校',
operatorId: admin.id,
operatorName: admin.name,
mode: 'transfer-out',
note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day),
},
});
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
//
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
const graduatedClass = await prisma.class.create({
data: {
name: formatClassName(2023, 1),
grade: '高三',
enrollmentYear: 2023,
classIndex: 1,
teacherId: teacher1.id,
code: 'G231',
passwordHash: classPasswordHash,
},
});
const archivedYear = await prisma.archivedYear.create({
data: {
enrollmentYear: 2023,
graduationYear: 2023 + 3,
name: formatYearLabel(2023),
note: '演示用:2023 级已于 2026 年 6 月毕业',
operatorId: admin.id,
operatorName: admin.name,
archivedAt: new Date(now - 100 * day),
},
});
await prisma.class.update({
where: { id: graduatedClass.id },
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
});
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
for (const [index, name] of graduateNames.entries()) {
await prisma.student.create({
data: {
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
name,
classId: graduatedClass.id,
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
status: 'graduated',
archivedYearId: archivedYear.id,
archivedAt: new Date(now - 100 * day),
},
});
}
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
await prisma.homework.create({
data: {
classId: graduatedClass.id,
title: '高考前最后一轮复习',
content: '按考点清单过一遍错题本,重点看函数与数列。',
assignDate: dayKeyLocal(new Date(now - 120 * day)),
createdBy: teacher1.id,
},
});
await prisma.notification.create({
data: {
classId: graduatedClass.id,
title: '毕业典礼安排',
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
priority: 'HIGH',
createdBy: teacher1.id,
createdAt: new Date(now - 110 * day),
},
});
const counts = {
用户: await prisma.user.count(),
班级: await prisma.class.count(),
账号: await prisma.user.count(),
在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
学生: await prisma.student.count({ where: { status: 'active' } }),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.count(),
调班历史: await prisma.studentClassTransfer.count(),
归档届别: await prisma.archivedYear.count(),
};
logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name})`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
console.log('==========================================\n');
}