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

@@ -0,0 +1,4 @@
-- 课表支持单双周:新增 weekParity(ALL=每周 / ODD=单周 / EVEN=双周)
-- 旧数据默认为 'ALL',行为与升级前完全一致(向后兼容)。
ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL';
+2
View File
@@ -137,6 +137,8 @@ model Schedule {
weekStart Int @default(1)
/// 学期结束周次(含)
weekEnd Int @default(20)
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
weekParity String @default("ALL")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+22
View File
@@ -169,6 +169,8 @@ async function main(): Promise<void> {
if (!course) continue;
// 前 2 节限制在第 1 周(演示按周筛选)
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
await prisma.schedule.create({
data: {
classId: targetClass.id,
@@ -179,8 +181,28 @@ async function main(): Promise<void> {
location: targetClass.room,
weekStart: 1,
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
weekParity: paritySlot ? 'ODD' : 'ALL',
},
});
if (paritySlot) {
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
if (evenCourse) {
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: evenCourse.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: MAX_TERM_WEEK,
weekParity: 'EVEN',
},
});
}
}
}
}
}
@@ -0,0 +1,62 @@
/**
* 维护脚本:给"已有数据库"补上 Schedule.weekParity 列(单双周课表)。
*
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
* 部署,就需要显式跑一次本脚本。
*
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
*
* node apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"
*
* 环境变量(与服务端一致):
* DATABASE_PROVIDER=sqlite
* DATABASE_URL="file:<绝对路径>/classhelper.db"
*/
const { pathToFileURL } = require('node:url');
const dbEntry = process.argv[2];
if (!dbEntry) {
console.error('用法:node apply-parity-column.cjs <已编译的 lib/db.js 绝对路径>');
process.exit(2);
}
(async () => {
let prisma;
try {
const module = await import(pathToFileURL(dbEntry).href);
prisma = module.prisma ?? module.default?.prisma;
} catch (error) {
console.error(
'[parity] 无法加载服务端的 Prisma 客户端:',
error && error.message ? error.message : error,
);
process.exitCode = 1;
return;
}
if (!prisma) {
console.error('[parity] lib/db.js 里没有导出 prisma');
process.exitCode = 1;
return;
}
try {
const columns = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")');
if (columns.some((column) => column.name === 'weekParity')) {
console.log('[parity] Schedule.weekParity 已存在,无需处理');
} else {
await prisma.$executeRawUnsafe(
`ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
);
console.log('[parity] 已新增 Schedule.weekParity(默认 ALL,旧数据行为不变)');
}
const after = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")');
console.log(`[parity] 当前列:${after.map((column) => column.name).join(', ')}`);
} catch (error) {
console.error('[parity] 失败:', error && error.message ? error.message : error);
process.exitCode = 1;
} finally {
await prisma.$disconnect?.();
}
})();
+246
View File
@@ -143,6 +143,199 @@ async function main() {
`第 1 周 ${totalSlots} 节课`,
);
/* ---------------------------------------------------------------- 2.1 单双周课表 */
// 第 1 周是单周、第 2 周是双周(与 ClassIsland 的 WeekCountDiv 语义一致)
const parityCourseId = courseId ?? courses.payload?.data?.[0]?.id;
const parityCreate = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId: parityCourseId,
dayOfWeek: 6,
startTime: '07:00',
endTime: '07:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'ODD',
location: '单双周校验 · 单周',
},
});
record(
'创建单周课表(weekParity=ODD)',
parityCreate.status === 201 && parityCreate.payload?.data?.weekParity === 'ODD',
`status=${parityCreate.status} weekParity=${parityCreate.payload?.data?.weekParity ?? '-'}`,
);
const parityId = parityCreate.payload?.data?.id;
const oddGrid = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
const evenGrid = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
const inGrid = (payload) =>
(payload?.data?.columns ?? []).some((col) => (col.items ?? []).some((item) => item.id === parityId));
record(
'单周课表只出现在单周(week=1 有、week=2 无)',
oddGrid.status === 200 && evenGrid.status === 200 && inGrid(oddGrid.payload) && !inGrid(evenGrid.payload),
`week1=${inGrid(oddGrid.payload)} week2=${inGrid(evenGrid.payload)}`,
);
// 补一条双周课(同一时段),验证两周各出现一节、互不冲突
const evenCreate = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId: parityCourseId,
dayOfWeek: 6,
startTime: '07:00',
endTime: '07:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'EVEN',
location: '单双周校验 · 双周',
},
});
const evenId = evenCreate.payload?.data?.id;
const oddGrid2 = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
const evenGrid2 = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
const countAt = (payload, id) =>
(payload?.data?.columns ?? []).reduce(
(sum, col) => sum + (col.items ?? []).filter((item) => item.id === id).length,
0,
);
record(
'同一时段的单周/双周课各归各周(互不串周)',
evenCreate.status === 201 &&
countAt(oddGrid2.payload, parityId) === 1 &&
countAt(oddGrid2.payload, evenId) === 0 &&
countAt(evenGrid2.payload, parityId) === 0 &&
countAt(evenGrid2.payload, evenId) === 1,
`week1 单周=${countAt(oddGrid2.payload, parityId)} 双周=${countAt(oddGrid2.payload, evenId)};` +
`week2 单周=${countAt(evenGrid2.payload, parityId)} 双周=${countAt(evenGrid2.payload, evenId)}`,
);
/* ---------------------------------------------------------------- 2.2 ClassIsland 课程表导入(含单双周) */
// 真实档案结构:TimeLayouts / ClassPlans / Subjects 都是 Guid 字典,单双周=两条 ClassPlan
const classIslandProfile = {
Name: '导入校验档案',
TimeLayouts: {
'11111111-1111-4111-8111-111111111111': {
Name: '周一作息',
Layouts: [
{ StartTime: '08:00:00', EndTime: '08:45:00', TimeType: 0, BreakName: '' },
{ StartTime: '08:45:00', EndTime: '08:55:00', TimeType: 1, BreakName: '课间' },
{ StartTime: '08:55:00', EndTime: '09:40:00', TimeType: 0, BreakName: '' },
],
},
},
ClassPlans: {
'aaaaaaaa-0001-4000-8000-000000000001': {
Name: '周一-单周',
TimeLayoutId: '11111111-1111-4111-8111-111111111111',
TimeRule: { WeekDay: 1, WeekCountDiv: 1, WeekCountDivTotal: 2 },
Classes: [
{ SubjectId: 'bbbbbbbb-0001-4000-8000-000000000001', IsEnabled: true },
{ SubjectId: 'bbbbbbbb-0002-4000-8000-000000000002', IsEnabled: true },
],
},
'aaaaaaaa-0002-4000-8000-000000000002': {
Name: '周一-双周',
TimeLayoutId: '11111111-1111-4111-8111-111111111111',
TimeRule: { WeekDay: 1, WeekCountDiv: 2, WeekCountDivTotal: 2 },
Classes: [
{ SubjectId: 'bbbbbbbb-0001-4000-8000-000000000001', IsEnabled: true },
{ SubjectId: 'bbbbbbbb-0003-4000-8000-000000000003', IsEnabled: true },
],
},
},
Subjects: {
'bbbbbbbb-0001-4000-8000-000000000001': { Name: '导入校验语文', TeacherName: '王芳' },
'bbbbbbbb-0002-4000-8000-000000000002': { Name: '导入校验数学', TeacherName: '李梅' },
'bbbbbbbb-0003-4000-8000-000000000003': { Name: '导入校验英语', TeacherName: '张伟' },
},
};
const classPlanPreview = await api('/imports/class-plan/preview', {
method: 'POST',
token: teacherToken,
body: { classId, mode: 'merge', payload: classIslandProfile },
});
const previewEntries = classPlanPreview.payload?.data?.entries ?? [];
record(
'ClassIsland 课程表预览:解析出 4 节(单周 2 / 双周 2)',
classPlanPreview.status === 200 &&
previewEntries.length === 4 &&
previewEntries.filter((item) => item.weekParity === 'ODD').length === 2 &&
previewEntries.filter((item) => item.weekParity === 'EVEN').length === 2 &&
previewEntries.every((item) => item.dayOfWeek === 1),
`status=${classPlanPreview.status} 共 ${previewEntries.length} 节 · ` +
`单周 ${previewEntries.filter((item) => item.weekParity === 'ODD').length} / ` +
`双周 ${previewEntries.filter((item) => item.weekParity === 'EVEN').length}` +
`${classPlanPreview.payload?.data?.errors?.length ? ` 错误=${classPlanPreview.payload.data.errors.join(';')}` : ''}`,
);
record(
'课程表预览会列出需要自动补建的科目',
(classPlanPreview.payload?.data?.missingSubjects ?? []).length === 3,
`待建科目=[${(classPlanPreview.payload?.data?.missingSubjects ?? []).join('、')}]`,
);
const classPlanImport = await api('/imports/class-plan', {
method: 'POST',
token: teacherToken,
body: { classId, mode: 'merge', payload: classIslandProfile },
});
const importResult = classPlanImport.payload?.data;
record(
'导入 ClassIsland 课程表:4 节写入并自动补建 3 门课程',
classPlanImport.status === 200 &&
(importResult?.created ?? 0) + (importResult?.updated ?? 0) === 4 &&
(importResult?.createdCourses ?? []).length === 3,
`新增=${importResult?.created ?? '-'} 更新=${importResult?.updated ?? '-'} ` +
`补建=[${(importResult?.createdCourses ?? []).join('、')}]`,
);
// 导入结果里单双周必须落库:第 1 周只有单周两节、第 2 周只有双周两节
const oddAfterImport = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
const evenAfterImport = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
const importedAt = (payload, parity) =>
(payload?.data?.columns ?? []).reduce(
(sum, col) =>
sum +
(col.items ?? []).filter(
(item) =>
item.location === null &&
item.weekParity === parity &&
item.dayOfWeek === 1 &&
item.startTime < '09:00',
).length,
0,
);
record(
'导入后的单双周课表按周正确落位(第 1 周 2 节单周、第 2 周 2 节双周)',
importedAt(oddAfterImport.payload, 'ODD') >= 2 &&
importedAt(evenAfterImport.payload, 'ODD') === 0 &&
importedAt(evenAfterImport.payload, 'EVEN') >= 2 &&
importedAt(oddAfterImport.payload, 'EVEN') === 0,
`week1 单周=${importedAt(oddAfterImport.payload, 'ODD')} 双周=${importedAt(oddAfterImport.payload, 'EVEN')};` +
`week2 单周=${importedAt(evenAfterImport.payload, 'ODD')} 双周=${importedAt(evenAfterImport.payload, 'EVEN')}`,
);
// 清理:按"位置/科目名前缀"识别本次写入的数据,避免误删演示课表
const allSchedules = await api(`/schedules?classId=${classId}`, { token: teacherToken });
let removedSchedules = 0;
for (const item of allSchedules.payload?.data ?? []) {
const isParityProbe = String(item.location ?? '').startsWith('单双周校验');
const isImported = String(item.course?.name ?? '').startsWith('导入校验');
if (!isParityProbe && !isImported) continue;
const removed = await api(`/schedules/${item.id}`, { method: 'DELETE', token: teacherToken });
if (removed.status === 200) removedSchedules += 1;
}
const allCourses = await api(`/courses?classId=${classId}`, { token: teacherToken });
let removedImportedCourses = 0;
for (const course of allCourses.payload?.data ?? []) {
if (!String(course.name).startsWith('导入校验')) continue;
const removed = await api(`/courses/${course.id}`, { method: 'DELETE', token: teacherToken });
if (removed.status === 200) removedImportedCourses += 1;
}
record(
'清理单双周校验与导入数据',
removedSchedules >= 6,
`删除课表 ${removedSchedules} 条、课程 ${removedImportedCourses} 门`,
);
// ---------------------------------------------------------------- 3. WebSocket 连接
const socket = io(BASE_URL, {
auth: { token: studentToken, clientType: 'desktop' },
@@ -227,6 +420,59 @@ async function main() {
`status=${statusUpdate.status}`,
);
/* ---------------------------------------------------------------- 5.1 未交名单 */
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId);
record(
'教师可读取作业提交名单(全班学生 + 完成状态)',
submissions.status === 200 &&
studentIds.length > 0 &&
(submissions.payload?.data?.total ?? 0) === studentIds.length,
`status=${submissions.status} 全班 ${submissions.payload?.data?.total ?? '-'} 人 · ` +
`已完成 ${submissions.payload?.data?.completedCount ?? '-'} 人`,
);
// 勾选"未交名单":只留第一位学生未交,其余一律视为已交
const firstStudentId = studentIds[0];
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: teacherToken,
body: { notSubmittedUserIds: [firstStudentId] },
});
record(
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
savedSubmissions.status === 200 &&
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId &&
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
);
// 全部已交:未交名单清空
const allSubmitted = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: teacherToken,
body: { notSubmittedUserIds: [] },
});
record(
'未交名单可一键清空(全部已交)',
allSubmitted.status === 200 &&
(allSubmitted.payload?.data?.notSubmitted ?? []).length === 0 &&
allSubmitted.payload?.data?.completedCount === studentIds.length,
`未交=${(allSubmitted.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${allSubmitted.payload?.data?.completedCount ?? '-'}/${allSubmitted.payload?.data?.total ?? '-'}`,
);
// 越权:普通学生不能维护未交名单(只能标记自己)
const studentSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: studentToken,
body: { notSubmittedUserIds: [] },
});
record(
'普通学生不能维护未交名单(403)',
studentSubmissions.status === 403,
`status=${studentSubmissions.status}`,
);
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
record(
+2
View File
@@ -153,6 +153,7 @@ export interface ScheduleLike {
location: string | null;
weekStart: number;
weekEnd: number;
weekParity: string;
createdAt: Date;
course?: CourseBriefLike | null;
}
@@ -168,6 +169,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
location: item.location,
weekStart: item.weekStart,
weekEnd: item.weekEnd,
weekParity: (item.weekParity ?? 'ALL') as ScheduleDto['weekParity'],
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
};
@@ -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 },
});