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 = [
'学号,姓名,考试名称,分数,总分,科目',
+95 -25
View File
@@ -1,6 +1,7 @@
import {
dayKeyLocal,
resolveClassStatus,
type CourseBrief,
type ScheduleDto,
type ActiveClassPlanDto,
} from '@classhelper/shared';
@@ -20,30 +21,96 @@ export async function loadPlans(classId: string) {
orderBy: { updatedAt: 'desc' },
});
}
export function projectPlan(plan: PlanRow): ScheduleDto[] {
return plan.entries
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
.map<ScheduleDto>((e) => ({
id: e.legacyScheduleId || e.id,
classId: plan.classId,
courseId: e.courseId!,
course: e.course!,
dayOfWeek: plan.weekday || 1,
startTime: e.timePoint.startTime,
endTime: e.timePoint.endTime,
location: e.location,
weekStart: plan.weekStart,
weekEnd: plan.weekEnd,
weekParity:
plan.weekCountDivTotal === 2 && plan.weekCountDiv
? plan.weekCountDiv === 1
? 'ODD'
: 'EVEN'
: 'ALL',
source: 'manual',
createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.toISOString(),
}))
/** 时间点的默认课程映射(defaultClassId → 课程摘要)。 */
export type DefaultCourseMap = Map<string, CourseBrief>;
/**
* 一次性取出时间表里所有默认课程。
*
* 刻意不在 Prisma 里给 `TimeLayoutItem.defaultClassId` 加关系:该字段早已存在,
* 加外键要重建表;这里按 id 查一次课程即可,SQLite/MySQL 都通用。
*/
export async function loadDefaultCourseMap(
points: ReadonlyArray<{ defaultClassId: string | null }>,
): Promise<DefaultCourseMap> {
const ids = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
if (!ids.length) return new Map();
const courses = await prisma.course.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
});
return new Map(courses.map((course) => [course.id, course]));
}
/** 给时间表时间点附加默认课程,供 Web 编辑器与客户端读取。 */
export async function decorateLayout<
T extends { classId: string | null; points: Array<{ defaultClassId: string | null }> },
>(
layout: T,
): Promise<
Omit<T, 'points'> & { points: Array<T['points'][number] & { defaultCourse: CourseBrief | null }> }
> {
const defaults = await loadDefaultCourseMap(layout.points);
return {
...layout,
points: layout.points.map((point) => ({
...point,
// 全校时间表不能携带默认课程。
defaultCourse:
layout.classId && point.defaultClassId ? (defaults.get(point.defaultClassId) ?? null) : null,
})),
};
}
/**
* 日课表投影。
*
* 按**时间表的 LESSON 时间点**遍历,而不是只遍历已保存的条目:
* - 有条目:以条目为准(停用则整节不出现,课程为空则是显式空课,不回退);
* - 没有条目:回退时间点的默认课程。
* 默认课程的回退项没有 `ClassPlanEntry`,用 `default:<planId>:<pointId>` 作为稳定 id,
* 仅供读取展示;旧 `/schedules` 写接口不会命中它。
*/
export function projectPlan(plan: PlanRow, defaults: DefaultCourseMap = new Map()): ScheduleDto[] {
const entryOf = new Map(plan.entries.map((entry) => [entry.timeLayoutItemId, entry]));
return plan.layout.points
.filter((point) => point.timeType === 'LESSON')
.flatMap<ScheduleDto>((point) => {
const entry = entryOf.get(point.id);
if (entry && !entry.isEnabled) return [];
// 全校时间表不能携带默认课程:旧数据即使残留 DefaultClassId 也不参与回退,
// 否则会把这门课泄漏到别的班级。
const fallback =
!entry && point.defaultClassId && plan.layout.classId
? (defaults.get(point.defaultClassId) ?? null)
: null;
const course = entry ? (entry.course ?? null) : fallback;
if (!course) return [];
return [
{
id: entry?.legacyScheduleId || entry?.id || `default:${plan.id}:${point.id}`,
classId: plan.classId,
courseId: course.id,
course,
dayOfWeek: plan.weekday || 1,
startTime: point.startTime,
endTime: point.endTime,
location: entry?.location ?? null,
weekStart: plan.weekStart,
weekEnd: plan.weekEnd,
weekParity:
plan.weekCountDivTotal === 2 && plan.weekCountDiv
? plan.weekCountDiv === 1
? 'ODD'
: 'EVEN'
: 'ALL',
source: 'manual',
createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.toISOString(),
},
];
})
.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
@@ -103,8 +170,9 @@ export async function resolveActiveClassPlan(
candidates.find((p) => p.group?.isGlobal) ??
candidates.find((p) => !p.associatedGroup) ??
null;
const defaults = plan ? await loadDefaultCourseMap(plan.layout.points) : new Map<string, CourseBrief>();
const schedules = plan
? projectPlan(plan).map((s) => ({
? projectPlan(plan, defaults).map((s) => ({
...s,
dayOfWeek: weekday,
weekStart: week,
@@ -120,6 +188,8 @@ export async function resolveActiveClassPlan(
points: plan.layout.points.map((p) => ({
...p,
timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION',
defaultCourse:
plan.layout.classId && p.defaultClassId ? (defaults.get(p.defaultClassId) ?? null) : null,
})),
}
: null,
@@ -79,6 +79,13 @@ layouts.post(
);
},
);
layouts.post(
'/:id/points/:pointId/apply-default',
requirePermission('timetable:manage'),
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))),
);
layouts.delete(
'/:id',
requirePermission('timetable:manage'),
@@ -5,7 +5,7 @@ import { getMaxTermWeeks } from '../../lib/term.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { emitToClass } from '../../realtime/bus.js';
import { planInclude } from '../../lib/class-plan.js';
import { decorateLayout, planInclude } from '../../lib/class-plan.js';
import type { z } from 'zod';
import type {
planSchema,
@@ -26,14 +26,31 @@ export async function notifyScheduleChanged(classId?: string | null) {
export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId);
const ids = classId ? [classId] : await getAccessibleClassIds(user);
return prisma.timeLayout.findMany({
const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } },
});
return Promise.all(rows.map((row) => decorateLayout(row)));
}
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
if (id && !(await prisma.timeLayout.findUnique({ where: { id } }))) throw ApiError.notFound('时间表不存在');
const pointIds = input.points.flatMap((p) => (p.id ? [p.id] : []));
const existing = id
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
: null;
if (id && !existing) throw ApiError.notFound('时间表不存在');
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
const points = input.points.map((point) => ({
...point,
defaultClassId: point.timeType === 'LESSON' && layoutClassId ? (point.defaultClassId ?? null) : null,
}));
const defaultIds = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
if (defaultIds.length && layoutClassId) {
const count = await prisma.course.count({
where: { id: { in: defaultIds }, classId: layoutClassId },
});
if (count !== defaultIds.length) throw ApiError.badRequest('默认课程不属于该班级');
}
const pointIds = points.flatMap((p) => (p.id ? [p.id] : []));
if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复');
const result = await prisma.$transaction(async (tx) => {
const layout = id
@@ -48,7 +65,7 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
if (pointIds.some((p) => !owned.some((o) => o.id === p)))
throw ApiError.badRequest('时间点不属于这份时间表');
await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } });
for (const [position, point] of input.points.entries()) {
for (const [position, point] of points.entries()) {
const { id: pointId, ...data } = point;
if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } });
else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } });
@@ -59,7 +76,49 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
});
});
await notifyScheduleChanged();
return result;
return decorateLayout(result);
}
/**
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
*/
export async function applyDefaultCourse(layoutId: string, pointId: string) {
const layout = await prisma.timeLayout.findUnique({
where: { id: layoutId },
include: { points: true },
});
if (!layout) throw ApiError.notFound('时间表不存在');
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
const point = layout.points.find((p) => p.id === pointId);
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
if (!point.defaultClassId) throw ApiError.badRequest('请先设置默认课程');
const course = await prisma.course.findFirst({
where: { id: point.defaultClassId, classId: layout.classId },
select: { id: true },
});
if (!course) throw ApiError.badRequest('默认课程不属于该班级');
const plans = await prisma.classPlan.findMany({
where: { timeLayoutId: layout.id, classId: layout.classId },
select: { id: true },
});
const courseId = point.defaultClassId;
await prisma.$transaction(
plans.map((plan) =>
prisma.classPlanEntry.upsert({
where: { classPlanId_timeLayoutItemId: { classPlanId: plan.id, timeLayoutItemId: point.id } },
create: {
classPlanId: plan.id,
timeLayoutItemId: point.id,
courseId,
isEnabled: true,
},
update: { courseId, isEnabled: true },
}),
),
);
await notifyScheduleChanged(layout.classId);
return { plans: plans.length };
}
export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId);
@@ -17,6 +17,7 @@ import {
requireClassDeviceClassId,
} from '../../lib/access.js';
import {
loadDefaultCourseMap,
loadPlans,
planInclude,
projectPlan,
@@ -52,9 +53,11 @@ export async function listSchedules(
result.push(...(await resolveActiveClassPlan(classId, date)).schedules);
}
} else {
for (const plan of await loadPlans(classId))
const plans = await loadPlans(classId);
const defaults = await loadDefaultCourseMap(plans.flatMap((plan) => plan.layout.points));
for (const plan of plans)
if (!plan.isOverlay && (!options.dayOfWeek || plan.weekday === options.dayOfWeek))
result.push(...projectPlan(plan));
result.push(...projectPlan(plan, defaults));
}
}
const byId = new Map<string, ScheduleDto>();