feat(shared,prisma): 建立日课表与岛组件契约及数据范围模型
This commit is contained in:
1 parent
c0ebf88138
commit
66ef878a71
11 files changed
+758
-11
No files matched your search
+181
@@ -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;
|
||||||
@@ -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;
|
||||||
+8
@@ -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;
|
||||||
@@ -211,7 +211,7 @@ model Class {
|
|||||||
/// 班号(同一届内的序号)
|
/// 班号(同一届内的序号)
|
||||||
classIndex Int?
|
classIndex Int?
|
||||||
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
|
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
|
||||||
teacherId String
|
teacherId String?
|
||||||
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
|
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
|
||||||
code String @unique
|
code String @unique
|
||||||
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
|
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
|
||||||
@@ -230,7 +230,7 @@ model Class {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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)
|
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
|
||||||
students Student[]
|
students Student[]
|
||||||
courses Course[]
|
courses Course[]
|
||||||
@@ -240,6 +240,9 @@ model Class {
|
|||||||
grades Grade[]
|
grades Grade[]
|
||||||
exams Exam[]
|
exams Exam[]
|
||||||
timeLayouts TimeLayout[]
|
timeLayouts TimeLayout[]
|
||||||
|
classPlans ClassPlan[]
|
||||||
|
planGroups ClassPlanGroup[]
|
||||||
|
scheduledPlans ScheduledClassPlan[]
|
||||||
clientDevices ClientDevice[]
|
clientDevices ClientDevice[]
|
||||||
|
|
||||||
@@index([teacherId])
|
@@index([teacherId])
|
||||||
@@ -255,6 +258,8 @@ model Class {
|
|||||||
model Course {
|
model Course {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
initial String @default("")
|
||||||
|
isOutDoor Boolean @default(false)
|
||||||
/// 该班该科的任课老师
|
/// 该班该科的任课老师
|
||||||
teacherId String
|
teacherId String
|
||||||
classId String
|
classId String
|
||||||
@@ -265,6 +270,7 @@ model Course {
|
|||||||
schedules Schedule[]
|
schedules Schedule[]
|
||||||
homeworks Homework[]
|
homeworks Homework[]
|
||||||
grades Grade[]
|
grades Grade[]
|
||||||
|
planEntries ClassPlanEntry[]
|
||||||
|
|
||||||
@@unique([classId, name])
|
@@unique([classId, name])
|
||||||
@@index([classId])
|
@@index([classId])
|
||||||
@@ -384,16 +390,20 @@ model NotificationRead {
|
|||||||
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
|
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
|
||||||
model TimeLayout {
|
model TimeLayout {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
classId String
|
classId String?
|
||||||
name String
|
name String
|
||||||
/// 来源:classisland | manual
|
/// 来源:classisland | manual
|
||||||
source String @default("classisland")
|
source String @default("classisland")
|
||||||
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
|
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
|
||||||
items String
|
items String @default("[]")
|
||||||
|
isOverlay Boolean @default(false)
|
||||||
|
overlaySourceId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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])
|
@@index([classId])
|
||||||
}
|
}
|
||||||
@@ -429,6 +439,8 @@ model Grade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model PermissionGroup {
|
model PermissionGroup {
|
||||||
|
scope String @default("SELF_CLASS")
|
||||||
|
scopeValue String @default("")
|
||||||
/// 角色默认组;自定义组为空,改名不影响对应角色。
|
/// 角色默认组;自定义组为空,改名不影响对应角色。
|
||||||
defaultRole String? @unique
|
defaultRole String? @unique
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -526,7 +538,7 @@ model ClientDevice {
|
|||||||
|
|
||||||
/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。
|
/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。
|
||||||
///
|
///
|
||||||
/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**,
|
/// 客户端心跳时拉取未回执且未过期的指令,收到即执行(2026-10-17 产品要求),
|
||||||
/// 执行后回执(`ackedAt` + `result`)。
|
/// 执行后回执(`ackedAt` + `result`)。
|
||||||
model ClientCommand {
|
model ClientCommand {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -539,7 +551,7 @@ model ClientCommand {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
/// 过期时间:超过后不再下发(避免机器离线后几天突然关机)
|
/// 过期时间:超过后不再下发(避免机器离线后几天突然关机)
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
/// 客户端确认执行的时间
|
/// 客户端回执执行结果的时间
|
||||||
ackedAt DateTime?
|
ackedAt DateTime?
|
||||||
/// 执行结果说明(成功 / 失败原因)
|
/// 执行结果说明(成功 / 失败原因)
|
||||||
result String @default("")
|
result String @default("")
|
||||||
@@ -615,3 +627,93 @@ model Exam {
|
|||||||
@@unique([classId, name])
|
@@unique([classId, name])
|
||||||
@@index([classId])
|
@@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?
|
||||||
|
}
|
||||||
@@ -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<ActiveClassPlanDto & { weekday: number; date: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置来自服务端;离线/未初始化时统一回落 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 }[];
|
||||||
|
}
|
||||||
@@ -487,6 +487,7 @@ export const SOCKET_EVENTS = {
|
|||||||
homeworkStatus: 'homework:status',
|
homeworkStatus: 'homework:status',
|
||||||
gradeUpdated: 'grade:updated',
|
gradeUpdated: 'grade:updated',
|
||||||
scheduleUpdated: 'schedule:updated',
|
scheduleUpdated: 'schedule:updated',
|
||||||
|
clientReleaseProgress: 'client:release-progress',
|
||||||
classUpdated: 'class:updated',
|
classUpdated: 'class:updated',
|
||||||
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
|
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
|
||||||
callNew: 'call:new',
|
callNew: 'call:new',
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export * from './constants.js';
|
|||||||
export * from './motion.js';
|
export * from './motion.js';
|
||||||
export * from './permissions.js';
|
export * from './permissions.js';
|
||||||
export * from './utils.js';
|
export * from './utils.js';
|
||||||
|
export * from './class-plan.js';
|
||||||
|
export * from './island-layout.js';
|
||||||
export type * from './types.js';
|
export type * from './types.js';
|
||||||
@@ -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<IslandComponentKind, string> = {
|
||||||
|
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<string>();
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -172,6 +172,7 @@ export const PERMISSION_KEYS = [
|
|||||||
'teacher:manage',
|
'teacher:manage',
|
||||||
'schedule:read',
|
'schedule:read',
|
||||||
'schedule:write',
|
'schedule:write',
|
||||||
|
'timetable:manage',
|
||||||
// 内容发布
|
// 内容发布
|
||||||
'homework:read',
|
'homework:read',
|
||||||
'homework:write',
|
'homework:write',
|
||||||
@@ -191,6 +192,7 @@ export const PERMISSION_KEYS = [
|
|||||||
// 设备与联动
|
// 设备与联动
|
||||||
'client:read',
|
'client:read',
|
||||||
'client:manage',
|
'client:manage',
|
||||||
|
'client:update',
|
||||||
'client:update-push',
|
'client:update-push',
|
||||||
'remote:control',
|
'remote:control',
|
||||||
// 学期与归档
|
// 学期与归档
|
||||||
@@ -210,6 +212,41 @@ export const PERMISSION_KEYS = [
|
|||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
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<PermissionKey, string> = {
|
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||||
'class:read': '查看班级与班级列表',
|
'class:read': '查看班级与班级列表',
|
||||||
@@ -220,6 +257,8 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
|||||||
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
|
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
|
||||||
'schedule:read': '查看课表',
|
'schedule:read': '查看课表',
|
||||||
'schedule:write': '课表与课程编辑',
|
'schedule:write': '课表与课程编辑',
|
||||||
|
'timetable:manage': '管理全校时间表',
|
||||||
|
'client:update': '管理客户端安装包与更新策略',
|
||||||
'homework:read': '查看作业',
|
'homework:read': '查看作业',
|
||||||
'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)',
|
'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)',
|
||||||
'notification:read': '查看通知',
|
'notification:read': '查看通知',
|
||||||
@@ -264,6 +303,7 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
|
|||||||
'teacher:manage',
|
'teacher:manage',
|
||||||
'schedule:read',
|
'schedule:read',
|
||||||
'schedule:write',
|
'schedule:write',
|
||||||
|
'timetable:manage',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -292,7 +332,7 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '客户端设备',
|
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'] },
|
{ title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] },
|
||||||
{
|
{
|
||||||
@@ -327,6 +367,8 @@ export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]
|
|||||||
'teacher:manage': ['ADMIN'],
|
'teacher:manage': ['ADMIN'],
|
||||||
'schedule:read': ['ADMIN', 'TEACHER'],
|
'schedule:read': ['ADMIN', 'TEACHER'],
|
||||||
'schedule:write': ['ADMIN', 'TEACHER'],
|
'schedule:write': ['ADMIN', 'TEACHER'],
|
||||||
|
'timetable:manage': ['ADMIN'],
|
||||||
|
'client:update': ['ADMIN'],
|
||||||
'homework:read': ['ADMIN', 'TEACHER'],
|
'homework:read': ['ADMIN', 'TEACHER'],
|
||||||
'homework:write': ['ADMIN', 'TEACHER'],
|
'homework:write': ['ADMIN', 'TEACHER'],
|
||||||
'notification:read': ['ADMIN', 'TEACHER'],
|
'notification:read': ['ADMIN', 'TEACHER'],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PermissionScope as ContractPermissionScope, ReleaseProgressDto as ContractReleaseProgressDto } from './class-plan.js';
|
||||||
/**
|
/**
|
||||||
* 三端共享的领域类型定义。
|
* 三端共享的领域类型定义。
|
||||||
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
|
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
|
||||||
@@ -334,6 +335,7 @@ export interface TermWeekDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TermWeeksDto {
|
export interface TermWeeksDto {
|
||||||
|
maxTermWeeks?: number;
|
||||||
/**
|
/**
|
||||||
* 这套区间属于谁:空串 = **全校默认**;非空 = 某个班的覆盖。
|
* 这套区间属于谁:空串 = **全校默认**;非空 = 某个班的覆盖。
|
||||||
* 班级没有自己的配置时回落到全校默认。
|
* 班级没有自己的配置时回落到全校默认。
|
||||||
@@ -503,6 +505,8 @@ export interface UpdateClassAccountRequest {
|
|||||||
/* ------------------------------------------------------------------ 课程 */
|
/* ------------------------------------------------------------------ 课程 */
|
||||||
|
|
||||||
export interface CourseDto {
|
export interface CourseDto {
|
||||||
|
initial?: string;
|
||||||
|
isOutDoor?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
teacherId: string;
|
teacherId: string;
|
||||||
@@ -1181,6 +1185,7 @@ export interface DashboardSummary {
|
|||||||
|
|
||||||
/** 服务端 -> 客户端事件名 */
|
/** 服务端 -> 客户端事件名 */
|
||||||
export interface ServerToClientEvents {
|
export interface ServerToClientEvents {
|
||||||
|
'client:release-progress': (payload: ContractReleaseProgressDto) => void;
|
||||||
'notification:deleted': (payload: { id: string; classId: string }) => void;
|
'notification:deleted': (payload: { id: string; classId: string }) => void;
|
||||||
'notification:new': (payload: NotificationDto) => void;
|
'notification:new': (payload: NotificationDto) => void;
|
||||||
/** 叫人:定向广播给被叫学生 */
|
/** 叫人:定向广播给被叫学生 */
|
||||||
@@ -1422,6 +1427,8 @@ export interface UpdateInfo {
|
|||||||
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
|
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
|
||||||
*/
|
*/
|
||||||
export interface PermissionGroupDto {
|
export interface PermissionGroupDto {
|
||||||
|
scope?: ContractPermissionScope;
|
||||||
|
scopeValue?: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -1462,12 +1469,16 @@ export interface PermissionCatalogDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePermissionGroupRequest {
|
export interface CreatePermissionGroupRequest {
|
||||||
|
scope?: ContractPermissionScope;
|
||||||
|
scopeValue?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
keys?: PermissionKey[];
|
keys?: PermissionKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePermissionGroupRequest {
|
export interface UpdatePermissionGroupRequest {
|
||||||
|
scope?: ContractPermissionScope;
|
||||||
|
scopeValue?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
keys?: PermissionKey[];
|
keys?: PermissionKey[];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getMaxTermWeeks } from './class-plan.js';
|
||||||
import { WEEKDAY_LABELS, WEEKDAYS, WEEK_PARITY_LABELS } from './constants.js';
|
import { WEEKDAY_LABELS, WEEKDAYS, WEEK_PARITY_LABELS } from './constants.js';
|
||||||
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView, WeekParity } from './types.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;
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
/** @deprecated 仅保留旧调用方 ABI;新代码统一调用 getMaxTermWeeks。 */
|
||||||
export const MAX_TERM_WEEK = 30;
|
export const MAX_TERM_WEEK = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,7 +302,7 @@ export const MAX_TERM_WEEK = 30;
|
|||||||
export function resolveCurrentWeek(
|
export function resolveCurrentWeek(
|
||||||
termStartDate: string,
|
termStartDate: string,
|
||||||
now: Date = new Date(),
|
now: Date = new Date(),
|
||||||
maxWeek: number = MAX_TERM_WEEK,
|
maxWeek: number = getMaxTermWeeks(),
|
||||||
): number {
|
): number {
|
||||||
if (!termStartDate) return 1;
|
if (!termStartDate) return 1;
|
||||||
const start = toDate(termStartDate);
|
const start = toDate(termStartDate);
|
||||||
@@ -346,7 +348,7 @@ export function resolveWeekFromRanges(
|
|||||||
*/
|
*/
|
||||||
export function buildAutoTermWeeks(
|
export function buildAutoTermWeeks(
|
||||||
termStartDate: string,
|
termStartDate: string,
|
||||||
maxWeek: number = MAX_TERM_WEEK,
|
maxWeek: number = getMaxTermWeeks(),
|
||||||
): Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> {
|
): Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> {
|
||||||
if (!termStartDate) return [];
|
if (!termStartDate) return [];
|
||||||
const start = toDate(termStartDate);
|
const start = toDate(termStartDate);
|
||||||
@@ -355,7 +357,7 @@ export function buildAutoTermWeeks(
|
|||||||
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
|
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
|
||||||
monday.setHours(0, 0, 0, 0);
|
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 }> = [];
|
const weeks: Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> = [];
|
||||||
for (let index = 0; index < total; index += 1) {
|
for (let index = 0; index < total; index += 1) {
|
||||||
const from = new Date(monday.getTime() + index * 7 * MS_PER_DAY);
|
const from = new Date(monday.getTime() + index * 7 * MS_PER_DAY);
|
||||||
|
|||||||
Reference in new issue
Block a user