feat: 重做灵动岛编辑器与课表档案时间表编辑

灵动岛
- 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。
- 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。
- 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。
- preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。

课表档案
- 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。
- 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。
- 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。
- 「课间」统一改称「课间休息」。

验证
- pnpm typecheck、pnpm lint 通过。
- verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。
- ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
This commit is contained in:
LoveChengke committed 2026-10-25 16:41:29 +08:00
1 parent 938285bb9c
commit 682818dd4a
41 files changed
+4349 -1095

No files matched your search

+108
View File
@@ -2224,6 +2224,114 @@ async function main() {
`status=${studentLayoutList.status}`,
);
// 8.2.1 时间点默认课程:随布局返回、无条目时回退、覆盖现有课程写入条目
if (layoutId && courseId) {
const defaultCourseName = courses.payload?.data?.[0]?.name ?? '';
const savedLayout = await api(`/time-layouts/${layoutId}`, {
method: 'PUT',
token: adminToken,
body: {
name: '默认时间表',
classId,
points: [
{
name: '第 1 节',
startTime: '08:00',
endTime: '08:40',
timeType: 'LESSON',
defaultClassId: courseId,
isHideDefault: false,
breakName: '',
actionSet: '{}',
},
{
name: '课间',
startTime: '08:40',
endTime: '08:50',
timeType: 'BREAK',
breakName: '课间休息',
actionSet: '{}',
},
],
},
});
const savedPoints = savedLayout.payload?.data?.points ?? [];
const lessonPoint = savedPoints.find((point) => point.timeType === 'LESSON');
record(
'时间表保存默认课程并随布局返回',
savedLayout.status === 200 &&
lessonPoint?.defaultClassId === courseId &&
lessonPoint?.defaultCourse?.name === defaultCourseName,
`status=${savedLayout.status} default=${lessonPoint?.defaultCourse?.name ?? '-'}`,
);
const defaultPlan = await api('/class-plans', {
method: 'POST',
token: teacherToken,
body: {
classId,
name: '默认课程验证课表',
timeLayoutId: layoutId,
weekday: 0,
weekCountDiv: 0,
weekCountDivTotal: 2,
weekStart: 1,
weekEnd: 30,
isEnabled: true,
},
});
const defaultPlanId = defaultPlan.payload?.data?.id ?? null;
const activeDefault = await api(`/schedules/active-week?classId=${classId}&week=1`, {
token: teacherToken,
});
const fallback = (activeDefault.payload?.data?.days ?? [])
.flatMap((day) => day.schedules ?? [])
.find((item) => item.course?.name === defaultCourseName && String(item.id).startsWith('default:'));
record(
'日课表没有单独条目时回退时间点默认课程',
activeDefault.status === 200 && Boolean(fallback),
`status=${activeDefault.status} fallback=${fallback ? fallback.course?.name : '-'}`,
);
if (lessonPoint?.id) {
const applied = await api(`/time-layouts/${layoutId}/points/${lessonPoint.id}/apply-default`, {
method: 'POST',
token: adminToken,
});
const plansAfterApply = await api(`/class-plans?classId=${classId}`, { token: teacherToken });
const appliedPlan = (plansAfterApply.payload?.data ?? []).find((item) => item.id === defaultPlanId);
const appliedEntry = appliedPlan?.entries?.find((entry) => entry.timeLayoutItemId === lessonPoint.id);
record(
'覆盖现有课程写入所有引用课表',
applied.status === 200 &&
(applied.payload?.data?.plans ?? 0) >= 1 &&
appliedEntry?.courseId === courseId,
`status=${applied.status} plans=${applied.payload?.data?.plans ?? 0}`,
);
}
if (defaultPlanId) {
await api(`/class-plans/${defaultPlanId}`, { method: 'DELETE', token: teacherToken });
}
}
// 8.2.2 时间表可通过新路由删除(Web 时间表编辑器使用的接口)
const removableLayout = await api('/time-layouts', {
method: 'POST',
token: adminToken,
body: { name: '临时删除验证', classId, points: [] },
});
const removableId = removableLayout.payload?.data?.id ?? null;
const removedLayout = removableId
? await api(`/time-layouts/${removableId}`, { method: 'DELETE', token: adminToken })
: { status: 0 };
record(
'时间表可通过新路由删除',
removableLayout.status === 201 && removedLayout.status === 200,
`创建=${removableLayout.status} 删除=${removedLayout.status}`,
);
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [
'学号,姓名,考试名称,分数,总分,科目',