From 66ef878a714facb1b95e96cace017cfdd95ae2af Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 09:32:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared,prisma):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E6=97=A5=E8=AF=BE=E8=A1=A8=E4=B8=8E=E5=B2=9B=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=8F=8A=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 181 ++++++++++++ .../migration.sql | 31 ++ .../migration.sql | 8 + packages/server/prisma/schema.prisma | 116 +++++++- packages/shared/src/class-plan.ts | 88 ++++++ packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 2 + packages/shared/src/island-layout.ts | 279 ++++++++++++++++++ packages/shared/src/permissions.ts | 44 ++- packages/shared/src/types.ts | 11 + packages/shared/src/utils.ts | 8 +- 11 files changed, 758 insertions(+), 11 deletions(-) create mode 100644 packages/server/prisma/migrations/20261017120000_class_plans_and_scopes/migration.sql create mode 100644 packages/server/prisma/migrations/20261017121000_unassigned_class_owner/migration.sql create mode 100644 packages/server/prisma/migrations/20261017122000_normalize_imported_time_points/migration.sql create mode 100644 packages/shared/src/class-plan.ts create mode 100644 packages/shared/src/island-layout.ts diff --git a/packages/server/prisma/migrations/20261017120000_class_plans_and_scopes/migration.sql b/packages/server/prisma/migrations/20261017120000_class_plans_and_scopes/migration.sql new file mode 100644 index 0000000..a82db9b --- /dev/null +++ b/packages/server/prisma/migrations/20261017120000_class_plans_and_scopes/migration.sql @@ -0,0 +1,181 @@ +-- CreateTable +CREATE TABLE "TimeLayoutItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "timeLayoutId" TEXT NOT NULL, + "position" INTEGER NOT NULL, + "name" TEXT NOT NULL DEFAULT '', + "startTime" TEXT NOT NULL, + "endTime" TEXT NOT NULL, + "timeType" TEXT NOT NULL DEFAULT 'LESSON', + "isHideDefault" BOOLEAN NOT NULL DEFAULT false, + "defaultClassId" TEXT, + "breakName" TEXT NOT NULL DEFAULT '', + "actionSet" TEXT NOT NULL DEFAULT '{}', + CONSTRAINT "TimeLayoutItem_timeLayoutId_fkey" FOREIGN KEY ("timeLayoutId") REFERENCES "TimeLayout" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ClassPlanGroup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isGlobal" BOOLEAN NOT NULL DEFAULT false, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "ClassPlanGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ClassPlan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "timeLayoutId" TEXT NOT NULL, + "associatedGroup" TEXT, + "weekday" INTEGER NOT NULL DEFAULT 0, + "weekCountDiv" INTEGER NOT NULL DEFAULT 0, + "weekCountDivTotal" INTEGER NOT NULL DEFAULT 2, + "weekStart" INTEGER NOT NULL DEFAULT 1, + "weekEnd" INTEGER NOT NULL DEFAULT 60, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "isOverlay" BOOLEAN NOT NULL DEFAULT false, + "overlaySourceId" TEXT, + "overlayDate" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ClassPlan_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ClassPlan_timeLayoutId_fkey" FOREIGN KEY ("timeLayoutId") REFERENCES "TimeLayout" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ClassPlan_associatedGroup_fkey" FOREIGN KEY ("associatedGroup") REFERENCES "ClassPlanGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ClassPlanEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classPlanId" TEXT NOT NULL, + "timeLayoutItemId" TEXT NOT NULL, + "courseId" TEXT, + "isChangedClass" BOOLEAN NOT NULL DEFAULT false, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "location" TEXT, + "legacyScheduleId" TEXT, + CONSTRAINT "ClassPlanEntry_classPlanId_fkey" FOREIGN KEY ("classPlanId") REFERENCES "ClassPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ClassPlanEntry_timeLayoutItemId_fkey" FOREIGN KEY ("timeLayoutItemId") REFERENCES "TimeLayoutItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ClassPlanEntry_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ScheduledClassPlan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "date" TEXT NOT NULL, + "classPlanId" TEXT NOT NULL, + CONSTRAINT "ScheduledClassPlan_classPlanId_fkey" FOREIGN KEY ("classPlanId") REFERENCES "ClassPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ScheduledClassPlan_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PromotionRun" ( + "year" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "status" TEXT NOT NULL, + "snapshotPath" TEXT NOT NULL DEFAULT '', + "message" TEXT NOT NULL DEFAULT '', + "classCount" INTEGER NOT NULL DEFAULT 0, + "studentCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" DATETIME +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Course" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "initial" TEXT NOT NULL DEFAULT '', + "isOutDoor" BOOLEAN NOT NULL DEFAULT false, + "teacherId" TEXT NOT NULL, + "classId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Course_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Course_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Course" ("classId", "createdAt", "id", "name", "teacherId") SELECT "classId", "createdAt", "id", "name", "teacherId" FROM "Course"; +DROP TABLE "Course"; +ALTER TABLE "new_Course" RENAME TO "Course"; +CREATE INDEX "Course_classId_idx" ON "Course"("classId"); +CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId"); +CREATE UNIQUE INDEX "Course_classId_name_key" ON "Course"("classId", "name"); +CREATE TABLE "new_PermissionGroup" ( + "scope" TEXT NOT NULL DEFAULT 'SELF_CLASS', + "scopeValue" TEXT NOT NULL DEFAULT '', + "defaultRole" TEXT, + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "builtin" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_PermissionGroup" ("builtin", "createdAt", "defaultRole", "description", "id", "name", "updatedAt") SELECT "builtin", "createdAt", "defaultRole", "description", "id", "name", "updatedAt" FROM "PermissionGroup"; +DROP TABLE "PermissionGroup"; +ALTER TABLE "new_PermissionGroup" RENAME TO "PermissionGroup"; +CREATE UNIQUE INDEX "PermissionGroup_defaultRole_key" ON "PermissionGroup"("defaultRole"); +CREATE UNIQUE INDEX "PermissionGroup_name_key" ON "PermissionGroup"("name"); +CREATE TABLE "new_TimeLayout" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT, + "name" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'classisland', + "items" TEXT NOT NULL DEFAULT '[]', + "isOverlay" BOOLEAN NOT NULL DEFAULT false, + "overlaySourceId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "TimeLayout_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_TimeLayout" ("classId", "createdAt", "id", "items", "name", "source", "updatedAt") SELECT "classId", "createdAt", "id", "items", "name", "source", "updatedAt" FROM "TimeLayout"; +DROP TABLE "TimeLayout"; +ALTER TABLE "new_TimeLayout" RENAME TO "TimeLayout"; +CREATE INDEX "TimeLayout_classId_idx" ON "TimeLayout"("classId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "TimeLayoutItem_timeLayoutId_position_idx" ON "TimeLayoutItem"("timeLayoutId", "position"); + +-- CreateIndex +CREATE INDEX "ClassPlanGroup_classId_idx" ON "ClassPlanGroup"("classId"); + +-- CreateIndex +CREATE INDEX "ClassPlan_classId_weekday_idx" ON "ClassPlan"("classId", "weekday"); + +-- CreateIndex +CREATE UNIQUE INDEX "ClassPlanEntry_classPlanId_timeLayoutItemId_key" ON "ClassPlanEntry"("classPlanId", "timeLayoutItemId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScheduledClassPlan_classId_date_key" ON "ScheduledClassPlan"("classId", "date"); + + +-- 将旧行按实际教学周展开为完整的一天,避免不同行的周次范围互相遮挡。 +INSERT INTO TimeLayout (id,classId,name,source,items,createdAt,updatedAt) +SELECT 'legacy-layout-' || classId || '-' || dayOfWeek,classId,'兼容作息 ' || dayOfWeek,'manual','[]',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP FROM Schedule GROUP BY classId,dayOfWeek; +INSERT INTO TimeLayoutItem (id,timeLayoutId,position,name,startTime,endTime,timeType) +SELECT 'legacy-layout-' || classId || '-' || dayOfWeek || '-' || startTime || '-' || endTime,'legacy-layout-' || classId || '-' || dayOfWeek, +ROW_NUMBER() OVER (PARTITION BY classId,dayOfWeek ORDER BY startTime), '',startTime,endTime,'LESSON' FROM Schedule GROUP BY classId,dayOfWeek,startTime,endTime; +WITH RECURSIVE weeks(w) AS (SELECT 1 UNION ALL SELECT w+1 FROM weeks WHERE w<60) +INSERT INTO ClassPlan (id,classId,name,timeLayoutId,weekday,weekStart,weekEnd,createdAt,updatedAt) +SELECT 'legacy-plan-' || s.classId || '-' || s.dayOfWeek || '-' || w,s.classId,'兼容第 ' || w || ' 周星期 ' || s.dayOfWeek,'legacy-layout-' || s.classId || '-' || s.dayOfWeek,s.dayOfWeek,w,w,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP +FROM Schedule s CROSS JOIN weeks WHERE w BETWEEN s.weekStart AND s.weekEnd AND (s.weekParity='ALL' OR s.weekParity=CASE w%2 WHEN 1 THEN 'ODD' ELSE 'EVEN' END) GROUP BY s.classId,s.dayOfWeek,w; +INSERT INTO ClassPlanEntry (id,classPlanId,timeLayoutItemId,courseId,location,legacyScheduleId) +SELECT s.id || '-' || p.weekStart,p.id,t.id,s.courseId,s.location,s.id FROM ClassPlan p JOIN Schedule s ON s.classId=p.classId AND s.dayOfWeek=p.weekday +AND p.weekStart BETWEEN s.weekStart AND s.weekEnd AND (s.weekParity='ALL' OR s.weekParity=CASE p.weekStart%2 WHEN 1 THEN 'ODD' ELSE 'EVEN' END) +JOIN TimeLayoutItem t ON t.timeLayoutId=p.timeLayoutId AND t.startTime=s.startTime AND t.endTime=s.endTime +WHERE s.id=(SELECT MIN(s2.id) FROM Schedule s2 WHERE s2.classId=s.classId AND s2.dayOfWeek=s.dayOfWeek AND s2.startTime=s.startTime AND s2.endTime=s.endTime AND p.weekStart BETWEEN s2.weekStart AND s2.weekEnd AND (s2.weekParity='ALL' OR s2.weekParity=CASE p.weekStart%2 WHEN 1 THEN 'ODD' ELSE 'EVEN' END)); + +-- 升级现有角色默认组,新增的两个管理能力初始只给管理员。 +INSERT INTO PermissionGroupItem (id,groupId,key,createdAt) SELECT 'v14-timetable-' || id,id,'timetable:manage',CURRENT_TIMESTAMP FROM PermissionGroup WHERE defaultRole='ADMIN'; +INSERT INTO PermissionGroupItem (id,groupId,key,createdAt) SELECT 'v14-update-' || id,id,'client:update',CURRENT_TIMESTAMP FROM PermissionGroup WHERE defaultRole='ADMIN'; + +UPDATE Course SET initial=substr(name,1,1) WHERE initial=''; + +-- 仅保留旧接口返回字段;课表解析不读取它,业务启用规则仍以日课表为唯一真值。 +INSERT INTO AppSetting (key,value,createdAt,updatedAt) SELECT 'schedule.compat.' || id,json_object('weekStart',weekStart,'weekEnd',weekEnd,'weekParity',weekParity),CURRENT_TIMESTAMP,CURRENT_TIMESTAMP FROM Schedule; diff --git a/packages/server/prisma/migrations/20261017121000_unassigned_class_owner/migration.sql b/packages/server/prisma/migrations/20261017121000_unassigned_class_owner/migration.sql new file mode 100644 index 0000000..32d3b8f --- /dev/null +++ b/packages/server/prisma/migrations/20261017121000_unassigned_class_owner/migration.sql @@ -0,0 +1,31 @@ +-- 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, + "code" TEXT NOT NULL, + "passwordHash" TEXT, + "termWeeks" INTEGER NOT NULL DEFAULT 20, + "termStartDate" TEXT, + "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 SET NULL ON UPDATE CASCADE, + CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Class" ("archivedAt", "archivedYearId", "classIndex", "code", "createdAt", "enrollmentYear", "grade", "id", "name", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termStartDate", "termWeeks", "updatedAt") SELECT "archivedAt", "archivedYearId", "classIndex", "code", "createdAt", "enrollmentYear", "grade", "id", "name", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termStartDate", "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"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/packages/server/prisma/migrations/20261017122000_normalize_imported_time_points/migration.sql b/packages/server/prisma/migrations/20261017122000_normalize_imported_time_points/migration.sql new file mode 100644 index 0000000..edfb898 --- /dev/null +++ b/packages/server/prisma/migrations/20261017122000_normalize_imported_time_points/migration.sql @@ -0,0 +1,8 @@ +-- 将历史离线导入的 JSON 时间点补为关系记录;items 仅保留原始审计来源。 +INSERT INTO TimeLayoutItem (id,timeLayoutId,position,name,startTime,endTime,timeType,isHideDefault,breakName) +SELECT 'import-point-'||t.id||'-'||j.key,t.id,CAST(j.key AS INTEGER),COALESCE(json_extract(j.value,'$.name'),''),json_extract(j.value,'$.startTime'),json_extract(j.value,'$.endTime'), +CASE json_extract(j.value,'$.type') WHEN 'break' THEN 'BREAK' WHEN 'divider' THEN 'DIVIDER' WHEN 'action' THEN 'ACTION' ELSE 'LESSON' END, +COALESCE(json_extract(j.value,'$.skipped'),false),CASE json_extract(j.value,'$.type') WHEN 'break' THEN COALESCE(json_extract(j.value,'$.name'),'') ELSE '' END +FROM TimeLayout t,json_each(CASE WHEN json_valid(t.items) THEN t.items ELSE '[]' END) j +WHERE NOT EXISTS(SELECT 1 FROM TimeLayoutItem p WHERE p.timeLayoutId=t.id) +AND json_extract(j.value,'$.startTime') IS NOT NULL AND json_extract(j.value,'$.endTime') IS NOT NULL; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 625e48c..d475108 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -211,7 +211,7 @@ model Class { /// 班号(同一届内的序号) classIndex Int? /// 班主任(每班 1 人);科任老师按科目配在 Course 上 - teacherId String + teacherId String? /// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写) code String @unique /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录 @@ -230,7 +230,7 @@ model Class { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade) + teacher User? @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: SetNull) archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull) students Student[] courses Course[] @@ -240,6 +240,9 @@ model Class { grades Grade[] exams Exam[] timeLayouts TimeLayout[] + classPlans ClassPlan[] + planGroups ClassPlanGroup[] + scheduledPlans ScheduledClassPlan[] clientDevices ClientDevice[] @@index([teacherId]) @@ -255,6 +258,8 @@ model Class { model Course { id String @id @default(cuid()) name String + initial String @default("") + isOutDoor Boolean @default(false) /// 该班该科的任课老师 teacherId String classId String @@ -265,6 +270,7 @@ model Course { schedules Schedule[] homeworks Homework[] grades Grade[] + planEntries ClassPlanEntry[] @@unique([classId, name]) @@index([classId]) @@ -384,16 +390,20 @@ model NotificationRead { /// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表) model TimeLayout { id String @id @default(cuid()) - classId String + classId String? name String /// 来源:classisland | manual source String @default("classisland") /// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }] - items String + items String @default("[]") + isOverlay Boolean @default(false) + overlaySourceId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class? @relation(fields: [classId], references: [id], onDelete: Cascade) + points TimeLayoutItem[] + plans ClassPlan[] @@index([classId]) } @@ -429,6 +439,8 @@ model Grade { } model PermissionGroup { + scope String @default("SELF_CLASS") + scopeValue String @default("") /// 角色默认组;自定义组为空,改名不影响对应角色。 defaultRole String? @unique id String @id @default(cuid()) @@ -526,7 +538,7 @@ model ClientDevice { /// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。 /// -/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**, +/// 客户端心跳时拉取未回执且未过期的指令,收到即执行(2026-10-17 产品要求), /// 执行后回执(`ackedAt` + `result`)。 model ClientCommand { id String @id @default(cuid()) @@ -539,7 +551,7 @@ model ClientCommand { createdAt DateTime @default(now()) /// 过期时间:超过后不再下发(避免机器离线后几天突然关机) expiresAt DateTime? - /// 客户端确认执行的时间 + /// 客户端回执执行结果的时间 ackedAt DateTime? /// 执行结果说明(成功 / 失败原因) result String @default("") @@ -615,3 +627,93 @@ model Exam { @@unique([classId, name]) @@index([classId]) } + +/// 服务端不用单机 Profile:全校共享时间表 + 班级日课表替代档案。 +model TimeLayoutItem { + id String @id @default(cuid()) + timeLayoutId String + position Int + name String @default("") + startTime String + endTime String + timeType String @default("LESSON") + isHideDefault Boolean @default(false) + defaultClassId String? + breakName String @default("") + actionSet String @default("{}") + layout TimeLayout @relation(fields: [timeLayoutId], references: [id], onDelete: Cascade) + entries ClassPlanEntry[] + @@index([timeLayoutId, position]) +} + +model ClassPlanGroup { + id String @id @default(cuid()) + classId String + name String + isGlobal Boolean @default(false) + isDefault Boolean @default(false) + plans ClassPlan[] + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + @@index([classId]) +} + +model ClassPlan { + id String @id @default(cuid()) + classId String + name String + timeLayoutId String + associatedGroup String? + weekday Int @default(0) + weekCountDiv Int @default(0) + weekCountDivTotal Int @default(2) + weekStart Int @default(1) + weekEnd Int @default(60) + isEnabled Boolean @default(true) + isOverlay Boolean @default(false) + overlaySourceId String? + overlayDate String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + layout TimeLayout @relation(fields: [timeLayoutId], references: [id], onDelete: Cascade) + group ClassPlanGroup? @relation(fields: [associatedGroup], references: [id], onDelete: SetNull) + entries ClassPlanEntry[] + scheduled ScheduledClassPlan[] + @@index([classId, weekday]) +} + +model ClassPlanEntry { + id String @id @default(cuid()) + classPlanId String + timeLayoutItemId String + courseId String? + isChangedClass Boolean @default(false) + isEnabled Boolean @default(true) + location String? + legacyScheduleId String? + plan ClassPlan @relation(fields: [classPlanId], references: [id], onDelete: Cascade) + timePoint TimeLayoutItem @relation(fields: [timeLayoutItemId], references: [id], onDelete: Cascade) + course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) + @@unique([classPlanId, timeLayoutItemId]) +} + +model ScheduledClassPlan { + id String @id @default(cuid()) + classId String + date String + classPlanId String + plan ClassPlan @relation(fields: [classPlanId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + @@unique([classId, date]) +} + +model PromotionRun { + year Int @id + status String + snapshotPath String @default("") + message String @default("") + classCount Int @default(0) + studentCount Int @default(0) + createdAt DateTime @default(now()) + finishedAt DateTime? +} diff --git a/packages/shared/src/class-plan.ts b/packages/shared/src/class-plan.ts new file mode 100644 index 0000000..584e422 --- /dev/null +++ b/packages/shared/src/class-plan.ts @@ -0,0 +1,88 @@ +import type { ClassPeriod as ContractClassPeriod } from './types.js'; +import type { ScheduleDto } from './types.js'; + +export const TIME_POINT_TYPES = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'] as const; +export type TimePointType = (typeof TIME_POINT_TYPES)[number]; +export interface TimePointDto { + id: string; + position: number; + name: string; + startTime: string; + endTime: string; + timeType: TimePointType; + isHideDefault: boolean; + defaultClassId: string | null; + breakName: string; + actionSet: string; +} +export interface TimeLayoutDetailDto { + id: string; + name: string; + classId: string | null; + points: TimePointDto[]; +} +export interface DailyPlanEntryDto { + id: string; + timeLayoutItemId: string; + courseId: string | null; + isEnabled: boolean; + isChangedClass: boolean; + location: string | null; +} +export interface ClassPlanDto { + id: string; + classId: string; + name: string; + timeLayoutId: string; + associatedGroup: string | null; + weekday: number; + weekCountDiv: number; + weekCountDivTotal: number; + weekStart: number; + weekEnd: number; + isEnabled: boolean; + isOverlay: boolean; + overlaySourceId: string | null; + entries: DailyPlanEntryDto[]; +} +export interface ActiveClassPlanDto { + classPlan: ClassPlanDto | null; + timeLayout: TimeLayoutDetailDto | null; + schedules: ScheduleDto[]; + current: ContractClassPeriod | null; + next: ContractClassPeriod | null; + inClass: boolean; + week: number; +} +export interface ActiveClassPlanWeekDto { + classId: string; + week: number; + days: Array; +} + +/** 设置来自服务端;离线/未初始化时统一回落 30。 */ +export function getMaxTermWeeks(configured?: unknown): number { + const value = Number(configured); + return Number.isInteger(value) && value >= 1 && value <= 60 ? value : 30; +} + +export type PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL'; +export interface ReleaseProgressDto { + version: string; + stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed'; + downloadedBytes: number; + totalBytes: number; + percent: number | null; + error: string; +} +export interface CollectiveGradeAnalysisDto { + mode: 'collective'; + classId: string; + suppressed: boolean; + averagePercent: number | null; + passRate: number | null; + excellentRate: number | null; + subjects: { name: string; averagePercent: number | null }[]; + distribution: { label: string; count: number | null }[]; + trend: { examName: string; averagePercent: number | null }[]; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 04ed0d3..97bcb58 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -487,6 +487,7 @@ export const SOCKET_EVENTS = { homeworkStatus: 'homework:status', gradeUpdated: 'grade:updated', scheduleUpdated: 'schedule:updated', + clientReleaseProgress: 'client:release-progress', classUpdated: 'class:updated', /** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */ callNew: 'call:new', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d9bd6c3..5c38472 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,4 +6,6 @@ export * from './constants.js'; export * from './motion.js'; export * from './permissions.js'; export * from './utils.js'; +export * from './class-plan.js'; +export * from './island-layout.js'; export type * from './types.js'; diff --git a/packages/shared/src/island-layout.ts b/packages/shared/src/island-layout.ts new file mode 100644 index 0000000..cbce5b5 --- /dev/null +++ b/packages/shared/src/island-layout.ts @@ -0,0 +1,279 @@ +export const ISLAND_COMPONENTS = [ + 'date', + 'clock', + 'countdown', + 'schedule', + 'text', + 'separator', + 'slide', + 'rolling', + 'group', + 'stack', + 'notification', + 'homework', + 'call', +] as const; +export type IslandComponentKind = (typeof ISLAND_COMPONENTS)[number]; +export const ISLAND_COMPONENT_LABELS: Record = { + date: '日期', + clock: '时钟', + countdown: '倒计时', + schedule: '课程表', + text: '文本', + separator: '分隔符', + slide: '轮播', + rolling: '滚动', + group: '分组', + stack: '堆叠', + notification: '通知', + homework: '作业', + call: '叫人', +}; +export interface ComponentAppearance { + resourceOverridingEnabled: boolean; + secondaryFontSize: number; + bodyFontSize: number; + emphasizedFontSize: number; + largeFontSize: number; + customForegroundEnabled: boolean; + foreground: string; + customBackgroundEnabled: boolean; + background: string; + customBackgroundOpacityEnabled: boolean; + backgroundOpacity: number; + customCornerRadiusEnabled: boolean; + cornerRadius: number; + opacity: number; +} +export interface IslandComponentNode { + id: string; + kind: IslandComponentKind; + settings: { + text: string; + template: boolean; + hour12: boolean; + seconds: boolean; + target: 'next' | 'school'; + display: 'current' | 'next' | 'today'; + count: number; + showPeriod: boolean; + direction: 'horizontal' | 'vertical'; + gap: number; + interval: number; + speed: number; + }; + appearance: ComponentAppearance; + advanced: { + hideRules: ('in-class' | 'no-message' | 'no-class')[]; + align: 'stretch' | 'start' | 'center' | 'end'; + width: number; + maxWidth: number; + }; + children: IslandComponentNode[]; +} +export interface IslandLayoutRow { + id: string; + name: string; + main: boolean; + allowNotifications: boolean; + gap: number; + nodes: IslandComponentNode[]; +} +export interface IslandLayoutScheme { + id: string; + name: string; + rows: IslandLayoutRow[]; + scale: number; + color: string; + foreground: string; + opacity: number; + radius: number; + separated: boolean; + spacing: number; +} +export interface IslandLayoutSettings { + layout: 'fixed' | 'custom'; + activeSchemeId: string; + schemes: IslandLayoutScheme[]; +} +export function islandNode(kind: IslandComponentKind, id: string): IslandComponentNode { + return { + id, + kind, + children: [], + settings: { + text: '班级信息', + template: false, + hour12: false, + seconds: true, + target: 'next', + display: 'current', + count: 5, + showPeriod: true, + direction: 'horizontal', + gap: 8, + interval: 5, + speed: 30, + }, + appearance: { + resourceOverridingEnabled: false, + secondaryFontSize: 12, + bodyFontSize: 16, + emphasizedFontSize: 20, + largeFontSize: 28, + customForegroundEnabled: false, + foreground: '#ffffff', + customBackgroundEnabled: false, + background: '#202024', + customBackgroundOpacityEnabled: false, + backgroundOpacity: 0.5, + customCornerRadiusEnabled: false, + cornerRadius: 16, + opacity: 1, + }, + advanced: { hideRules: [], align: 'stretch', width: 0, maxWidth: 0 }, + }; +} +export function defaultIslandLayouts(): IslandLayoutSettings { + return { + layout: 'fixed', + activeSchemeId: 'default', + schemes: [ + { + id: 'default', + name: '默认组件方案', + rows: [ + { + id: 'main', + name: '主要行', + main: true, + allowNotifications: true, + gap: 12, + nodes: [ + islandNode('date', 'date'), + islandNode('clock', 'clock'), + islandNode('schedule', 'schedule'), + islandNode('notification', 'messages'), + ], + }, + ], + scale: 1, + color: '#202024', + foreground: '#ffffff', + opacity: 0.85, + radius: 24, + separated: false, + spacing: 8, + }, + ], + }; +} +/** 导入/IPC 配置有同一边界:最多 12 行/200 节点/6 层,颜色和数值全部归一化。 */ +export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings { + const fallback = defaultIslandLayouts(); + if (!input || typeof input !== 'object') return fallback; + const raw = input as IslandLayoutSettings; + const clamp = (n: unknown, min: number, max: number, value: number) => + typeof n === 'number' && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : value; + const color = (v: unknown, value: string) => + typeof v === 'string' && /^#[\da-f]{6}$/i.test(v) ? v : value; + let count = 0; + const ids = new Set(); + const node = (n: IslandComponentNode, depth: number): IslandComponentNode | null => { + if (!n || depth > 6 || ++count > 200 || !ISLAND_COMPONENTS.includes(n.kind)) return null; + const id = typeof n.id === 'string' && n.id && !ids.has(n.id) ? n.id.slice(0, 100) : `node-${count}`; + ids.add(id); + const base = islandNode(n.kind, id), + a = n.appearance || base.appearance, + s = n.settings || base.settings, + adv = n.advanced || base.advanced; + return { + ...base, + settings: { + ...base.settings, + text: String(s.text || '').slice(0, 2000), + template: s.template === true, + hour12: s.hour12 === true, + seconds: s.seconds !== false, + target: s.target === 'school' ? 'school' : 'next', + display: ['current', 'next', 'today'].includes(s.display) ? s.display : 'current', + count: clamp(s.count, 1, 40, 5), + showPeriod: s.showPeriod !== false, + direction: s.direction === 'vertical' ? 'vertical' : 'horizontal', + gap: clamp(s.gap, 0, 64, 8), + interval: clamp(s.interval, 1, 300, 5), + speed: clamp(s.speed, 1, 200, 30), + }, + appearance: { + resourceOverridingEnabled: a.resourceOverridingEnabled === true, + secondaryFontSize: clamp(a.secondaryFontSize, 8, 96, 12), + bodyFontSize: clamp(a.bodyFontSize, 8, 96, 16), + emphasizedFontSize: clamp(a.emphasizedFontSize, 8, 96, 20), + largeFontSize: clamp(a.largeFontSize, 8, 128, 28), + customForegroundEnabled: a.customForegroundEnabled === true, + foreground: color(a.foreground, '#ffffff'), + customBackgroundEnabled: a.customBackgroundEnabled === true, + background: color(a.background, '#202024'), + customBackgroundOpacityEnabled: a.customBackgroundOpacityEnabled === true, + backgroundOpacity: clamp(a.backgroundOpacity, 0, 1, 0.5), + customCornerRadiusEnabled: a.customCornerRadiusEnabled === true, + cornerRadius: clamp(a.cornerRadius, 0, 100, 16), + opacity: clamp(a.opacity, 0, 1, 1), + }, + advanced: { + hideRules: Array.isArray(adv.hideRules) + ? adv.hideRules.filter((r) => ['in-class', 'no-message', 'no-class'].includes(r)) + : [], + align: ['stretch', 'start', 'center', 'end'].includes(adv.align) ? adv.align : 'stretch', + width: clamp(adv.width, 0, 2000, 0), + maxWidth: clamp(adv.maxWidth, 0, 2000, 0), + }, + children: Array.isArray(n.children) + ? n.children.map((c) => node(c, depth + 1)).filter((n): n is IslandComponentNode => Boolean(n)) + : [], + }; + }; + const schemes: IslandLayoutScheme[] = []; + for (const scheme of (Array.isArray(raw.schemes) ? raw.schemes : []).slice(0, 20)) { + if (!scheme || !Array.isArray(scheme.rows)) continue; + const rows = scheme.rows + .filter((r) => r && typeof r === 'object') + .slice(0, 12) + .map((r, i) => ({ + id: String(r.id || `row-${i}`).slice(0, 100), + name: String(r.name || `行 ${i + 1}`).slice(0, 64), + main: r.main === true, + allowNotifications: r.allowNotifications !== false, + gap: clamp(r.gap, 0, 64, 8), + nodes: (Array.isArray(r.nodes) ? r.nodes : []) + .map((n) => node(n, 0)) + .filter((n): n is IslandComponentNode => Boolean(n)), + })); + if (!rows.length) continue; + if (!rows.some((r) => r.main)) rows[0]!.main = true; + let main = false; + for (const r of rows) { + if (r.main && main) r.main = false; + if (r.main) main = true; + } + schemes.push({ + id: String(scheme.id || `scheme-${schemes.length}`).slice(0, 100), + name: String(scheme.name || '组件方案').slice(0, 64), + rows, + scale: clamp(scheme.scale, 0.5, 3, 1), + color: color(scheme.color, '#202024'), + foreground: color(scheme.foreground, '#ffffff'), + opacity: clamp(scheme.opacity, 0.1, 1, 0.85), + radius: clamp(scheme.radius, 0, 100, 24), + separated: scheme.separated === true, + spacing: clamp(scheme.spacing, 0, 100, 8), + }); + } + return { + layout: raw.layout === 'custom' ? 'custom' : 'fixed', + schemes: schemes.length ? schemes : fallback.schemes, + activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId) + ? raw.activeSchemeId + : schemes[0]?.id || 'default', + }; +} diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index d515560..83dcd9f 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -172,6 +172,7 @@ export const PERMISSION_KEYS = [ 'teacher:manage', 'schedule:read', 'schedule:write', + 'timetable:manage', // 内容发布 'homework:read', 'homework:write', @@ -191,6 +192,7 @@ export const PERMISSION_KEYS = [ // 设备与联动 'client:read', 'client:manage', + 'client:update', 'client:update-push', 'remote:control', // 学期与归档 @@ -210,6 +212,41 @@ export const PERMISSION_KEYS = [ export type PermissionKey = (typeof PERMISSION_KEYS)[number]; +/** 默认角色组之外的教师岗位组;空年级范围必须由管理员配置,不能自动授权全校。 */ +const TEACHING_KEYS: PermissionKey[] = [ + 'homework:write', + 'grade:write', + 'notification:publish', + 'call:create', + 'schedule:read', + 'roster:read', + 'grade:read', + 'homework:read', + 'notification:read', +]; +const LEAD_KEYS: PermissionKey[] = [ + 'client:read', + 'client:manage', + 'remote:control', + 'roster:read', + 'class:read', + 'schedule:read', + 'grade:read', + 'dashboard:read', +]; +export const BUILTIN_TEACHER_GROUPS = [ + { + id: 'builtin-head', + name: '班主任', + scope: 'SELF_CLASS', + scopeValue: 'HEAD', + keys: [...TEACHING_KEYS, 'client:read', 'client:manage'], + }, + { id: 'builtin-teacher', name: '教师', scope: 'SELF_CLASS', scopeValue: '', keys: TEACHING_KEYS }, + { id: 'builtin-grade', name: '级长', scope: 'GRADE', scopeValue: '', keys: LEAD_KEYS }, + { id: 'builtin-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS }, +] as const; + /** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */ export const PERMISSION_LABELS: Record = { 'class:read': '查看班级与班级列表', @@ -220,6 +257,8 @@ export const PERMISSION_LABELS: Record = { 'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)', 'schedule:read': '查看课表', 'schedule:write': '课表与课程编辑', + 'timetable:manage': '管理全校时间表', + 'client:update': '管理客户端安装包与更新策略', 'homework:read': '查看作业', 'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)', 'notification:read': '查看通知', @@ -264,6 +303,7 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per 'teacher:manage', 'schedule:read', 'schedule:write', + 'timetable:manage', ], }, { @@ -292,7 +332,7 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per }, { title: '客户端设备', - keys: ['client:read', 'client:manage', 'client:update-push', 'remote:control'], + keys: ['client:read', 'client:manage', 'client:update', 'client:update-push', 'remote:control'], }, { title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] }, { @@ -327,6 +367,8 @@ export const PERMISSION_DEFAULT_ROLES: Record 客户端事件名 */ export interface ServerToClientEvents { + 'client:release-progress': (payload: ContractReleaseProgressDto) => void; 'notification:deleted': (payload: { id: string; classId: string }) => void; 'notification:new': (payload: NotificationDto) => void; /** 叫人:定向广播给被叫学生 */ @@ -1422,6 +1427,8 @@ export interface UpdateInfo { * **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。 */ export interface PermissionGroupDto { + scope?: ContractPermissionScope; + scopeValue?: string; id: string; name: string; description: string; @@ -1462,12 +1469,16 @@ export interface PermissionCatalogDto { } export interface CreatePermissionGroupRequest { + scope?: ContractPermissionScope; + scopeValue?: string; name: string; description?: string; keys?: PermissionKey[]; } export interface UpdatePermissionGroupRequest { + scope?: ContractPermissionScope; + scopeValue?: string; name?: string; description?: string; keys?: PermissionKey[]; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 92dcc77..3dd710e 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -1,3 +1,4 @@ +import { getMaxTermWeeks } from './class-plan.js'; import { WEEKDAY_LABELS, WEEKDAYS, WEEK_PARITY_LABELS } from './constants.js'; import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView, WeekParity } from './types.js'; @@ -291,6 +292,7 @@ export function timeToMinutes(value: string): number { } const MS_PER_DAY = 86_400_000; +/** @deprecated 仅保留旧调用方 ABI;新代码统一调用 getMaxTermWeeks。 */ export const MAX_TERM_WEEK = 30; /** @@ -300,7 +302,7 @@ export const MAX_TERM_WEEK = 30; export function resolveCurrentWeek( termStartDate: string, now: Date = new Date(), - maxWeek: number = MAX_TERM_WEEK, + maxWeek: number = getMaxTermWeeks(), ): number { if (!termStartDate) return 1; const start = toDate(termStartDate); @@ -346,7 +348,7 @@ export function resolveWeekFromRanges( */ export function buildAutoTermWeeks( termStartDate: string, - maxWeek: number = MAX_TERM_WEEK, + maxWeek: number = getMaxTermWeeks(), ): Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> { if (!termStartDate) return []; const start = toDate(termStartDate); @@ -355,7 +357,7 @@ export function buildAutoTermWeeks( const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY); monday.setHours(0, 0, 0, 0); - const total = Math.max(1, Math.min(maxWeek, MAX_TERM_WEEK)); + const total = getMaxTermWeeks(maxWeek); const weeks: Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> = []; for (let index = 0; index < total; index += 1) { const from = new Date(monday.getTime() + index * 7 * MS_PER_DAY);