feat(shared,prisma): 建立日课表与岛组件契约及数据范围模型

This commit is contained in:
LoveChengke committed 2026-10-17 09:33:32 +08:00
1 parent c0ebf88138
commit 66ef878a71
11 files changed
+758 -11

No files matched your search

+88
View File
@@ -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 }[];
}
+1
View File
@@ -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',
+2
View File
@@ -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';
+279
View File
@@ -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',
};
}
+43 -1
View File
@@ -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<PermissionKey, string> = {
'class:read': '查看班级与班级列表',
@@ -220,6 +257,8 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'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<PermissionKey, readonly UserRole[]
'teacher:manage': ['ADMIN'],
'schedule:read': ['ADMIN', 'TEACHER'],
'schedule:write': ['ADMIN', 'TEACHER'],
'timetable:manage': ['ADMIN'],
'client:update': ['ADMIN'],
'homework:read': ['ADMIN', 'TEACHER'],
'homework:write': ['ADMIN', 'TEACHER'],
'notification:read': ['ADMIN', 'TEACHER'],
+11
View File
@@ -1,3 +1,4 @@
import type { PermissionScope as ContractPermissionScope, ReleaseProgressDto as ContractReleaseProgressDto } from './class-plan.js';
/**
* 三端共享的领域类型定义。
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
@@ -334,6 +335,7 @@ export interface TermWeekDto {
}
export interface TermWeeksDto {
maxTermWeeks?: number;
/**
* 这套区间属于谁:空串 = **全校默认**;非空 = 某个班的覆盖。
* 班级没有自己的配置时回落到全校默认。
@@ -503,6 +505,8 @@ export interface UpdateClassAccountRequest {
/* ------------------------------------------------------------------ 课程 */
export interface CourseDto {
initial?: string;
isOutDoor?: boolean;
id: string;
name: string;
teacherId: string;
@@ -1181,6 +1185,7 @@ export interface DashboardSummary {
/** 服务端 -> 客户端事件名 */
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[];
+5 -3
View File
@@ -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);