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:
1 parent
938285bb9c
commit
682818dd4a
41 files changed
+4349
-1095
No files matched your search
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user