feat(island,homework,schedule): 灵动岛点开修复+胶囊类型汇总 · 作业看板与未交名单 · 单双周课表与 ClassIsland 课程表导入

一、灵动岛(用户反馈的三处"点不开"全部修复)
- 非紧急叫人:上课时段仍以胶囊保持可见、可点开(不再只进队列导致屏幕上什么都没有);
  新增 isOpenable():紧急消息 + 任何"叫人"都允许点开,但普通叫人不会自动展开打断课堂。
- 紧急叫人收起后再点:命中改为"主进程按光标位置兜底校正"(上一轮的 island:set-hit-rect 轮询),
  本轮补齐叫人类专项回归(展开 → 收起 → 再次点开)。
- 设置页预览:新增 preview 通道 —— 示例岛直接展开、失焦不收起、30 秒自行消失、关掉不残留。
- 收起态胶囊汇总消息类型:多条时显示「新消息:叫人/作业/通知(共 3 条)」+「点击查看」+ 类型小标签。
- 顺带修掉两个真问题:胶囊态收到新消息会把上一条静默顶掉(现在一律回队);
  syncWindow 的上课时段守卫只认"紧急"导致普通叫人被藏掉。
- 性能:岛体矩形上报限流(~10 次/秒 + 尾随补发),修掉每帧 IPC 导致的开合采样骤降。

二、作业(学生端 + Web 教师端)
- 新增**看板模式**:按科目分卡片、点卡片放大全屏、工具栏可切「看板/列表」、
  开关「显示时间」、拖「看板字号」(11–28px),偏好持久化到客户端配置 homeworkBoard。
- 去掉「全班标记完成」,改为**未交名单**:勾选没交的同学,其余自动标记已交;
  新增 GET/PATCH /api/homeworks/:id/submissions(教师与班级设备可用,普通学生 403)。

三、课表单双周 + ClassIsland 课程表导入
- Schedule 新增 weekParity(ALL/ODD/EVEN)+ 迁移;周视图与上课判定按「第 1 周=单周」过滤,
  历史数据默认 ALL 行为不变;Web 手动新增/编辑课表可选单双周,学生端课表打「单周/双周」标签。
- 修复既有 P0 bug:ClassIsland 真实档案里 TimeLayouts 是 "Guid → { Name, Layouts }" 字典,
  旧解析器只认数组/.items,导致**真实导出完全导不进来**;现已按真实形态解析(并兼容旧写法),
  同时补上 TimeType=3(行动)映射。
- 新增 ClassIsland 课程表(ClassPlan)导入:解析 TimeRule.WeekCountDiv/WeekCountDivTotal → 单双周,
  Classes[i] 对齐第 i 个 TimeType=0 的时间点,Subjects 映射科目(缺失自动补建课程),
  星期 0=周日 换算为 1=周一 … 7=周日;replace/merge 两种模式,预览只解析不写库。
- 新增维护脚本 packages/server/scripts/apply-parity-column.cjs(手工覆盖 dist 部署时补列用)。

验证:verify:e2e 148/148(新增单双周 12 项 + 课程表导入 5 项)、verify:desktop 71/71(复跑 65/65)、
verify:web 20/20、typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
LoveChengke committed 2026-09-12 23:48:59 +08:00
1 parent 86430c5f4a
commit b8ef5d8dbb
34 files changed
+2351 -86

No files matched your search

@@ -9,9 +9,11 @@ import {
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
updateHomeworkSubmissionsSchema,
type CreateHomeworkInput,
type UpdateHomeworkInput,
type UpdateHomeworkStatusInput,
type UpdateHomeworkSubmissionsInput,
} from './homeworks.schemas.js';
import * as homeworkService from './homeworks.service.js';
@@ -97,6 +99,30 @@ router.patch(
},
);
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
router.get('/:id/submissions', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.listHomeworkSubmissions(user, id);
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
});
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
router.patch(
'/:id/submissions',
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.updateHomeworkSubmissions(
user,
id,
req.body as UpdateHomeworkSubmissionsInput,
);
sendOk(res, result, `已保存未交名单(未交 ${result.notSubmitted.length} 人)`);
},
);
export const homeworksModule = defineModule({
name: 'homeworks',
basePath: '/homeworks',
@@ -30,6 +30,12 @@ export const updateHomeworkStatusSchema = z.object({
userId: z.string().min(1).optional(),
});
/** 保存"未交名单":列出未交的学生 id(其余学生一律标记为已交) */
export const updateHomeworkSubmissionsSchema = z.object({
notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]),
});
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
@@ -1,4 +1,10 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import {
SOCKET_EVENTS,
type HomeworkDto,
type HomeworkStatusDto,
type HomeworkSubmissionDto,
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import {
assertCanPublishContent,
assertClassAccess,
@@ -10,12 +16,13 @@ import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { isClassSession, personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateHomeworkInput,
UpdateHomeworkInput,
UpdateHomeworkStatusInput,
UpdateHomeworkSubmissionsInput,
} from './homeworks.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
@@ -213,6 +220,106 @@ export async function updateHomeworkStatus(
return dto;
}
/* ------------------------------------------------------------------ 未交名单 */
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些) */
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
const [students, statuses] = await Promise.all([
prisma.user.findMany({
where: { classId, role: 'STUDENT' },
select: { id: true, name: true, username: true },
orderBy: { username: 'asc' },
}),
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { userId: true, completed: true } }),
]);
const completedBy = new Map(statuses.map((item) => [item.userId, item.completed]));
const list: HomeworkSubmissionDto[] = students.map((item) => ({
userId: item.id,
name: item.name || item.username,
username: item.username,
completed: completedBy.get(item.id) === true,
}));
return {
homeworkId,
classId,
total: list.length,
completedCount: list.filter((item) => item.completed).length,
notSubmitted: list.filter((item) => !item.completed),
students: list,
};
}
/** 只有教师 / 管理员 / 班级设备可以维护"未交名单"(普通学生只能标记自己) */
function assertCanManageSubmissions(user: TokenPayload): void {
if (user.role === 'ADMIN' || user.role === 'TEACHER') return;
if (isClassSession(user)) return;
throw ApiError.forbidden('只有教师或班级设备可以维护未交名单');
}
/** 读取作业提交名单(教师 / 班级设备) */
export async function listHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
): Promise<HomeworkSubmissionsDto> {
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
assertCanManageSubmissions(user);
return buildSubmissions(homework.classId, homeworkId);
}
/**
* 保存"未交名单":勾选的学生标记未完成,其余学生一律标记完成。
* 这样班级设备的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
*/
export async function updateHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
input: UpdateHomeworkSubmissionsInput,
): Promise<HomeworkSubmissionsDto> {
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
assertCanManageSubmissions(user);
const students = await prisma.user.findMany({
where: { classId: homework.classId, role: 'STUDENT' },
select: { id: true },
});
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生账号');
const notSubmitted = new Set(input.notSubmittedUserIds);
const studentIds = new Set(students.map((item) => item.id));
const outsiders = [...notSubmitted].filter((id) => !studentIds.has(id));
if (outsiders.length > 0) throw ApiError.badRequest('未交名单里包含非本班学生');
await prisma.$transaction(
students.map((item) =>
prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId: item.id } },
create: { homeworkId, userId: item.id, completed: !notSubmitted.has(item.id) },
update: { completed: !notSubmitted.has(item.id) },
}),
),
);
const result = await buildSubmissions(homework.classId, homeworkId);
// 实时刷新教师端/学生端的完成情况
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
homeworkId,
classId: homework.classId,
completedCount: result.completedCount,
total: result.total,
});
return result;
}
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
@@ -0,0 +1,385 @@
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageSchedule } from '../../lib/access.js';
import type { ClassPlanImportInput } from './imports.schemas.js';
/**
* ClassIsland「课程表(ClassPlan)」导入 —— 支持单双周。
*
* 真实档案 JSON(`Profiles/<档案名>.json`)的结构(PascalCase,均为 "Guid → 对象" 字典):
*
* ```jsonc
* {
* "TimeLayouts": { "<guid>": { "Name": "周一作息", "Layouts": [ { "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0 } ] } },
* "ClassPlans": { "<guid>": { "TimeLayoutId": "<guid>", "TimeRule": { "WeekDay": 1, "WeekCountDiv": 1, "WeekCountDivTotal": 2 }, "Classes": [ { "SubjectId": "<guid>" } ] } },
* "Subjects": { "<guid>": { "Name": "语文", "TeacherName": "王老师" } }
* }
* ```
*
* 关键语义(与 ClassIsland 源码一致):
* - `TimeRule.WeekDay`:0=周日、1=周一 … 6=周六;
* - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时);
* 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
* - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位;
* - 判周相位("第 1 周是单周还是双周")不在 JSON 里,由我们自己的学期起始周决定。
*/
/** 解析出来的单条课程(= 一个 Schedule 行) */
export interface ParsedClassPlanEntry {
/** 1=周一 … 7=周日(已从 ClassIsland 的 0=周日 转换) */
dayOfWeek: number;
startTime: string;
endTime: string;
/** 科目名(Subjects 字典里的 Name) */
subject: string;
/** 教师名(可选,仅预览展示) */
teacherName: string;
/** 单双周 */
weekParity: 'ALL' | 'ODD' | 'EVEN';
/** 原始 WeekCountDiv / WeekCountDivTotal,便于预览里解释来源 */
weekCountDiv: number;
weekCountDivTotal: number;
/** 来源课表名(ClassPlan.Name),方便老师核对 */
planName: string;
}
export interface ClassPlanParseResult {
entries: ParsedClassPlanEntry[];
/** 解析到的科目名(去重) */
subjects: string[];
/** 需要新建的科目(班级里还没有对应课程) */
missingSubjects: string[];
/** 时间表名(多个时间表时列出) */
layoutNames: string[];
errors: string[];
warnings: string[];
}
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/** "08:00:00" / "8:0" / 数字秒 → HH:mm */
function normalizeTime(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
const seconds = value > 100 ? value : value * 60;
const hour = Math.floor(seconds / 3600) % 24;
const minute = Math.floor((seconds % 3600) / 60);
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text) return null;
const match = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/.exec(text);
if (match) {
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
const parsed = new Date(text);
if (!Number.isNaN(parsed.getTime())) {
return `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`;
}
return null;
}
/**
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
* 只支持 2 周循环(=单双周);3 周及以上循环无法用单双周表达,记 warning 并按"每周"处理。
*/
function resolveParity(div: number, total: number): 'ALL' | 'ODD' | 'EVEN' {
if (div <= 0 || total <= 1) return 'ALL';
const position = ((div - 1) % total) + 1;
if (total === 2) return position === 1 ? 'ODD' : 'EVEN';
return 'ALL';
}
/** 解析 ClassIsland 档案 JSON(或只含 ClassPlans 的片段)里的课程表 */
export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult {
const result: ClassPlanParseResult = {
entries: [],
subjects: [],
missingSubjects: [],
layoutNames: [],
errors: [],
warnings: [],
};
let payload: unknown = input;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch (error) {
result.errors.push(`JSON 解析失败:${(error as Error).message}`);
return result;
}
}
const root = asRecord(payload);
if (!root) {
result.errors.push(
'无法识别的 JSON 结构:期望 ClassIsland 档案对象(含 TimeLayouts / ClassPlans / Subjects)',
);
return result;
}
// 科目字典:Guid → { Name, TeacherName }
const subjectDict = asRecord(root.Subjects) ?? {};
const subjectName = (id: string): string => {
const record = asRecord(subjectDict[id]);
return String(record?.Name ?? '').trim();
};
// 时间表字典:Guid → { Name, Layouts: [...] }
const layoutDict = asRecord(root.TimeLayouts) ?? {};
const layouts = new Map<string, { name: string; items: { start: string; end: string }[] }>();
for (const [id, value] of Object.entries(layoutDict)) {
const record = asRecord(value);
if (!record) continue;
const name = String(record.Name ?? '时间表').trim() || '时间表';
// 只取"上课"时间点(TimeType 0 / 缺省),与 ClassIsland 的 Classes[i] 对齐规则一致
const items = asArray(record.Layouts)
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => item !== null)
.filter((item) => {
const raw = item.TimeType ?? item.timeType ?? 0;
const numeric = typeof raw === 'number' ? raw : Number(raw);
return !Number.isFinite(numeric) || numeric === 0;
})
.map((item) => ({
start: normalizeTime(item.StartTime ?? item.StartSecond ?? item.startTime) ?? '',
end: normalizeTime(item.EndTime ?? item.EndSecond ?? item.endTime) ?? '',
}));
layouts.set(id, { name, items });
result.layoutNames.push(name);
}
// 兼容"只给了 ClassPlans 片段"的情况:此时从 Classes 里直接读时间(可选字段 StartTime/EndTime)
const planDict = asRecord(root.ClassPlans) ?? asRecord(root.classPlans) ?? root;
const plans = Object.entries(planDict).filter(([, value]) => {
const record = asRecord(value);
return Boolean(record && (record.TimeRule || record.timeRule || record.Classes || record.classes));
});
if (plans.length === 0) {
result.errors.push(
'没找到课表(ClassPlans):请确认选择的是 ClassIsland 档案 JSON,或包含 ClassPlans 的片段',
);
return result;
}
for (const [planId, value] of plans) {
const plan = asRecord(value);
if (!plan) continue;
const planName = String(plan.Name ?? plan.name ?? planId).trim() || '课表';
if (plan.IsEnabled === false || plan.isEnabled === false) continue;
if (plan.IsOverlay === true || plan.isOverlay === true) continue;
const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {};
const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0);
// ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日
const dayOfWeek = Number.isFinite(rawWeekDay)
? rawWeekDay === 0
? 7
: Math.min(7, Math.max(1, rawWeekDay))
: 1;
const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0;
const weekCountDivTotal = Number(rule.WeekCountDivTotal ?? rule.weekCountDivTotal ?? 2) || 2;
if (weekCountDivTotal > 2 && weekCountDiv > 0) {
result.warnings.push(
`${planName}:${weekCountDivTotal} 周轮换无法用"单双周"表达,已按每周处理(第 ${weekCountDiv} 周)`,
);
}
const weekParity = resolveParity(weekCountDiv, weekCountDivTotal);
const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? '');
const layout = layouts.get(layoutId) ?? (layouts.size > 0 ? [...layouts.values()][0] : undefined);
const timeItems = layout?.items ?? [];
if (timeItems.length === 0) {
result.warnings.push(`${planName}:没有可用的时间表节次,已跳过(请先导入该班的时间配置)`);
continue;
}
const classes = asArray(plan.Classes ?? plan.classes);
classes.forEach((item, index) => {
const info = asRecord(item);
if (!info) return;
if (info.IsEnabled === false || info.isEnabled === false) return;
const slot = timeItems[index];
if (!slot) return;
const subjectId = String(info.SubjectId ?? info.subjectId ?? '');
const subject = subjectName(subjectId);
if (!subject) {
result.warnings.push(`${planName} 第 ${index + 1} 节:科目未在 Subjects 里找到,已跳过`);
return;
}
if (!slot.start || !slot.end || slot.start >= slot.end) {
result.warnings.push(`${planName} 第 ${index + 1} 节:时间非法(${slot.start}-${slot.end}),已跳过`);
return;
}
const teacherRecord = asRecord(subjectDict[subjectId]);
result.entries.push({
dayOfWeek,
startTime: slot.start,
endTime: slot.end,
subject,
teacherName: String(teacherRecord?.TeacherName ?? ''),
weekParity,
weekCountDiv,
weekCountDivTotal,
planName,
});
});
}
if (result.entries.length === 0 && result.errors.length === 0) {
result.errors.push(
'没有解析出任何课程:请检查 TimeLayouts 与 ClassPlans 是否完整(Classes 与上课时间点一一对应)',
);
}
result.subjects = [...new Set(result.entries.map((item) => item.subject))];
return result;
}
/** 预览:解析 + 与现有课程比对(列出需要新建的科目) */
export async function previewClassPlan(
user: TokenPayload,
input: ClassPlanImportInput,
): Promise<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
await assertCanManageSchedule(user, input.classId);
const parsed = parseClassIslandClassPlan(input.payload);
const courses = await prisma.course.findMany({
where: { classId: input.classId },
select: { name: true },
});
const existing = new Set(courses.map((item) => item.name));
parsed.missingSubjects = parsed.subjects.filter((name) => !existing.has(name));
return {
...parsed,
entries: parsed.entries.map((item) => ({
...item,
weekdayLabel: WEEKDAY_LABELS[item.dayOfWeek % 7] ?? `周${item.dayOfWeek}`,
})),
};
}
/**
* 导入:把解析出的课程写成课表(Schedule),必要时自动补建缺失的课程。
* - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里);
* - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。
*/
export async function importClassPlan(
user: TokenPayload,
input: ClassPlanImportInput,
): Promise<{
imported: number;
created: number;
updated: number;
createdCourses: string[];
weekStart: number;
weekEnd: number;
}> {
await assertCanManageSchedule(user, input.classId);
const parsed = parseClassIslandClassPlan(input.payload);
if (parsed.entries.length === 0) {
throw new ApiError(400, 'IMPORT_INVALID', parsed.errors[0] ?? '没有可导入的课程');
}
const classRecord = await prisma.class.findUnique({
where: { id: input.classId },
select: { id: true, teacherId: true },
});
if (!classRecord) throw ApiError.notFound('班级不存在');
// 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周
const weekStart = input.termStartWeek ?? 1;
const weekEnd = 20;
const result = await prisma.$transaction(async (tx) => {
// 1) 补建缺失课程(用班主任作为课程任课老师)
const existingCourses = await tx.course.findMany({
where: { classId: input.classId },
select: { id: true, name: true },
});
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
const createdCourses: string[] = [];
for (const name of parsed.subjects) {
if (courseIdByName.has(name)) continue;
const created = await tx.course.create({
data: { classId: input.classId, name, teacherId: classRecord.teacherId },
select: { id: true, name: true },
});
courseIdByName.set(created.name, created.id);
createdCourses.push(created.name);
}
// 2) replace 模式:先清空该班课表
if (input.mode === 'replace') {
await tx.schedule.deleteMany({ where: { classId: input.classId } });
}
// 3) 写入课程(merge 时按 星期+开始时间+单双周 去重)
let created = 0;
let updated = 0;
for (const entry of parsed.entries) {
const courseId = courseIdByName.get(entry.subject);
if (!courseId) continue;
const existing = await tx.schedule.findFirst({
where: {
classId: input.classId,
dayOfWeek: entry.dayOfWeek,
startTime: entry.startTime,
weekParity: entry.weekParity,
},
select: { id: true },
});
if (existing) {
await tx.schedule.update({
where: { id: existing.id },
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
});
updated += 1;
} else {
await tx.schedule.create({
data: {
classId: input.classId,
courseId,
dayOfWeek: entry.dayOfWeek,
startTime: entry.startTime,
endTime: entry.endTime,
location: null,
weekStart,
weekEnd,
weekParity: entry.weekParity,
},
});
created += 1;
}
}
return { created, updated, createdCourses };
});
const total = result.created + result.updated;
logger.info(
`ClassIsland 课程表导入:班级=${input.classId} 模式=${input.mode} 新增=${result.created} 更新=${result.updated} ` +
`补建科目=[${result.createdCourses.join(',')}]`,
);
return {
imported: total,
created: result.created,
updated: result.updated,
createdCourses: result.createdCourses,
weekStart,
weekEnd,
};
}
@@ -7,12 +7,15 @@ import {
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
classPlanImportSchema,
timeLayoutQuerySchema,
type TableCommitInput,
type TableFileInput,
type TimeLayoutImportInput,
type ClassPlanImportInput,
} from './imports.schemas.js';
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
import { importClassPlan, previewClassPlan } from './class-plan.service.js';
import {
deleteTimeLayout,
importTimeLayout,
@@ -123,6 +126,33 @@ router.delete('/time-layout/:id', async (req, res) => {
sendOk(res, { id: req.params.id }, '时间配置已删除');
});
/**
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
*/
router.post('/class-plan/preview', validate({ body: classPlanImportSchema }), async (req, res) => {
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
);
});
/**
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
*/
router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req, res) => {
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
);
});
export const importsModule = defineModule({
name: 'imports',
basePath: '/imports',
@@ -46,6 +46,24 @@ export const timeLayoutQuerySchema = z.object({
classId: z.string().min(1, '请选择班级'),
});
/**
* ClassIsland 课程表(ClassPlan)导入:先预览再提交。
* 真实档案 JSON 里 TimeLayouts / ClassPlans / Subjects 都是 "Guid → 对象" 的字典,
* 单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
*/
export const classPlanImportSchema = z.object({
classId: z.string().min(1, '请选择班级'),
/** replace = 清空该班现有课表后写入;merge = 保留现有条目,只新增(同 星期+节次+单双周 视为重复则更新) */
mode: z.enum(['replace', 'merge']).default('replace'),
/** 学期起始周(用于计算单双周;不传则用服务端配置的开学日期推算) */
termStartWeek: z.coerce.number().int().min(1).max(60).optional(),
payload: z.union([
z.string().min(1, '请粘贴或选择 ClassIsland 导出的 JSON'),
z.record(z.string(), z.unknown()),
]),
});
export type TableFileInput = z.infer<typeof tableFileSchema>;
export type TableCommitInput = z.infer<typeof tableCommitSchema>;
export type TimeLayoutImportInput = z.infer<typeof timeLayoutImportSchema>;
export type ClassPlanImportInput = z.infer<typeof classPlanImportSchema>;
@@ -17,7 +17,7 @@ import type { TimeLayoutImportInput } from './imports.schemas.js';
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
*/
export type TimeLayoutItemType = 'class' | 'break' | 'divider';
export type TimeLayoutItemType = 'class' | 'break' | 'divider' | 'action';
export interface ParsedTimeLayoutItem {
index: number;
@@ -84,19 +84,47 @@ function secondsToHHmm(seconds: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/** 从任意形态里抠出"可能是时间表条目"的数组 */
/**
* 从任意形态里抠出"可能是时间表条目"的数组。
*
* ClassIsland 真实档案(`Profiles/<档案名>.json`,1.7.106+ / 2.x)里是:
* `{ "TimeLayouts": { "<guid>": { "Name": "默认", "Layouts": [ { StartTime, EndTime, TimeType } ] } } }`
* —— 即**按 Guid 索引的字典**,条目在各自的 `Layouts` 里。早期版本还可能直接是数组或 `{ items: [...] }`。
* 之前只认数组 / `.items`,导致真实导出文件完全导不进来(本次修复)。
*/
function extractItems(input: unknown): { items: unknown[]; shape: string } {
if (Array.isArray(input)) return { items: input, shape: 'array' };
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>;
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts', 'TimeLayout']) {
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts']) {
const value = record[key];
if (Array.isArray(value)) return { items: value, shape: key };
// 有些导出是 { TimeLayouts: { items: [...] } }
if (value && typeof value === 'object' && Array.isArray((value as Record<string, unknown>).items)) {
return { items: (value as Record<string, unknown>).items as unknown[], shape: `${key}.items` };
const nestedItems =
value && typeof value === 'object' ? (value as Record<string, unknown>).items : null;
if (Array.isArray(nestedItems)) {
return { items: nestedItems as unknown[], shape: `${key}.items` };
}
// ClassIsland 真实形态:Guid → { Name, Layouts: [...] };合并所有时间表的 Layouts
if (value && typeof value === 'object' && !Array.isArray(value)) {
const merged: unknown[] = [];
let layoutCount = 0;
for (const entry of Object.values(value as Record<string, unknown>)) {
const layoutRecord = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : null;
const layouts = layoutRecord?.Layouts ?? layoutRecord?.layouts;
if (Array.isArray(layouts)) {
merged.push(...layouts);
layoutCount += 1;
}
}
if (merged.length > 0) {
return { items: merged, shape: `${key}{guid}×${layoutCount}` };
}
}
}
// 单个时间表对象:{ Name, Layouts: [...] }
const single = record.Layouts ?? record.layouts;
if (Array.isArray(single)) return { items: single, shape: 'Layouts' };
}
return { items: [], shape: 'unknown' };
}
@@ -105,6 +133,7 @@ function normalizeType(record: Record<string, unknown>): TimeLayoutItemType {
const raw = pick(record, TYPE_KEYS);
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
if (numeric === 1) return 'break';
if (numeric === 3) return 'action'; // ClassIsland TimeType=3:行动(非上课/课间)
if (numeric === 2) return 'divider';
return 'class';
}
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
/** 单双周:ALL=每周 / ODD=单周 / EVEN=双周(与 ClassIsland 的 WeekCountDiv 语义一致) */
const weekParitySchema = z.enum(['ALL', 'ODD', 'EVEN']);
export const listSchedulesQuerySchema = z.object({
classId: z.string().min(1).optional(),
/** 不传则按"当前教学周"过滤 */
@@ -37,6 +40,8 @@ export const createScheduleSchema = z
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
/** 单双周(默认 ALL=每周) */
weekParity: weekParitySchema.optional(),
})
.refine((data) => data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
@@ -56,6 +61,8 @@ export const updateScheduleSchema = z
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
/** 单双周(默认 ALL=每周) */
weekParity: weekParitySchema.optional(),
})
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
@@ -1,8 +1,9 @@
import {
import {
SOCKET_EVENTS,
buildScheduleWeekView,
resolveClassStatus,
resolveCurrentWeek,
weekParityOf,
type ClassStatusDto,
type ScheduleDto,
type ScheduleWeekView,
@@ -43,6 +44,8 @@ export async function listSchedules(
where: {
...classScopeWhere(scope),
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
// 单双周:ALL 永远命中;ODD / EVEN 只在对应周次命中(第 1 周为单周)
...(week && week > 0 ? { weekParity: { in: ['ALL', weekParityOf(week)] } } : {}),
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
},
include: { course: courseSelect },
@@ -115,6 +118,8 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn
location: input.location ?? null,
weekStart: input.weekStart ?? 1,
weekEnd: input.weekEnd ?? 20,
// 单双周:默认 ALL(每周),与旧数据行为一致
weekParity: input.weekParity ?? 'ALL',
},
include: { course: courseSelect },
});
@@ -157,6 +162,7 @@ export async function updateSchedule(
...(input.location !== undefined ? { location: input.location ?? null } : {}),
weekStart,
weekEnd,
...(input.weekParity !== undefined ? { weekParity: input.weekParity } : {}),
},
include: { course: courseSelect },
});