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

+111 -13
View File
@@ -1363,15 +1363,40 @@ async function runPlanEditorChecks(win) {
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
const ready = await waitFor(
win,
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
`Boolean(document.querySelector('.course-table')) && Boolean(document.querySelector('.plan-explorer')) && Boolean(document.querySelector('.subject-palette'))`,
);
record(
'课表档案在管理端页面内展示周课表和选科侧栏',
'课表档案默认进入单表编辑并展示选科侧栏',
ready &&
(await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4 && getComputedStyle(document.querySelector('.archive-editor')).position !== 'fixed' && Boolean(document.querySelector('.archive-page .page-title'))`,
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4 && getComputedStyle(document.querySelector('.archive-editor')).position !== 'fixed' && Boolean(document.querySelector('.archive-page .page-title')) && Boolean(document.querySelector('.course-table-head'))`,
)),
);
await waitFor(win, `!document.body.innerText.includes('正在加载')`);
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-list.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(
`document.querySelector('.inspector-tabs button:nth-child(2)').click()`,
);
record(
'课表信息面板可编辑触发规则',
await waitFor(
win,
`Boolean(document.querySelector('.plan-details')) && document.body.innerText.includes('启用星期') && document.body.innerText.includes('轮周设定') && document.body.innerText.includes('生效周次')`,
),
);
await win.webContents.executeJavaScript(
`document.querySelector('.inspector-tabs button:first-child').click()`,
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
const weekReady = await waitFor(
win,
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
);
record('底部视图切换可进入周课表', weekReady);
const assignment = await win.webContents.executeJavaScript(`(() => {
const cell = document.querySelector('.lesson-cell');
if (!cell) return null;
@@ -1416,6 +1441,7 @@ async function runPlanEditorChecks(win) {
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`,
),
);
await waitFor(win, `!document.querySelector('.archive-tabs button:nth-child(2)')?.disabled`);
await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:nth-child(2)').click()`,
);
@@ -1423,22 +1449,77 @@ async function runPlanEditorChecks(win) {
'时间表页四种时间点与保存入口可见',
await waitFor(
win,
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动')`,
`document.body.innerText.includes('课间休息') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动') && Boolean(document.querySelector('.layout-toolbar'))`,
),
);
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
const before = document.querySelectorAll('.time-row').length;
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click();
await new Promise(resolve => setTimeout(resolve, 180));
const rows = document.querySelectorAll('.time-row');
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value);
const minutes = (value) => { const [h, m] = (value || '').split(':').map(Number); return h * 60 + m; };
return { before, after: rows.length, diff: minutes(values[1]) - minutes(values[0]) };
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const token = localStorage.getItem('classhelper.token');
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
};
const classes = await fetch('/api/classes', { headers }).then(r => r.json()).catch(() => null);
const pickedName = document.querySelector('.class-picker input')?.value ?? '';
const klass = (classes?.data ?? []).find(c => c.name === pickedName) ?? classes?.data?.[0];
let created = null;
if (klass) {
// 探针课表可能占满全天;用接口建一张空时间表作为可逆验收数据,随后由 UI 刷新选中。
created = await fetch('/api/time-layouts', {
method: 'POST',
headers,
body: JSON.stringify({ name: 'UI 冒烟时间表', classId: klass.id, points: [] }),
}).then(r => r.json()).catch(() => null);
}
[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();
await wait(200);
if (document.querySelector('.el-message-box'))
document.querySelector('.el-message-box__btns .el-button--primary')?.click();
for (let i = 0; i < 30; i++) {
await wait(100);
if ([...document.querySelectorAll('.layout-explorer .plan-item')].some(b => b.textContent.includes('UI 冒烟时间表'))) break;
}
[...document.querySelectorAll('.layout-explorer .plan-item')].find(b => b.textContent.includes('UI 冒烟时间表'))?.click();
await wait(250);
const before = document.querySelectorAll('.time-point-row').length;
const addButton = [...document.querySelectorAll('.layout-toolbar button')].find(b => b.textContent.trim() === '上课');
addButton?.click();
await wait(200);
const rows = document.querySelectorAll('.time-point-row');
const timeText = document.querySelector('.time-point-row.selected .point-time')?.textContent?.trim() ?? '';
const match = timeText.match(/(\\d{2}:\\d{2})\\s*[–-]\\s*(\\d{2}:\\d{2})/);
const minutes = (value) => { const [h, m] = value.split(':').map(Number); return h * 60 + m; };
return {
before,
after: rows.length,
diff: match ? minutes(match[2]) - minutes(match[1]) : -1,
timeText,
klass: klass?.name ?? '',
created: created?.data?.id ?? null,
addDisabled: addButton ? addButton.disabled : null,
activeLayout: document.querySelector('.layout-explorer .plan-item.active strong')?.textContent?.trim() ?? '',
};
})()`);
record(
'时间表新建课时按快速时长生成起止时间',
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
'时间表新建上课点按默认 40 分钟生成起止时间',
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 40,
JSON.stringify(quickAdd),
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="时间轴视图"]').click()`);
record(
'时间表时间轴视图与右侧时间点详情可用',
await waitFor(
win,
`Boolean(document.querySelector('.time-block')) && Boolean(document.querySelector('.point-inspector')) && Boolean(document.querySelector('.timeline-ruler'))`,
),
);
await sleep(400);
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-timeline.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`,
);
@@ -1457,11 +1538,28 @@ async function runPlanEditorChecks(win) {
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`));
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
await waitFor(win, `Boolean(document.querySelector('.course-table'))`);
await sleep(300);
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
(await win.webContents.capturePage()).toPNG(),
);
if (quickAdd.created) {
// 清理本次新建的临时时间表,避免开发库留下验收数据。
await win.webContents.executeJavaScript(`(async () => {
const token = localStorage.getItem('classhelper.token');
try {
await fetch('/api/time-layouts/' + ${JSON.stringify(quickAdd.created)}, {
method: 'DELETE',
headers: token ? { Authorization: 'Bearer ' + token } : {},
});
} catch {
// 清理失败不阻断验收(隔离库会整体丢弃)。
}
})()`);
}
await win.webContents.executeJavaScript(
`document.querySelector('.management-menu [data-path="/dashboard"]').click()`,
);