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");