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:
1 parent
86430c5f4a
commit
b8ef5d8dbb
34 files changed
+2351
-86
No files matched your search
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user