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

+15 -2
View File
@@ -36,8 +36,13 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
## 编辑自定义方案 ## 编辑自定义方案
“设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。 “设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。
方案支持复制、删除、JSON 导入导出;组件支持日期、时钟、倒计时、课程、文本、分隔符、通知/作业/叫人及组合容器。 页面上方排列当前组件,右上角选择配置方案;刷新图标重置当前方案,加号新建方案,文件夹菜单提供方案设置、复制、删除及 JSON 导入导出。
分别编辑常态 rows 与无消息展开 expandedRows,拖动调整结构,设置内容、外观、尺寸与隐藏条件。 下方组件库常驻显示文本、分割线、课程表、日期、时钟、天气简报、倒计时和组合容器,并保留班级通知、作业和叫人组件。
从组件库拖入上方的行或容器以添加,拖动已有组件调整顺序或跨行移动;也可点击组件库添加到选中的容器或当前行。
分别编辑常态 rows 与无消息展开 expandedRows,点选组件打开内容、外观、尺寸与隐藏条件设置;行可以新增、排序、设为主要行或删除。
“进入编辑模式”切换为实际内容预览,支持预览缩放;退出后回到组件标签排列。轮播和滚动容器在编辑时展示全部子组件,便于选择和排列。
倒计时可指定目标名称与日期,也可按课表倒数至上课、下课或放学。
天气简报需填写地点名称及经纬度,使用 [Open-Meteo](https://open-meteo.com/en/docs) 的天气数据,每 15 分钟更新;断网时保留本次客户端运行中的缓存并标明“缓存”。天气提示根据天气概况生成,不提供官方气象预警。
方案保存到本机 config.json.islandLayout,不增加服务端同步。 方案保存到本机 config.json.islandLayout,不增加服务端同步。
编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。 编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。
保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。 保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。
@@ -56,6 +61,14 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
## 课程与课件 ## 课程与课件
准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。 准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。
自定义常驻岛的「课程表」组件可选「当前时间点 / 下一节课 / 今日时间点」:
「今日时间点」显示当天全部时间点,只有当前项展开显示起止时间,其余折叠只显示课程名;
课间休息与「默认隐藏」的上课时间点折叠时完全隐藏,轮到它们时才展开;分割线显示一条竖线,行动不显示。
课中课程表不再常驻显示倒计时,只在最后一分钟由既有的「下课提醒」页显示「距下课还剩」;
倒计时阈值沿用本机提醒设置(默认 60 秒)。没有时间点数据时退回按课表条目显示课程。
经典 fixed 岛的课中提醒条保持原有倒计时行为。
课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。 课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。
客户端不会自动执行收到的文件,见[课件](courseware.md)。 客户端不会自动执行收到的文件,见[课件](courseware.md)。
+5 -3
View File
@@ -8,11 +8,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。
Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。 上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的课表交互实现,作为管理端内的普通页面展示,沿用全站的导航、间距、控件及浅色/深色主题。顶部切换课表、时间表、科目和调课;默认以周网格展示课程,右侧提供科目简称面板,底部可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一页面,侧栏和顶部导航始终可用。 编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的课表交互实现,作为管理端内的普通页面展示,沿用全站的导航、间距、控件及浅色/深色主题。顶部切换课表、时间表、科目和调课;课表页默认进入单表编辑:左侧按课表群分组的课表列表,中间逐节选择科目,右侧切换「科目 / 课表信息」。底部仍可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一页面,侧栏和顶部导航始终可用。
点击每天列头的「新课表」选择时间表并创建日课表。点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在「详细信息」中维护。 点击每天列头的「新课表」选择时间表并创建日课表。周视图点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在右侧「课表信息」中维护。列表视图提供批量排课、复制粘贴、拖动交换课程等操作。
列表视图保留批量排课、复制粘贴、拖动交换课程等操作;时间表页支持拖动时间点排序、任意分钟输入,以及上课、课间、分割线、行动四种时间点。设置的课程/课间分钟数用于新建时间点的默认起止时间。编辑完成点击「保存」或按 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。 时间表页采用 ClassIsland 式编辑器:左侧时间表列表(新建、复制、搜索),顶部按类型添加上课、课间休息、分割线、行动,中间可在列表视图与时间轴视图之间切换,右侧编辑选中时间点,左下设置默认上课/课间时长,右下缩放。时间轴视图可按分钟拖动时间点的开始/结束把柄,并按 1 分钟取整;列表视图可拖动排序。保存仍走页面顶部「保存」或 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。
上课时间点可以设置一个默认课程。日课表里**没有单独条目**的课时会回退到该默认课程;已有条目(包括显式空课)不会被覆盖。「覆盖现有课程」会把这门课写入所有引用该时间表的日课表。默认隐藏的上课时间点只在轮到时才在主界面显示;课间休息类型代表下课时间,折叠时完全隐藏,轮到它时才展开。
完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。 完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -3,10 +3,12 @@ import type { IslandComponentKind } from '@classhelper/shared';
defineProps<{ kind: IslandComponentKind }>(); defineProps<{ kind: IslandComponentKind }>();
const paths: Record<IslandComponentKind, string> = { const paths: Record<IslandComponentKind, string> = {
clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2', clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2',
weather:
'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5',
date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2', date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2',
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1', schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4', countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4',
text: 'M5 6V4h14v2M12 4v16m-4 0h8', text: 'M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm1 5h10M7 12h7m-7 4h10',
separator: 'M12 4v16M5 8v8m14-8v8', separator: 'M12 4v16M5 8v8m14-8v8',
notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z', notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z',
homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5', homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5',
@@ -5,13 +5,16 @@ import {
resolveClassStatus, resolveClassStatus,
type IslandComponentNode, type IslandComponentNode,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import WeatherBrief from './WeatherBrief.vue';
const props = defineProps<{ const props = defineProps<{
node: IslandComponentNode; node: IslandComponentNode;
state: IslandState; state: IslandState;
now: number; now: number;
schedules: ScheduleDto[]; schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
editing: boolean; editing: boolean;
selectedId: string; selectedId: string;
alertNodeId: string; alertNodeId: string;
@@ -38,20 +41,69 @@ function timeAt(time: string) {
d.setHours(h!, m!, 0, 0); d.setHours(h!, m!, 0, 0);
return d.getTime(); return d.getTime();
} }
const elapsed = computed(() => /**
current.value * 今天的时间点:优先用服务端下发的完整时间表(含课间/分割线/默认隐藏),
? Math.max( * 没有时回退到课表条目,保证旧服务端与离线缓存仍能显示课程。
0, */
Math.min( const dayPoints = computed<IslandTimePointDto[]>(() => {
1, const source = props.timePoints.length
(props.now - timeAt(current.value.startTime)) / ? props.timePoints
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)), : today.value.map<IslandTimePointDto>((s) => ({
), id: s.id,
) timeType: 'LESSON',
: 0, name: '',
courseName: s.course?.name ?? null,
startTime: s.startTime,
endTime: s.endTime,
isHideDefault: false,
}));
return [...source].sort((a, b) => a.startTime.localeCompare(b.startTime));
});
const currentPoint = computed(
() =>
dayPoints.value.find(
(point) =>
point.timeType !== 'DIVIDER' &&
point.timeType !== 'ACTION' &&
props.now >= timeAt(point.startTime) &&
props.now < timeAt(point.endTime),
) ?? null,
); );
const currentLesson = computed(() => (currentPoint.value?.timeType === 'LESSON' ? currentPoint.value : null));
/** 课程表组件按 ClassIsland 主界面的折叠/展开规则挑选时间点。 */
const schedulePoints = computed<IslandTimePointDto[]>(() => {
const display = props.node.settings.display;
if (display === 'next')
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
if (display === 'current') {
if (currentPoint.value) return [currentPoint.value];
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
}
const visible = dayPoints.value.filter(
(point) =>
point.timeType === 'DIVIDER' ||
point.id === currentPoint.value?.id ||
(point.timeType === 'LESSON' && !point.isHideDefault),
);
const count = Math.max(1, props.node.settings.count);
if (visible.length <= count) return visible;
const index = visible.findIndex((point) => point.id === currentPoint.value?.id);
const start = Math.max(0, Math.min(index - Math.floor(count / 2), visible.length - count));
return visible.slice(start, start + count);
});
const elapsed = computed(() => {
const point = currentLesson.value;
if (!point) return 0;
const start = timeAt(point.startTime);
const end = timeAt(point.endTime);
return Math.max(0, Math.min(1, (props.now - start) / Math.max(1, end - start)));
});
const remaining = computed(() => const remaining = computed(() =>
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0, currentLesson.value ? Math.max(0, Math.ceil((timeAt(currentLesson.value.endTime) - props.now) / 1000)) : 0,
); );
const hidden = computed( const hidden = computed(
() => () =>
@@ -62,11 +114,11 @@ const hidden = computed(
(r) => (r) =>
(r === 'in-class' && props.state.inClass) || (r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) || (r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value), (r === 'no-class' && !currentLesson.value),
)), )),
); );
const visibleChildren = computed(() => const visibleChildren = computed(() =>
props.node.kind === 'slide' props.node.kind === 'slide' && !props.editing
? props.node.children.filter( ? props.node.children.filter(
(_, i) => (_, i) =>
i === i ===
@@ -95,6 +147,18 @@ const content = computed(() => {
.replaceAll('{course}', current.value?.course?.name || '暂无课程') .replaceAll('{course}', current.value?.course?.name || '暂无课程')
: n.settings.text; : n.settings.text;
if (n.kind === 'countdown') { if (n.kind === 'countdown') {
if (n.settings.target === 'date') {
if (!n.settings.targetDate) return '请设置目标日期';
const [year, month, day] = n.settings.targetDate.split('-').map(Number);
const days = Math.round(
(Date.UTC(year!, month! - 1, day!) - Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000,
);
return days > 0
? `距${n.settings.countdownTitle} ${days} 天`
: days === 0
? `${n.settings.countdownTitle}就在今天`
: `${n.settings.countdownTitle}已过 ${-days} 天`;
}
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value); const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
const time = const time =
n.settings.target === 'school' n.settings.target === 'school'
@@ -111,13 +175,6 @@ const content = computed(() => {
} }
return ''; return '';
}); });
const lessons = computed(() =>
props.node.settings.display === 'today'
? today.value.slice(0, props.node.settings.count)
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
(s): s is ScheduleDto => Boolean(s),
),
);
const style = computed<CSSProperties>(() => { const style = computed<CSSProperties>(() => {
const n = props.node, const n = props.node,
a = n.appearance, a = n.appearance,
@@ -177,6 +234,7 @@ const style = computed<CSSProperties>(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="editing" :editing="editing"
:selected-id="selectedId" :selected-id="selectedId"
:alert-node-id="alertNodeId" :alert-node-id="alertNodeId"
@@ -188,26 +246,33 @@ const style = computed<CSSProperties>(() => {
</ComponentNode> </ComponentNode>
</template> </template>
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template> <template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
<WeatherBrief v-else-if="node.kind === 'weather'" :settings="node.settings" />
<template v-else-if="node.kind === 'schedule'"> <template v-else-if="node.kind === 'schedule'">
<div v-for="s in lessons" :key="s.id" class="lesson-line"> <template v-for="p in schedulePoints" :key="p.id">
<strong>{{ s.course?.name }}</strong> <span v-if="p.timeType === 'DIVIDER'" class="time-divider" aria-hidden="true">│</span>
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small> <div
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining"> v-else
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }} class="lesson-line"
</small> :class="{ current: p.id === currentPoint?.id, break: p.timeType === 'BREAK' }"
</div> >
<span v-if="!lessons.length">暂无课程</span> <strong>{{ p.courseName || p.name || '—' }}</strong>
<div <small v-if="p.id === currentPoint?.id && node.settings.showPeriod" class="lesson-time">
v-if="current && node.settings.showProgress && node.settings.display !== 'next'" {{ p.startTime }}–{{ p.endTime }}
class="lesson-progress" </small>
role="progressbar" <span
:aria-valuenow="Math.round(elapsed * 100)" v-if="p.id === currentPoint?.id && p.timeType === 'LESSON' && node.settings.showProgress"
:aria-valuemin="0" class="lesson-progress"
:aria-valuemax="100" role="progressbar"
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`" :aria-valuenow="Math.round(elapsed * 100)"
> aria-valuemin="0"
<i :style="{ transform: `scaleX(${elapsed})` }" /> aria-valuemax="100"
</div> :aria-label="`${p.courseName || '课程'}课程进度,距下课${remaining}秒`"
>
<i :style="{ transform: `scaleX(${elapsed})` }" />
</span>
</div>
</template>
<span v-if="!schedulePoints.length">暂无课程</span>
</template> </template>
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)"> <template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
<slot v-if="node.id === alertNodeId" name="alerts" /> <slot v-if="node.id === alertNodeId" name="alerts" />
@@ -238,11 +303,13 @@ const style = computed<CSSProperties>(() => {
white-space: nowrap; white-space: nowrap;
} }
.schedule > .lesson-line { .schedule > .lesson-line {
position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding-bottom: 3px;
} }
.lesson-line strong { .lesson-line strong {
min-width: 0; min-width: 0;
@@ -250,6 +317,21 @@ const style = computed<CSSProperties>(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.lesson-line.current strong {
color: #fff;
}
.lesson-line.break {
opacity: 0.86;
}
.time-divider {
display: block;
width: 1px;
height: 18px;
overflow: hidden;
background: currentColor;
font-size: 0;
opacity: 0.25;
}
.lesson-time { .lesson-time {
flex-shrink: 0; flex-shrink: 0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -257,10 +339,11 @@ const style = computed<CSSProperties>(() => {
} }
.lesson-progress { .lesson-progress {
position: absolute; position: absolute;
inset: auto 4px 0; inset: auto 0 0;
height: 2px; height: 2px;
background: #ffffff24; background: #ffffff24;
overflow: hidden; overflow: hidden;
border-radius: 1px;
} }
.lesson-progress i { .lesson-progress i {
display: block; display: block;
@@ -272,12 +355,6 @@ const style = computed<CSSProperties>(() => {
.schedule > .lesson-line + .lesson-line { .schedule > .lesson-line + .lesson-line {
margin-top: 8px; margin-top: 8px;
} }
.lesson-remaining {
font-size: var(--secondary, 14px);
font-variant-numeric: tabular-nums;
opacity: 0.8;
white-space: nowrap;
}
.separator > span { .separator > span {
display: block; display: block;
width: 1px; width: 1px;
@@ -299,6 +376,9 @@ const style = computed<CSSProperties>(() => {
.rolling > :deep(.component-node) { .rolling > :deep(.component-node) {
animation: rolling var(--rolling-duration) linear infinite; animation: rolling var(--rolling-duration) linear infinite;
} }
.rolling.node-editing > :deep(.component-node) {
animation: none;
}
.selected { .selected {
outline: 2px solid #85aaff; outline: 2px solid #85aaff;
border-radius: 8px; border-radius: 8px;
@@ -6,6 +6,7 @@ import {
type IslandAppearance, type IslandAppearance,
type IslandLayoutSettings, type IslandLayoutSettings,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
type IslandLessonReminder, type IslandLessonReminder,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -20,6 +21,7 @@ const props = defineProps<{
settings: IslandLayoutSettings; settings: IslandLayoutSettings;
state: IslandState; state: IslandState;
schedules: ScheduleDto[]; schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
appearance: IslandAppearance; appearance: IslandAppearance;
nativeVisible: boolean; nativeVisible: boolean;
}>(); }>();
@@ -267,10 +269,19 @@ function visibility() {
void nextTick(reportHit); void nextTick(reportHit);
} }
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。 // 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, { watch(
deep: true, [
flush: 'post', expanded,
}); scheme,
lesson,
() => props.state.listExpanded,
messages,
() => props.timePoints,
() => Math.floor(now.value / 60_000),
],
measure,
{ deep: true, flush: 'post' },
);
watch( watch(
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed], [fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
() => { () => {
@@ -388,6 +399,7 @@ onUnmounted(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="false" :editing="false"
selected-id="" selected-id=""
alert-node-id="" alert-node-id=""
@@ -469,6 +481,7 @@ onUnmounted(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="false" :editing="false"
selected-id="" selected-id=""
alert-node-id="" alert-node-id=""
@@ -5,6 +5,7 @@ import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance, type IslandAppearance,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import FixedIsland from './IslandApp.vue'; import FixedIsland from './IslandApp.vue';
@@ -14,6 +15,7 @@ import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()), const settings = ref(defaultIslandLayouts()),
ready = ref(false); ready = ref(false);
const schedules = ref<ScheduleDto[]>([]); const schedules = ref<ScheduleDto[]>([]);
const timePoints = ref<IslandTimePointDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE }); const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false); const nativeVisible = ref(false);
const resident = () => settings.value.layout === 'custom'; const resident = () => settings.value.layout === 'custom';
@@ -36,6 +38,7 @@ onMounted(async () => {
if (bridge) { if (bridge) {
bridge.onLayout((value) => (settings.value = value)); bridge.onLayout((value) => (settings.value = value));
bridge.onSchedules((value) => (schedules.value = value)); bridge.onSchedules((value) => (schedules.value = value));
bridge.onTimePoints((value) => (timePoints.value = value));
bridge.onState((value) => (state.value = value)); bridge.onState((value) => (state.value = value));
bridge.onAppearance((value) => (appearance.value = value)); bridge.onAppearance((value) => (appearance.value = value));
bridge.onVisibility((value) => (nativeVisible.value = value)); bridge.onVisibility((value) => (nativeVisible.value = value));
@@ -43,6 +46,7 @@ onMounted(async () => {
appearance.value = await bridge.getAppearance(); appearance.value = await bridge.getAppearance();
settings.value = await bridge.getLayout(); settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules(); schedules.value = await bridge.getSchedules();
timePoints.value = await bridge.getTimePoints();
state.value = await bridge.getState(); state.value = await bridge.getState();
} }
await nextTick(); await nextTick();
@@ -55,9 +59,18 @@ onMounted(async () => {
:settings="settings" :settings="settings"
:state="state" :state="state"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:appearance="appearance" :appearance="appearance"
:native-visible="nativeVisible" :native-visible="nativeVisible"
/> />
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" /> <FixedIsland
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" /> v-if="ready"
v-show="!resident() && !state.coursewareTransfer"
:suspended="resident() || !!state.coursewareTransfer"
/>
<CoursewareTransfer
v-if="state.coursewareTransfer"
:transfer="state.coursewareTransfer"
:appearance="appearance"
/>
</template> </template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import type { IslandComponentNode, IslandWeather } from '@classhelper/shared';
const props = defineProps<{ settings: IslandComponentNode['settings'] }>();
const weather = ref<IslandWeather | null>(null),
loading = ref(false),
unavailable = ref(false);
const configured = computed(
() => typeof props.settings.latitude === 'number' && typeof props.settings.longitude === 'number',
);
let revision = 0;
async function refresh() {
const current = ++revision;
if (!configured.value) {
loading.value = false;
return;
}
loading.value = true;
try {
const bridge = window.island ?? window.desktop;
if (!bridge) throw new Error('仅桌面客户端可读取天气');
const result = await bridge.getIslandWeather({
latitude: props.settings.latitude!,
longitude: props.settings.longitude!,
});
if (current === revision) {
weather.value = result;
unavailable.value = false;
}
} catch {
if (current === revision) unavailable.value = true;
} finally {
if (current === revision) loading.value = false;
}
}
watch(
() => [props.settings.latitude, props.settings.longitude],
() => {
weather.value = null;
unavailable.value = false;
void refresh();
},
{ immediate: true },
);
const timer = setInterval(() => void refresh(), 60_000);
onUnmounted(() => {
clearInterval(timer);
revision++;
});
const description = computed(() => {
const code = weather.value?.code ?? -1;
if (code === 0) return '晴';
if (code <= 3 && code > 0) return ['少云', '多云', '阴'][code - 1];
if ([45, 48].includes(code)) return '雾';
if ([51, 53, 55, 56, 57].includes(code)) return '毛毛雨';
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return '雨';
if ([71, 73, 75, 77, 85, 86].includes(code)) return '雪';
if ([95, 96, 99].includes(code)) return '雷雨';
return '天气';
});
const advice = computed(() => {
if (!weather.value) return '';
if (weather.value.code >= 95) return '雷雨天气,注意避雷';
if (weather.value.windSpeed >= 40) return '风力较大,注意出行';
return '';
});
</script>
<template>
<span
class="weather-brief"
:title="weather ? `Open-Meteo · ${new Date(weather.fetchedAt).toLocaleString('zh-CN')}\n${advice}` : ''"
>
<template v-if="weather">
{{ settings.locationName }} {{ description }} {{ Math.round(weather.temperature) }}°C
<span v-if="weather.stale || unavailable">· 缓存</span>
<small v-if="advice">{{ advice }}</small>
</template>
<template v-else>
{{ !configured ? '请设置天气地点' : loading ? '天气加载中…' : '天气暂不可用' }}
</template>
</span>
</template>
<style scoped>
.weather-brief {
white-space: nowrap;
}
small {
display: block;
font-size: var(--secondary, 12px);
}
</style>
+2
View File
@@ -6,6 +6,7 @@ import {
listLocalCourseware, listLocalCourseware,
} from './courseware.js'; } from './courseware.js';
import { island } from './island.js'; import { island } from './island.js';
import { getIslandWeather } from './island-weather.js';
import { getUpdateProgress } from './auto-update.js'; import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron'; import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared'; import type { UpdateInfo } from '@classhelper/shared';
@@ -21,6 +22,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS }; export { IPC_CHANNELS };
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
ipcMain.handle('island:weather', (_event, coordinates: unknown) => getIslandWeather(coordinates));
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id)); ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id)); ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:local-list', () => listLocalCourseware()); ipcMain.handle('courseware:local-list', () => listLocalCourseware());
@@ -0,0 +1,73 @@
import type { IslandWeather } from '@classhelper/shared';
const cache = new Map<string, { data: IslandWeather | null; retryAt: number }>();
const pending = new Map<string, Promise<IslandWeather>>();
/** 固定天气服务与数值坐标,不接受渲染层传入 URL。失败退避并保留本次运行的缓存。 */
export async function getIslandWeather(
input: unknown,
request: typeof fetch = fetch,
): Promise<IslandWeather> {
const coordinates = input as { latitude?: unknown; longitude?: unknown } | null;
const latitude = coordinates?.latitude,
longitude = coordinates?.longitude;
if (
typeof latitude !== 'number' ||
!Number.isFinite(latitude) ||
Math.abs(latitude) > 90 ||
typeof longitude !== 'number' ||
!Number.isFinite(longitude) ||
Math.abs(longitude) > 180
)
throw new Error('请设置有效的经纬度');
const key = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
const stored = cache.get(key);
if (stored && stored.retryAt > Date.now()) {
if (stored.data) return { ...stored.data, stale: Date.now() - stored.data.fetchedAt >= 15 * 60_000 };
throw new Error('天气暂不可用,稍后自动重试');
}
const running = pending.get(key);
if (running) return running;
const job = Promise.resolve().then(async () => {
try {
if (process.env.ELECTRON_SMOKE_TEST === '1' && request === fetch)
throw new Error('自动验收不访问外部天气服务');
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: 'temperature_2m,weather_code,wind_speed_10m',
forecast_days: '1',
}).toString();
const response = await request(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
if (!response.ok) throw new Error('天气服务暂不可用');
const body = await response.text();
if (body.length > 64_000) throw new Error('天气响应过大');
const current = JSON.parse(body).current;
if (
![current?.temperature_2m, current?.weather_code, current?.wind_speed_10m].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
throw new Error('天气数据格式不正确');
const data: IslandWeather = {
temperature: current.temperature_2m,
code: current.weather_code,
windSpeed: current.wind_speed_10m,
fetchedAt: Date.now(),
stale: false,
};
cache.set(key, { data, retryAt: Date.now() + 15 * 60_000 });
return data;
} catch {
cache.set(key, { data: stored?.data ?? null, retryAt: Date.now() + 60_000 });
if (stored?.data) return { ...stored.data, stale: true };
throw new Error('天气暂不可用,请检查网络或坐标');
} finally {
pending.delete(key);
while (cache.size > 32) cache.delete(cache.keys().next().value!);
}
});
pending.set(key, job);
return job;
}
+25 -7
View File
@@ -1,4 +1,7 @@
import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared'; import type {
IslandTimePointDto as ContractIslandTimePointDto,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron'; import { BrowserWindow, ipcMain, screen } from 'electron';
@@ -138,6 +141,7 @@ export interface IslandPushContext {
export interface IslandClassStatePayload { export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[]; schedules?: ContractScheduleDto[];
timePoints?: ContractIslandTimePointDto[];
inClass: boolean; inClass: boolean;
currentPeriodEnd?: string | null; currentPeriodEnd?: string | null;
week?: number; week?: number;
@@ -364,14 +368,15 @@ class IslandController {
if (this.customTimer) clearTimeout(this.customTimer); if (this.customTimer) clearTimeout(this.customTimer);
this.customTimer = null; this.customTimer = null;
// 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。 // 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。
if ( const width = Math.ceil(value.width);
this.customSize.width !== Math.ceil(value.width) || const height = Math.ceil(value.height);
this.customSize.height !== Math.ceil(value.height) const changed = this.customSize.width !== width || this.customSize.height !== height;
) if (changed)
this.residentMotionUntil = this.residentMotionUntil =
Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0); Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) }; this.customSize = { width, height };
this.syncResidentShape(true); // 时钟每分钟触发的重复测量尺寸没变,不要清空触摸形状(那 750ms 会漏掉点击)。
if (changed || !this.residentShaped) this.syncResidentShape(true);
return; return;
} }
if (this.customTimer) clearTimeout(this.customTimer); if (this.customTimer) clearTimeout(this.customTimer);
@@ -618,6 +623,7 @@ class IslandController {
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance(); this.emitAppearance();
this.win?.webContents.send('island:schedules', this.schedules); this.win?.webContents.send('island:schedules', this.schedules);
this.win?.webContents.send('island:time-points', this.timePoints);
this.emit(); this.emit();
if (this.residentEnabled) { if (this.residentEnabled) {
this.residentWasEnabled = true; this.residentWasEnabled = true;
@@ -634,6 +640,10 @@ class IslandController {
getSchedules(): ContractScheduleDto[] { getSchedules(): ContractScheduleDto[] {
return [...this.schedules]; return [...this.schedules];
} }
private timePoints: ContractIslandTimePointDto[] = [];
getTimePoints(): ContractIslandTimePointDto[] {
return [...this.timePoints];
}
getState(): IslandState { getState(): IslandState {
return { return {
@@ -860,6 +870,11 @@ class IslandController {
if (this.ready && this.win && !this.win.isDestroyed()) if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:schedules', this.schedules); this.win.webContents.send('island:schedules', this.schedules);
} }
if (Array.isArray(payload.timePoints)) {
this.timePoints = payload.timePoints.slice(0, 100);
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:time-points', this.timePoints);
}
const wasInClass = this.state.inClass; const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass; this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
@@ -2320,6 +2335,9 @@ export function registerIslandIpc(): void {
ipcMain.handle('island:get-schedules', (event) => ipcMain.handle('island:get-schedules', (event) =>
event.sender === island.getWindow()?.webContents ? island.getSchedules() : [], event.sender === island.getWindow()?.webContents ? island.getSchedules() : [],
); );
ipcMain.handle('island:get-time-points', (event) =>
event.sender === island.getWindow()?.webContents ? island.getTimePoints() : [],
);
ipcMain.handle('island:get-layout', () => getConfig().islandLayout); ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
ipcMain.handle('island:save-layout', (_event, value: unknown) => { ipcMain.handle('island:save-layout', (_event, value: unknown) => {
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout; const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
@@ -3,7 +3,13 @@ import { BrowserWindow } from 'electron';
import path from 'node:path'; import path from 'node:path';
import { island } from './island.js'; import { island } from './island.js';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared'; import {
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
} from '@classhelper/shared';
import { getIslandWeather } from './island-weather.js';
import { verifyResidentSmoke } from './smoke-resident.js'; import { verifyResidentSmoke } from './smoke-resident.js';
export async function verifyLayoutSmoke( export async function verifyLayoutSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>, check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
@@ -38,12 +44,30 @@ export async function verifyLayoutSmoke(
editingBounds = win.getBounds(); editingBounds = win.getBounds();
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)'); await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
await pause(); await pause();
if (process.env.ISLAND_SHOTS_DIR) {
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
const bounds = editor.getContentBounds();
editor.setContentSize(1600, 1000);
for (const theme of ['light', 'dark']) {
await editor.webContents.executeJavaScript(
`document.documentElement.classList.toggle('dark', ${theme === 'dark'})`,
);
await pause();
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, `component-editor-${theme}.png`),
(await editor.webContents.capturePage()).toPNG(),
);
}
await editor.webContents.executeJavaScript(`document.documentElement.classList.remove('dark')`);
editor.setContentSize(bounds.width, bounds.height);
await pause();
}
return ( return (
(await editor.webContents.executeJavaScript( (await editor.webContents.executeJavaScript(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))', 'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) && )) &&
editor.webContents.executeJavaScript( editor.webContents.executeJavaScript(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`, `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return document.querySelectorAll('.component-library > .component-options:first-of-type .library-item').length===11 && !document.querySelector('.layout-editor, iframe'); })()`,
) )
); );
}); });
@@ -66,12 +90,16 @@ export async function verifyLayoutSmoke(
return true; return true;
}); });
await check('组件可以包裹进容器且设置独立保存', async () => { await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.node-handle[data-kind=clock]')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript( await editor.webContents.executeJavaScript(
`document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`, `document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
); );
await pause(); await pause();
await editor.webContents.executeJavaScript( await editor.webContents.executeJavaScript(
`(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`, `(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组容器'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
); );
await pause(); await pause();
return ( return (
@@ -108,6 +136,10 @@ export async function verifyLayoutSmoke(
return initial && appearance && advanced; return initial && appearance && advanced;
}); });
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => { await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
return editor.webContents.executeJavaScript(`(() => { return editor.webContents.executeJavaScript(`(() => {
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect(); const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect(); const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
@@ -118,11 +150,190 @@ export async function verifyLayoutSmoke(
return editor.webContents.executeJavaScript(`(() => { return editor.webContents.executeJavaScript(`(() => {
const root=document.documentElement, wasDark=root.classList.contains('dark'); const root=document.documentElement, wasDark=root.classList.contains('dark');
root.classList.add('dark'); root.classList.add('dark');
const card=document.querySelector('.scheme-card'), style=getComputedStyle(card); const page=document.querySelector('.island-editor-page'), style=getComputedStyle(page);
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4; const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelector('.component-library .library-title') && document.querySelector('.scheme-controls');
root.classList.toggle('dark',wasDark);return Boolean(ok); root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`); })()`);
}); });
await check('从常驻组件库拖入容器,阻止将父组件放进自己的后代', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('[data-library-kind=text]'), target=document.querySelector('.editor-component.container');
if(!source||!target)throw Error('缺少组件库或容器');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
})()`);
await pause();
const group = getConfig()
.islandLayout.schemes[0]!.rows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'group');
if (!group?.children.some((n) => n.kind === 'text')) throw Error('组件库内容没有进入容器');
const before = JSON.stringify(getConfig().islandLayout);
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('.editor-component.container > .node-handle'), target=document.querySelector('.editor-component.container .container-children .node-handle');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:22,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:22,clientX:x,clientY:y}));
})()`);
await pause();
return before === JSON.stringify(getConfig().islandLayout);
});
await check('常态与无通知展开内容分别保存,日期倒计时可配置并预览', async () => {
const before = JSON.stringify(getConfig().islandLayout.schemes[0]!.rows);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[1].click();document.querySelector('[data-library-kind=countdown]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const set=(input,value)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));};
const field=[...document.querySelectorAll('.component-settings .el-form-item')].find(e=>e.textContent.includes('目标名称'));
set(field.querySelector('input'),'运动会');
document.querySelector('.component-settings .el-date-editor input').focus();
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.value='2099-10-25';picker.dispatchEvent(new Event('input',{bubbles:true}));
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.dispatchEvent(new Event('change',{bubbles:true}));picker.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true}));picker.blur();
})()`);
await pause();
const added = getConfig()
.islandLayout.schemes[0]!.expandedRows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'countdown');
if (added?.settings.targetDate !== '2099-10-25' || added.settings.countdownTitle !== '运动会')
throw Error(JSON.stringify(added?.settings));
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
const preview = await editor.webContents.executeJavaScript(
`document.querySelector('.island-preview .countdown')?.textContent.includes('距运动会')`,
);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[0].click()`,
);
await pause();
return preview && JSON.stringify(getConfig().islandLayout.schemes[0]!.rows) === before;
});
await check('天气坐标校验、并发合并与缓存使用 mock,不访问外网', async () => {
let calls = 0;
const request: typeof fetch = async (input) => {
calls++;
const url = new URL(String(input));
if (url.origin !== 'https://api.open-meteo.com' || url.searchParams.get('latitude') !== '12.34')
throw Error('请求边界错误');
return new Response(
JSON.stringify({ current: { temperature_2m: 23.4, weather_code: 2, wind_speed_10m: 8 } }),
);
};
const results = await Promise.all([
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
]);
await getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request);
let invalid = false;
try {
await getIslandWeather({ latitude: 100, longitude: 0 }, request);
} catch {
invalid = true;
}
const weather = islandNode('weather', 'weather-test'),
countdown = islandNode('countdown', 'countdown-test');
weather.settings.latitude = NaN;
countdown.settings.targetDate = '2026-02-31';
const config = defaultIslandLayouts();
config.schemes[0]!.rows[0]!.nodes = [weather, countdown];
const normalized = normalizeIslandLayouts(config).schemes[0]!.rows[0]!.nodes;
return (
calls === 1 &&
results.every((r) => r.temperature === 23.4 && !r.stale) &&
invalid &&
normalized[0]!.settings.latitude === null &&
normalized[1]!.settings.targetDate === ''
);
});
await check('右上角新建、重置和删除方案均可保存并恢复原方案', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="新建方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`(() => { const input=document.querySelector('.el-message-box__input input'); input.value='组件方案验收';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('.el-message-box__btns .el-button--primary').click(); })()`,
);
await pause();
if (getConfig().islandLayout.schemes.length !== 2) throw Error('新建方案没有保存');
await editor.webContents.executeJavaScript(
`document.querySelector('[aria-label="重置当前方案"]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return (
getConfig().islandLayout.schemes.length === 1 &&
getConfig().islandLayout.activeSchemeId === 'default' &&
getConfig().islandLayout.schemes[0]!.rows.length === 2
);
});
await check('方案 JSON 导入导出保留容器结构并重新生成标识', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
const exported = await editor.webContents.executeJavaScript(`(async () => {
let blob; const originalURL=URL.createObjectURL, originalClick=HTMLAnchorElement.prototype.click;
try {
URL.createObjectURL=value=>{blob=value;return 'blob:editor-smoke';};HTMLAnchorElement.prototype.click=()=>{};
[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='导出方案').click();
} finally { URL.createObjectURL=originalURL;HTMLAnchorElement.prototype.click=originalClick; }
const text=await blob.text(), parsed=JSON.parse(text);
const input=document.querySelector('.island-editor-page input[type=file]'), transfer=new DataTransfer();
transfer.items.add(new File([text],'组件方案.json',{type:'application/json'}));input.files=transfer.files;
input.dispatchEvent(new Event('change',{bubbles:true}));return parsed;
})()`);
await pause();
const layouts = getConfig().islandLayout;
const imported = layouts.schemes.find((s) => s.id === layouts.activeSchemeId)!;
const shape = (nodes: ReturnType<typeof islandNode>[]): string =>
JSON.stringify(
nodes.map((n) => ({ kind: n.kind, children: shape(n.children), settings: n.settings })),
);
const preserved =
exported.format === 'classhelper-island-layout' &&
imported.id !== exported.scheme.id &&
imported.rows.every(
(row, index) =>
row.id !== exported.scheme.rows[index].id &&
shape(row.nodes) === shape(exported.scheme.rows[index].nodes),
);
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return preserved && getConfig().islandLayout.schemes.length === 1;
});
if (process.env.ISLAND_SHOTS_DIR) { if (process.env.ISLAND_SHOTS_DIR) {
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`); await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
@@ -5,6 +5,7 @@ import {
islandNode, islandNode,
normalizeIslandLayouts, normalizeIslandLayouts,
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
@@ -607,7 +608,9 @@ export async function verifyResidentSmoke(
await pause(1100); await pause(1100);
const half = await win.webContents.executeJavaScript(`(() => { const half = await win.webContents.executeJavaScript(`(() => {
const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}'); const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}');
return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50); const text=root?.textContent??'';
const progress=Number(root?.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'));
return Boolean(root&&text.includes('数学')&&progress===50&&(${layout === 'custom' ? "text.includes('09:00–09:45')&&!text.includes('距下课')" : "text.includes('距下课')"}));
})()`); })()`);
if (!half) if (!half)
throw new Error( throw new Error(
@@ -679,6 +682,87 @@ export async function verifyResidentSmoke(
await pause(800); await pause(800);
} }
}); });
await check('自定义岛课程表按时间点折叠:课间折叠隐藏、当前项展开显示时间段', async () => {
const scheduleLayout = JSON.parse(JSON.stringify(fixture));
const node = islandNode('schedule', 'time-point-schedule');
node.settings.display = 'today';
node.settings.count = 10;
scheduleLayout.schemes[0].rows[0].nodes = [node];
saveConfig({ islandLayout: { ...scheduleLayout, layout: 'custom' } });
island.syncCustomLayout();
const timePoints: IslandTimePointDto[] = [
{
id: 'p-math',
timeType: 'LESSON',
name: '第 1 节',
courseName: '数学',
startTime: '09:00',
endTime: '09:45',
isHideDefault: false,
},
{
id: 'p-break',
timeType: 'BREAK',
name: '课间休息',
courseName: null,
startTime: '09:45',
endTime: '09:55',
isHideDefault: false,
},
{
id: 'p-chinese',
timeType: 'LESSON',
name: '第 2 节',
courseName: '语文',
startTime: '09:55',
endTime: '10:40',
isHideDefault: false,
},
];
const setTime = async (hour: number, minute: number) => {
const at = dateAt(hour, minute).getTime();
await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`);
island.setLessonReminderTestTime(at);
};
try {
await win.webContents.executeJavaScript(
`window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`,
);
island.setClassState({ inClass: false, schedules: lessonFixtures, timePoints });
await setTime(9, 20);
await pause(900);
const duringClass = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:00–09:45'), otherPeriod: text.includes('09:55–10:40') };
})()`);
await setTime(9, 50);
await pause(900);
const duringBreak = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:45–09:55') };
})()`);
if (!(
duringClass.math &&
duringClass.chinese &&
!duringClass.rest &&
duringClass.period &&
!duringClass.otherPeriod
))
throw new Error('课中时间点折叠错误:' + JSON.stringify(duringClass));
if (!(duringBreak.math && duringBreak.chinese && duringBreak.rest && duringBreak.period))
throw new Error('课间展开错误:' + JSON.stringify(duringBreak));
return true;
} finally {
await win.webContents.executeJavaScript(
`Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`,
);
island.setLessonReminderTestTime(null);
island.setClassState({ inClass: false, schedules: [], timePoints: [] });
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
}
});
await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => { await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => {
const appearance = island.getAppearance(); const appearance = island.getAppearance();
const dense = JSON.parse(JSON.stringify(fixture)); const dense = JSON.parse(JSON.stringify(fixture));
@@ -743,15 +827,26 @@ export async function verifyResidentSmoke(
island.handleAction({ action: 'expand' }); island.handleAction({ action: 'expand' });
await pause(1000); await pause(1000);
island.handleAction({ action: 'collapse' }); island.handleAction({ action: 'collapse' });
await pause(1000); // 收起后渲染进程要重新量宽并走 750ms 的触摸形状延时;尺寸稳定前不要断言。
let settled = false;
const deadline = Date.now() + 3500;
while (Date.now() < deadline) {
await pause(100);
const current = island.getHitRect();
if (
current &&
shape.length === 1 &&
shape[0]!.width <= current.width + 18 &&
shape[0]!.height <= current.height + 18
) {
settled = true;
break;
}
}
const hit = island.getHitRect(); const hit = island.getHitRect();
return Boolean( const ok = Boolean(settled && hit && JSON.stringify(box) === JSON.stringify(win.getBounds()));
hit && if (!ok) throw new Error(JSON.stringify({ hit, shape, bounds: win.getBounds(), box, settled }));
shape.length === 1 && return true;
shape[0]!.width <= hit.width + 18 &&
shape[0]!.height <= hit.height + 18 &&
JSON.stringify(box) === JSON.stringify(win.getBounds()),
);
} finally { } finally {
win.setShape = original; win.setShape = original;
island.setTouchMode(false); island.setTouchMode(false);
@@ -23,6 +23,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。 * 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/ */
const bridge: DesktopBridge = { const bridge: DesktopBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id), downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id), revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'), listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
@@ -1,5 +1,7 @@
import type { import type {
IslandLayoutSettings as ContractIslandLayoutSettings, IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ScheduleDto as ContractScheduleDto, ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
@@ -10,6 +12,7 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/ */
export interface IslandBridge { export interface IslandBridge {
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
getLayout(): Promise<ContractIslandLayoutSettings>; getLayout(): Promise<ContractIslandLayoutSettings>;
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>; saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void; onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
@@ -24,6 +27,8 @@ export interface IslandBridge {
onViewport(handler: (value: { width: number; height: number }) => void): () => void; onViewport(handler: (value: { width: number; height: number }) => void): () => void;
getSchedules(): Promise<ContractScheduleDto[]>; getSchedules(): Promise<ContractScheduleDto[]>;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void; onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
getTimePoints(): Promise<ContractIslandTimePointDto[]>;
onTimePoints(handler: (value: ContractIslandTimePointDto[]) => void): void;
onState(handler: (state: IslandState) => void): void; onState(handler: (state: IslandState) => void): void;
sendAction( sendAction(
@@ -57,6 +62,7 @@ export interface IslandBridge {
} }
const bridge: IslandBridge = { const bridge: IslandBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
getLayout: () => ipcRenderer.invoke('island:get-layout'), getLayout: () => ipcRenderer.invoke('island:get-layout'),
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value), saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
onLayout: (handler) => { onLayout: (handler) => {
@@ -84,6 +90,10 @@ const bridge: IslandBridge = {
onSchedules: (handler) => { onSchedules: (handler) => {
ipcRenderer.on('island:schedules', (_event, value) => handler(value)); ipcRenderer.on('island:schedules', (_event, value) => handler(value));
}, },
getTimePoints: () => ipcRenderer.invoke('island:get-time-points'),
onTimePoints: (handler) => {
ipcRenderer.on('island:time-points', (_event, value) => handler(value));
},
onState: (handler) => { onState: (handler) => {
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state)); ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
@@ -37,14 +37,63 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<el-switch v-model="node.settings.seconds" @change="emit('change')" /> <el-switch v-model="node.settings.seconds" @change="emit('change')" />
</el-form-item> </el-form-item>
</template> </template>
<el-form-item v-else-if="node.kind === 'countdown'" label="倒计时目标"> <template v-else-if="node.kind === 'countdown'">
<el-select v-model="node.settings.target" @change="emit('change')"> <el-form-item label="倒计时目标">
<el-option label="自动:距上课 / 下课" value="auto" /> <el-select v-model="node.settings.target" @change="emit('change')">
<el-option label="下一节课" value="next" /> <el-option label="自动:距上课 / 下课" value="auto" />
<el-option label="本节下课" value="end" /> <el-option label="下一节课" value="next" />
<el-option label="放学" value="school" /> <el-option label="本节下课" value="end" />
</el-select> <el-option label="放学" value="school" />
</el-form-item> <el-option label="指定日期" value="date" />
</el-select>
</el-form-item>
<template v-if="node.settings.target === 'date'">
<el-form-item label="目标名称">
<el-input v-model="node.settings.countdownTitle" :maxlength="64" @change="emit('change')" />
</el-form-item>
<el-form-item label="目标日期">
<el-date-picker
v-model="node.settings.targetDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@change="emit('change')"
/>
</el-form-item>
</template>
</template>
<template v-else-if="node.kind === 'weather'">
<el-form-item label="地点名称">
<el-input
v-model="node.settings.locationName"
placeholder="例如:学校"
:maxlength="64"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="纬度">
<el-input-number
v-model="node.settings.latitude"
:min="-90"
:max="90"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="经度">
<el-input-number
v-model="node.settings.longitude"
:min="-180"
:max="180"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<p class="hint">
填写地点坐标后每 15 分钟更新天气。数据来自
Open-Meteo,断网时显示本次运行的缓存;天气提示不作为官方预警。
</p>
</template>
<template v-else-if="node.kind === 'text'"> <template v-else-if="node.kind === 'text'">
<el-form-item label="文字内容"> <el-form-item label="文字内容">
<el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" /> <el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" />
@@ -57,20 +106,24 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<template v-else-if="node.kind === 'schedule'"> <template v-else-if="node.kind === 'schedule'">
<el-form-item label="显示课程"> <el-form-item label="显示课程">
<el-select v-model="node.settings.display" @change="emit('change')"> <el-select v-model="node.settings.display" @change="emit('change')">
<el-option label="当前课程" value="current" /> <el-option label="当前时间点" value="current" />
<el-option label="下一节课" value="next" /> <el-option label="下一节课" value="next" />
<el-option label="今日课表" value="today" /> <el-option label="今日时间点" value="today" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示节数"> <el-form-item v-if="node.settings.display === 'today'" label="显示时间点数">
<el-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" /> <el-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item label="显示课程时间"> <el-form-item label="当前时间点显示起止时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" /> <el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item label="显示上课进度"> <el-form-item label="当前课程显示进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" /> <el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item> </el-form-item>
<p class="hint">
今日时间点只展开当前项并显示时间段;课间休息与默认隐藏的时间点折叠时完全隐藏,轮到时才展开。
下课前一分钟的倒计时由「提醒」设置里的下课提醒负责。
</p>
</template> </template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])"> <template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向"> <el-form-item label="排列方向">
@@ -80,7 +133,7 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="组件间距"> <el-form-item label="组件间距">
<el-input-number v-model="node.settings.gap" :min="0" :max="80" @change="emit('change')" /> <el-input-number v-model="node.settings.gap" :min="0" :max="64" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)"> <el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number <el-input-number
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ISLAND_COMPONENT_LABELS, type IslandComponentNode } from '@classhelper/shared';
import ComponentIcon from '../../island/ComponentIcon.vue';
const props = defineProps<{
node: IslandComponentNode;
selectedId: string;
dropId?: string;
dropPosition?: string;
}>();
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
</script>
<template>
<div
class="editor-component"
:class="{
container,
selected: selectedId === node.id,
'drop-before': dropId === node.id && dropPosition === 'before',
'drop-after': dropId === node.id && dropPosition === 'after',
'drop-inside': dropId === node.id && dropPosition === 'inside',
}"
:data-node="node.id"
>
<button
class="component-chip node-handle"
:data-kind="node.kind"
:aria-label="`编辑${ISLAND_COMPONENT_LABELS[node.kind]}`"
@pointerdown="emit('drag', $event, node.id)"
@click.stop="emit('select', node.id)"
>
<ComponentIcon :kind="node.kind" />
<span>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</span>
</button>
<div v-if="container" class="container-children">
<IslandEditorNode
v-for="child in node.children"
:key="child.id"
:node="child"
:selected-id="selectedId"
:drop-id="dropId"
:drop-position="dropPosition"
@select="emit('select', $event)"
@drag="(event, id) => emit('drag', event, id)"
/>
<span v-if="!node.children.length" class="container-empty">拖入组件</span>
</div>
</div>
</template>
<style scoped>
.editor-component {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 5px;
}
.component-chip {
display: flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 5px 9px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
white-space: nowrap;
cursor: grab;
touch-action: none;
}
.component-chip svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.component-chip:hover {
background: var(--ch-hover-soft);
}
.component-chip:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.selected > .component-chip {
background: var(--ch-accent-soft);
border-color: var(--ch-accent);
}
.container {
border: 1px dashed var(--ch-border-strong);
padding: 3px;
gap: 3px;
}
.container-children {
display: flex;
align-items: center;
gap: 3px;
min-width: 60px;
min-height: 34px;
}
.container-empty {
color: var(--ch-text-tertiary);
font-size: 12px;
padding: 0 10px;
}
.drop-inside {
background: var(--ch-accent-soft);
outline: 2px solid var(--ch-accent);
}
.drop-before::before,
.drop-after::after {
content: '';
position: absolute;
inset: 3px auto 3px -4px;
width: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.drop-after::after {
left: auto;
right: -4px;
}
</style>
@@ -5,6 +5,7 @@ import {
resolveClassStatus, resolveClassStatus,
type ClassPeriod, type ClassPeriod,
type HomeworkDto, type HomeworkDto,
type IslandTimePointDto,
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -30,6 +31,8 @@ const TICK_INTERVAL_MS = 1_000;
const REFRESH_INTERVAL_MS = 30_000; const REFRESH_INTERVAL_MS = 30_000;
let schedules: ScheduleDto[] = []; let schedules: ScheduleDto[] = [];
/** 今天的完整时间点(含课间/分割线/默认隐藏),自定义岛的课程表组件使用。 */
let dayTimePoints: IslandTimePointDto[] = [];
let currentPeriod: ClassPeriod | null = null; let currentPeriod: ClassPeriod | null = null;
let inClass = false; let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null; let tickTimer: ReturnType<typeof setInterval> | null = null;
@@ -47,6 +50,51 @@ export function getIslandSchedules(): ScheduleDto[] {
return schedules; return schedules;
} }
export function getIslandTimePoints(): IslandTimePointDto[] {
return dayTimePoints;
}
/** 从 active-week 的今天取完整时间点;课程名优先条目、没有条目才回退默认课程。 */
function buildDayTimePoints(days: ContractActiveClassPlanWeekDto['days']): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
const day = days.find((item) => item.weekday === weekday);
const points = day?.timeLayout?.points ?? [];
if (!points.length) return [];
const entries = day?.classPlan?.entries ?? [];
return points.map((point) => {
const entry = entries.find((item) => item.timeLayoutItemId === point.id);
return {
id: point.id,
timeType: point.timeType,
name: point.breakName || point.name || '',
courseName: entry
? entry.isEnabled
? (entry.course?.name ?? null)
: null
: (point.defaultCourse?.name ?? null),
startTime: point.startTime,
endTime: point.endTime,
isHideDefault: point.isHideDefault,
};
});
}
/** 离线/旧服务端只有课表条目时退化成只有上课时间点。 */
function timePointsFromSchedules(list: ScheduleDto[]): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
return list
.filter((item) => item.dayOfWeek === weekday)
.map((item) => ({
id: item.id,
timeType: 'LESSON' as const,
name: '',
courseName: item.course?.name ?? null,
startTime: item.startTime,
endTime: item.endTime,
isHideDefault: false,
}));
}
/** 主动刷新课表(课表变更事件触发) */ /** 主动刷新课表(课表变更事件触发) */
export async function refreshIslandSchedules(): Promise<void> { export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore(); const appStore = useAppStore();
@@ -61,13 +109,16 @@ export async function refreshIslandSchedules(): Promise<void> {
'schedules', 'schedules',
`week:${week}`, `week:${week}`,
async () => { async () => {
const data = ( const payload = await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', {
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week }) classId,
).days.flatMap((day) => day.schedules); week,
});
const data = payload.days.flatMap((day) => day.schedules);
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。 // 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token) if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
throw new Error('课表刷新已被新请求替代'); throw new Error('课表刷新已被新请求替代');
schedules = data; schedules = data;
dayTimePoints = buildDayTimePoints(payload.days);
scheduleWeek = week; scheduleWeek = week;
recomputeClassState(true); recomputeClassState(true);
return data; return data;
@@ -78,12 +129,22 @@ export async function refreshIslandSchedules(): Promise<void> {
if (result.error && !result.updatedAt) return; if (result.error && !result.updatedAt) return;
if (result.fromCache) { if (result.fromCache) {
schedules = result.data; schedules = result.data;
dayTimePoints = timePointsFromSchedules(result.data);
scheduleWeek = week; scheduleWeek = week;
recomputeClassState(true); recomputeClassState(true);
} }
} }
let sentDay = ''; let sentDay = '';
let sentTimePoints = '';
function timePointsKey(): string {
return dayTimePoints
.map(
(point) =>
`${point.id}|${point.timeType}|${point.startTime}|${point.endTime}|${point.name}|${point.courseName ?? ''}|${point.isHideDefault ? 1 : 0}`,
)
.join(',');
}
function recomputeClassState(force = false): void { function recomputeClassState(force = false): void {
const day = new Date().toDateString(); const day = new Date().toDateString();
const appStore = useAppStore(); const appStore = useAppStore();
@@ -94,15 +155,19 @@ function recomputeClassState(force = false): void {
currentPeriod?.endTime === status.current?.endTime; currentPeriod?.endTime === status.current?.endTime;
currentPeriod = status.current; currentPeriod = status.current;
if (!force && day === sentDay && nextInClass === inClass && samePeriod) return; const pointsKey = timePointsKey();
if (!force && day === sentDay && nextInClass === inClass && samePeriod && pointsKey === sentTimePoints)
return;
sentDay = day; sentDay = day;
inClass = nextInClass; inClass = nextInClass;
sentTimePoints = pointsKey;
window.desktop?.islandSetClassState({ window.desktop?.islandSetClassState({
inClass, inClass,
currentPeriodEnd: currentPeriod?.endTime ?? null, currentPeriodEnd: currentPeriod?.endTime ?? null,
week: appStore.currentWeek, week: appStore.currentWeek,
schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)), schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)),
timePoints: dayTimePoints,
}); });
} }
@@ -389,7 +454,9 @@ export function stopIslandBridge(): void {
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated); useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
useRealtimeStore().off('__connected__', onScheduleUpdated); useRealtimeStore().off('__connected__', onScheduleUpdated);
sentDay = ''; sentDay = '';
sentTimePoints = '';
schedules = []; schedules = [];
dayTimePoints = [];
currentPeriod = null; currentPeriod = null;
inClass = false; inClass = false;
@@ -401,5 +468,6 @@ export function stopIslandBridge(): void {
currentPeriodEnd: null, currentPeriodEnd: null,
week: useAppStore().currentWeek, week: useAppStore().currentWeek,
schedules: [], schedules: [],
timePoints: [],
}); });
} }
@@ -0,0 +1,453 @@
.island-editor-page {
display: flex;
flex-direction: column;
flex: 1 0 auto;
min-height: 0;
min-width: 0;
color: var(--ch-text);
padding: 12px 24px 16px;
background: var(--editor-background, var(--ch-bg));
}
:global(.main:has(> .island-editor-page)) {
display: flex;
flex-direction: column;
}
:global(.main:has(> .island-editor-page) > .offline-banner),
.island-editor-page > * {
flex-shrink: 0;
}
:global(html.dark) .island-editor-page {
--editor-background: #262626;
--editor-divider: #3a3a3a;
--editor-card: #2c2c2c;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 30px;
margin-bottom: 4px;
}
.save-status {
color: var(--ch-text-tertiary);
font-size: 12px;
margin-right: 8px;
}
.save-status.failed {
color: var(--el-color-danger);
}
.component-heading {
align-items: flex-end;
gap: 24px;
margin-bottom: 20px;
}
.heading-copy {
flex: 1 1 380px;
}
.component-heading .page-title {
font-size: calc(34px * var(--ch-font-scale));
font-weight: 600;
line-height: 1.4;
margin-bottom: 20px;
}
.heading-copy p {
font-size: calc(16px * var(--ch-font-scale));
margin: 0;
line-height: 1.6;
}
.scheme-tools {
flex: 0 1 348px;
min-width: 300px;
}
.scheme-tools label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
.scheme-controls {
display: flex;
gap: 6px;
align-items: center;
}
.scheme-controls .el-select {
min-width: 140px;
flex: 1;
}
.scheme-controls :deep(.el-select__wrapper) {
min-height: 38px;
background: var(--ch-card-subtle);
}
.icon-button {
width: 34px;
height: 36px;
padding: 8px;
margin: 0;
flex-shrink: 0;
font-size: 19px;
}
.surface-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.surface-tabs,
.surface-actions {
display: flex;
gap: 12px;
align-items: center;
}
.surface-actions {
gap: 0;
}
.surface-tabs button {
position: relative;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 12px;
padding: 6px 0 9px;
cursor: pointer;
}
.surface-tabs button.active {
color: var(--ch-text);
}
.surface-tabs button.active::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
right: 10%;
height: 2px;
background: var(--ch-accent);
border-radius: 2px;
}
.preview-canvas {
min-height: 80px;
overflow: auto;
padding: 10px 0 20px;
}
.component-lines {
min-width: 100%;
width: max-content;
}
.component-line {
display: flex;
align-items: center;
min-height: 54px;
border-radius: 6px;
gap: 12px;
padding: 4px;
border: 1px solid transparent;
}
.component-line + .component-line {
margin-top: 6px;
}
.component-line.active-row {
border-color: var(--ch-border-strong);
}
.row-handle {
align-self: stretch;
width: 24px;
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 20px;
cursor: grab;
touch-action: none;
}
.row-handle:hover {
color: var(--ch-accent);
background: var(--ch-hover-soft);
}
.line-components {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 0 28px 0 0;
min-height: 38px;
}
.empty-line {
border: 1px dashed var(--ch-border-strong);
background: transparent;
color: var(--ch-text-tertiary);
padding: 10px 24px;
border-radius: 5px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.preview-canvas.live-preview {
display: grid;
place-items: center;
min-height: 160px;
padding: 24px;
background: var(--ch-layer-alt);
border-radius: 6px;
margin-top: 8px;
}
.island-preview {
display: flex;
flex-direction: column;
gap: var(--preview-gap);
width: max-content;
color: #ffffffeb;
padding: 12px;
border-radius: var(--preview-radius);
background: #202024;
}
.preview-row {
display: flex;
align-items: center;
min-width: 120px;
min-height: 44px;
border-radius: var(--preview-radius);
}
.island-preview.separated {
background: transparent;
padding: 0;
}
.separated .preview-row {
padding: 12px;
background: #202024;
}
.empty-preview {
color: #ffffffb8;
background: transparent;
border: 1px dashed #ffffff60;
border-radius: 5px;
padding: 10px 20px;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.drop-row {
outline: 2px dashed var(--ch-accent);
outline-offset: -2px;
background: var(--ch-accent-soft);
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 0 12px;
color: var(--ch-text-secondary);
font-size: 12px;
}
.preview-toolbar .el-slider {
width: 130px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.component-library {
border-top: 6px solid var(--editor-divider, var(--ch-divider));
margin: 0 -24px;
padding: 14px 24px 24px;
flex: 1 0 auto;
}
.library-title {
position: relative;
width: max-content;
font-size: 17px;
font-weight: 600;
margin: 0 0 12px;
padding: 0 0 7px;
}
.library-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.component-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.library-item {
min-width: 0;
width: 100%;
display: flex;
gap: 12px;
align-items: center;
text-align: left;
min-height: 76px;
padding: 10px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
cursor: grab;
touch-action: none;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
border-color var(--ch-dur-fast) var(--ch-ease-out);
}
.library-item:hover {
background: var(--ch-hover-soft);
border-color: var(--ch-border);
}
.library-item:active {
background: var(--ch-accent-soft);
}
.library-item:focus-visible,
.row-handle:focus-visible,
.surface-tabs button:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.library-item > svg {
width: 36px;
height: 36px;
flex: 0 0 36px;
}
.library-item > span {
min-width: 0;
}
.library-item strong {
display: block;
font-size: calc(18px * var(--ch-font-scale));
font-weight: 500;
line-height: 1.5;
}
.component-description {
display: block;
color: var(--ch-text-secondary);
font-size: calc(14px * var(--ch-font-scale));
line-height: 1.5;
}
.message-library-title {
color: var(--ch-text-tertiary);
font-size: 12px;
font-weight: 400;
margin: 20px 8px 4px;
}
.message-options .library-item {
min-height: 64px;
}
.message-options .library-item strong {
font-size: 15px;
}
.message-options .library-item > svg {
width: 26px;
height: 26px;
flex-basis: 26px;
}
.editor-tip {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
min-height: 60px;
border: 1px solid var(--ch-border-strong);
border-radius: 5px;
background: var(--editor-card, var(--ch-card-subtle));
font-size: 14px;
}
.editor-tip > span {
flex: 1;
line-height: 1.6;
}
.tip-icon {
color: var(--ch-accent);
font-size: 22px;
}
.dismiss-tip {
border: 0;
color: var(--ch-text);
background: transparent;
font-size: 26px;
cursor: pointer;
margin-left: auto;
padding: 0 8px;
}
.drag-ghost {
position: fixed;
z-index: 10000;
pointer-events: none;
padding: 10px 16px;
background: var(--ch-layer-solid);
border: 1px solid var(--ch-accent);
border-radius: 6px;
box-shadow: var(--ch-shadow-flyout);
color: var(--ch-text);
font-size: 14px;
}
.inspector-tabs {
margin-bottom: 20px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 20px;
}
.row-actions .el-button {
margin: 0;
}
@media (max-width: 1150px) {
.component-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.component-heading {
gap: 16px;
}
.heading-copy {
flex-basis: 300px;
}
.scheme-tools {
flex-basis: 300px;
}
}
@media (max-width: 800px) {
.component-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-heading .page-title {
margin-bottom: 10px;
}
.scheme-tools {
flex: 1 1 100%;
}
.editor-tip {
flex-wrap: wrap;
}
.preview-toolbar .hint {
display: none;
}
}
@media (max-width: 520px) {
.component-options {
grid-template-columns: 1fr;
}
.island-editor-page {
padding-right: 16px;
padding-left: 16px;
}
.component-library {
margin-right: -16px;
margin-left: -16px;
padding-right: 16px;
padding-left: 16px;
}
.save-status {
display: none;
}
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -1,5 +1,7 @@
import type { import type {
IslandLayoutSettings as ContractIslandLayoutSettings, IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ReleaseProgressDto as ContractReleaseProgressDto, ReleaseProgressDto as ContractReleaseProgressDto,
ScheduleDto as ContractScheduleDto, ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -111,6 +113,8 @@ export interface IslandPushContext {
export interface IslandClassStatePayload { export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[]; schedules?: ContractScheduleDto[];
/** 今天的完整时间点(含课间/分割线/默认隐藏标记),自定义岛课程表组件使用。 */
timePoints?: ContractIslandTimePointDto[];
inClass: boolean; inClass: boolean;
currentPeriodEnd?: string | null; currentPeriodEnd?: string | null;
week?: number; week?: number;
@@ -175,6 +179,7 @@ export interface DesktopBridge {
onUpdateAvailable(handler: (info: UpdateInfo) => void): void; onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
/* 灵动岛 */ /* 灵动岛 */
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */ /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
islandRemoveNotification(id: string): void; islandRemoveNotification(id: string): void;
+108
View File
@@ -2224,6 +2224,114 @@ async function main() {
`status=${studentLayoutList.status}`, `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 新增/重复/更新 + 错误行提示) // 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [ const gradesCsv = [
'学号,姓名,考试名称,分数,总分,科目', '学号,姓名,考试名称,分数,总分,科目',
+95 -25
View File
@@ -1,6 +1,7 @@
import { import {
dayKeyLocal, dayKeyLocal,
resolveClassStatus, resolveClassStatus,
type CourseBrief,
type ScheduleDto, type ScheduleDto,
type ActiveClassPlanDto, type ActiveClassPlanDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -20,30 +21,96 @@ export async function loadPlans(classId: string) {
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}); });
} }
export function projectPlan(plan: PlanRow): ScheduleDto[] {
return plan.entries /** 时间点的默认课程映射(defaultClassId → 课程摘要)。 */
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON') export type DefaultCourseMap = Map<string, CourseBrief>;
.map<ScheduleDto>((e) => ({
id: e.legacyScheduleId || e.id, /**
classId: plan.classId, * 一次性取出时间表里所有默认课程。
courseId: e.courseId!, *
course: e.course!, * 刻意不在 Prisma 里给 `TimeLayoutItem.defaultClassId` 加关系:该字段早已存在,
dayOfWeek: plan.weekday || 1, * 加外键要重建表;这里按 id 查一次课程即可,SQLite/MySQL 都通用。
startTime: e.timePoint.startTime, */
endTime: e.timePoint.endTime, export async function loadDefaultCourseMap(
location: e.location, points: ReadonlyArray<{ defaultClassId: string | null }>,
weekStart: plan.weekStart, ): Promise<DefaultCourseMap> {
weekEnd: plan.weekEnd, const ids = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
weekParity: if (!ids.length) return new Map();
plan.weekCountDivTotal === 2 && plan.weekCountDiv const courses = await prisma.course.findMany({
? plan.weekCountDiv === 1 where: { id: { in: ids } },
? 'ODD' select: { id: true, name: true },
: 'EVEN' });
: 'ALL', return new Map(courses.map((course) => [course.id, course]));
source: 'manual', }
createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.toISOString(), /** 给时间表时间点附加默认课程,供 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)); .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.group?.isGlobal) ??
candidates.find((p) => !p.associatedGroup) ?? candidates.find((p) => !p.associatedGroup) ??
null; null;
const defaults = plan ? await loadDefaultCourseMap(plan.layout.points) : new Map<string, CourseBrief>();
const schedules = plan const schedules = plan
? projectPlan(plan).map((s) => ({ ? projectPlan(plan, defaults).map((s) => ({
...s, ...s,
dayOfWeek: weekday, dayOfWeek: weekday,
weekStart: week, weekStart: week,
@@ -120,6 +188,8 @@ export async function resolveActiveClassPlan(
points: plan.layout.points.map((p) => ({ points: plan.layout.points.map((p) => ({
...p, ...p,
timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION', timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION',
defaultCourse:
plan.layout.classId && p.defaultClassId ? (defaults.get(p.defaultClassId) ?? null) : 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( layouts.delete(
'/:id', '/:id',
requirePermission('timetable:manage'), requirePermission('timetable:manage'),
@@ -5,7 +5,7 @@ import { getMaxTermWeeks } from '../../lib/term.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { emitToClass } from '../../realtime/bus.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 { z } from 'zod';
import type { import type {
planSchema, planSchema,
@@ -26,14 +26,31 @@ export async function notifyScheduleChanged(classId?: string | null) {
export async function listLayouts(user: TokenPayload, classId?: string) { export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId);
const ids = classId ? [classId] : await getAccessibleClassIds(user); const ids = classId ? [classId] : await getAccessibleClassIds(user);
return prisma.timeLayout.findMany({ const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] }, where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } }, include: { points: { orderBy: { position: 'asc' } } },
}); });
return Promise.all(rows.map((row) => decorateLayout(row)));
} }
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) { export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
if (id && !(await prisma.timeLayout.findUnique({ where: { id } }))) throw ApiError.notFound('时间表不存在'); const existing = id
const pointIds = input.points.flatMap((p) => (p.id ? [p.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('时间点不能重复'); if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复');
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const layout = id 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))) if (pointIds.some((p) => !owned.some((o) => o.id === p)))
throw ApiError.badRequest('时间点不属于这份时间表'); throw ApiError.badRequest('时间点不属于这份时间表');
await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } }); 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; const { id: pointId, ...data } = point;
if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } }); if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } });
else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } }); 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(); 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) { export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId);
@@ -17,6 +17,7 @@ import {
requireClassDeviceClassId, requireClassDeviceClassId,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { import {
loadDefaultCourseMap,
loadPlans, loadPlans,
planInclude, planInclude,
projectPlan, projectPlan,
@@ -52,9 +53,11 @@ export async function listSchedules(
result.push(...(await resolveActiveClassPlan(classId, date)).schedules); result.push(...(await resolveActiveClassPlan(classId, date)).schedules);
} }
} else { } 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)) if (!plan.isOverlay && (!options.dayOfWeek || plan.weekday === options.dayOfWeek))
result.push(...projectPlan(plan)); result.push(...projectPlan(plan, defaults));
} }
} }
const byId = new Map<string, ScheduleDto>(); const byId = new Map<string, ScheduleDto>();
+24 -1
View File
@@ -1,5 +1,5 @@
import type { ClassPeriod as ContractClassPeriod } from './types.js'; import type { ClassPeriod as ContractClassPeriod } from './types.js';
import type { ScheduleDto } from './types.js'; import type { CourseBrief, ScheduleDto } from './types.js';
export const TIME_POINT_TYPES = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'] as const; export const TIME_POINT_TYPES = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'] as const;
export type TimePointType = (typeof TIME_POINT_TYPES)[number]; export type TimePointType = (typeof TIME_POINT_TYPES)[number];
@@ -12,6 +12,8 @@ export interface TimePointDto {
timeType: TimePointType; timeType: TimePointType;
isHideDefault: boolean; isHideDefault: boolean;
defaultClassId: string | null; defaultClassId: string | null;
/** 服务端投影出的默认课程;日课表没有单独条目时用它回退。 */
defaultCourse?: CourseBrief | null;
breakName: string; breakName: string;
actionSet: string; actionSet: string;
} }
@@ -25,6 +27,8 @@ export interface DailyPlanEntryDto {
id: string; id: string;
timeLayoutItemId: string; timeLayoutItemId: string;
courseId: string | null; courseId: string | null;
/** 服务端 include 的课程摘要;课表页据此显示科目。 */
course?: CourseBrief | null;
isEnabled: boolean; isEnabled: boolean;
isChangedClass: boolean; isChangedClass: boolean;
location: string | null; location: string | null;
@@ -60,6 +64,25 @@ export interface ActiveClassPlanWeekDto {
days: Array<ActiveClassPlanDto & { weekday: number; date: string }>; days: Array<ActiveClassPlanDto & { weekday: number; date: string }>;
} }
/**
* 灵动岛当天时间点。
*
* 自定义常驻岛的「课程表」组件按它渲染 ClassIsland 主界面式的折叠/展开时间点:
* 上课点折叠只显示课程名,当前点展开显示时间段;课间休息与默认隐藏的时间点只在轮到时出现。
*/
export interface IslandTimePointDto {
id: string;
timeType: TimePointType;
/** 课间用 breakName,上课用时间点名称 */
name: string;
courseName: string | null;
/** HH:mm */
startTime: string;
/** HH:mm */
endTime: string;
isHideDefault: boolean;
}
/** 设置来自服务端;离线/未初始化时统一回落 30。 */ /** 设置来自服务端;离线/未初始化时统一回落 30。 */
export function getMaxTermWeeks(configured?: unknown): number { export function getMaxTermWeeks(configured?: unknown): number {
const value = Number(configured); const value = Number(configured);
+1 -1
View File
@@ -507,7 +507,7 @@ export const CLASSISLAND_TIME_TYPES = {
*/ */
export const CLASSISLAND_TIME_TYPE_LABELS: Record<number, string> = { export const CLASSISLAND_TIME_TYPE_LABELS: Record<number, string> = {
0: '上课', 0: '上课',
1: '课间', 1: '课间休息',
2: '分割线', 2: '分割线',
3: '行动', 3: '行动',
}; };
+39 -7
View File
@@ -1,6 +1,9 @@
import { isDayKey } from './utils.js';
export const ISLAND_COMPONENTS = [ export const ISLAND_COMPONENTS = [
'date', 'date',
'clock', 'clock',
'weather',
'countdown', 'countdown',
'schedule', 'schedule',
'text', 'text',
@@ -32,17 +35,25 @@ export const CLASSISLAND_ISLAND_METRICS = {
fadedOpacity: 0.05, fadedOpacity: 0.05,
} as const; } as const;
export type IslandComponentKind = (typeof ISLAND_COMPONENTS)[number]; export type IslandComponentKind = (typeof ISLAND_COMPONENTS)[number];
export interface IslandWeather {
temperature: number;
code: number;
windSpeed: number;
fetchedAt: number;
stale: boolean;
}
export const ISLAND_COMPONENT_LABELS: Record<IslandComponentKind, string> = { export const ISLAND_COMPONENT_LABELS: Record<IslandComponentKind, string> = {
date: '日期', date: '日期',
clock: '时钟', clock: '时钟',
weather: '天气简报',
countdown: '倒计时', countdown: '倒计时',
schedule: '课程表', schedule: '课程表',
text: '文本', text: '文本',
separator: '分隔符', separator: '分割线',
slide: '轮播', slide: '轮播容器',
rolling: '滚动', rolling: '滚动容器',
group: '分组', group: '分组容器',
stack: '堆叠', stack: '堆叠容器',
notification: '通知', notification: '通知',
homework: '作业', homework: '作业',
call: '叫人', call: '叫人',
@@ -71,7 +82,12 @@ export interface IslandComponentNode {
template: boolean; template: boolean;
hour12: boolean; hour12: boolean;
seconds: boolean; seconds: boolean;
target: 'auto' | 'next' | 'end' | 'school'; target: 'auto' | 'next' | 'end' | 'school' | 'date';
targetDate: string;
countdownTitle: string;
locationName: string;
latitude: number | null;
longitude: number | null;
display: 'current' | 'next' | 'today'; display: 'current' | 'next' | 'today';
count: number; count: number;
showPeriod: boolean; showPeriod: boolean;
@@ -165,6 +181,11 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
hour12: false, hour12: false,
seconds: true, seconds: true,
target: 'auto', target: 'auto',
targetDate: '',
countdownTitle: '目标日',
locationName: '',
latitude: null,
longitude: null,
display: 'current', display: 'current',
count: 5, count: 5,
showPeriod: true, showPeriod: true,
@@ -260,7 +281,18 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
template: s.template === true, template: s.template === true,
hour12: s.hour12 === true, hour12: s.hour12 === true,
seconds: s.seconds !== false, seconds: s.seconds !== false,
target: ['auto', 'next', 'end', 'school'].includes(s.target) ? s.target : 'auto', target: ['auto', 'next', 'end', 'school', 'date'].includes(s.target) ? s.target : 'auto',
targetDate: typeof s.targetDate === 'string' && isDayKey(s.targetDate) ? s.targetDate : '',
countdownTitle: String(s.countdownTitle || '目标日').slice(0, 64),
locationName: String(s.locationName || '').slice(0, 64),
latitude:
typeof s.latitude === 'number' && Number.isFinite(s.latitude)
? clamp(s.latitude, -90, 90, 0)
: null,
longitude:
typeof s.longitude === 'number' && Number.isFinite(s.longitude)
? clamp(s.longitude, -180, 180, 0)
: null,
display: ['current', 'next', 'today'].includes(s.display) ? s.display : 'current', display: ['current', 'next', 'today'].includes(s.display) ? s.display : 'current',
count: clamp(s.count, 1, 40, 5), count: clamp(s.count, 1, 40, 5),
showPeriod: s.showPeriod !== false, showPeriod: s.showPeriod !== false,
@@ -23,6 +23,14 @@ const paths: Record<string, string> = {
shield: 'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6', shield: 'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',
back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3', back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3',
chevron: 'M6 9l6 6 6-6', chevron: 'M6 9l6 6 6-6',
lesson: 'M4 5h16v12H4z M8 21h8 M12 17v4 M7 9h6 M7 12h4',
break: 'M6 8h9v4a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4V8z M15 9h2a2 2 0 0 1 0 4h-2 M4 20h13',
divider: 'M4 12h16',
action: 'M13 3 6 13h5l-1 8 8-11h-5l1-7z',
timeline: 'M7 3v18 M4 6h6 M4 12h6 M4 18h6 M14 8h6v4h-6z M14 15h4v4h-4z',
trash: 'M4 7h16 M9 7V4h6v3 M6 7l1 14h10l1-14 M10 11v6 M14 11v6',
'zoom-in': 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14 M20 20l-4-4 M8 11h6 M11 8v6',
'zoom-out': 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14 M20 20l-4-4 M8 11h6',
}; };
</script> </script>
@@ -45,7 +45,7 @@ const CLASS_PLAN_SAMPLE = `{
"Name": "周一作息", "Name": "周一作息",
"Layouts": [ "Layouts": [
{ "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0, "BreakName": "" }, { "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0, "BreakName": "" },
{ "StartTime": "08:45:00", "EndTime": "08:55:00", "TimeType": 1, "BreakName": "课间" }, { "StartTime": "08:45:00", "EndTime": "08:55:00", "TimeType": 1, "BreakName": "课间休息" },
{ "StartTime": "08:55:00", "EndTime": "09:40:00", "TimeType": 0, "BreakName": "" } { "StartTime": "08:55:00", "EndTime": "09:40:00", "TimeType": 0, "BreakName": "" }
] ]
} }
@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { ClassPlanDto, TimeLayoutDetailDto } from '@classhelper/shared';
import ArchiveIcon from './ArchiveIcon.vue';
/**
* 日课表的触发规则与基本信息。
*
* 同一份表单同时用于课表页右侧「课表信息」标签和周视图的详细信息入口,
* 直接修改父组件传入的 `plan`,保留 PlanEditorView 的脏数据比较。
*/
const plan = defineModel<ClassPlanDto>('plan', { required: true });
const props = defineProps<{
layouts: TimeLayoutDetailDto[];
groups: { id: string; name: string; isGlobal: boolean; isDefault: boolean }[];
maxWeek: number;
disabled: boolean;
saving: boolean;
}>();
const emit = defineEmits<{
'change-layout': [value: string];
'edit-layout': [];
save: [];
}>();
</script>
<template>
<el-form :disabled="disabled" label-position="top" class="plan-details" @submit.prevent>
<label class="detail-field">
<span>课表名称</span>
<el-input v-model="plan.name" maxlength="64" placeholder="例如:星期一课表" />
</label>
<label class="detail-field">
<span>启用星期</span>
<el-select v-model="plan.weekday">
<el-option label="任意星期" :value="0" />
<el-option v-for="day in 7" :key="day" :value="day" :label="`星期${'一二三四五六日'[day - 1]}`" />
</el-select>
</label>
<div class="detail-field">
<span>轮周设定</span>
<div class="inline-fields">
<el-input-number v-model="plan.weekCountDivTotal" :min="1" :max="9" />
<span>周一轮,使用第</span>
<el-input-number v-model="plan.weekCountDiv" :min="0" :max="plan.weekCountDivTotal" />
<span>周</span>
</div>
<small>轮换周期第 0 周表示每周使用,支持 1–9 周轮换。</small>
</div>
<div class="detail-field">
<span>生效周次</span>
<div class="inline-fields">
<el-input-number v-model="plan.weekStart" :min="1" :max="plan.weekEnd" />
<span>至</span>
<el-input-number v-model="plan.weekEnd" :min="plan.weekStart" :max="props.maxWeek" />
<span>周</span>
</div>
</div>
<div class="detail-field">
<span>时间表模板</span>
<el-select
:model-value="plan.timeLayoutId"
placeholder="选择时间表"
@change="emit('change-layout', $event)"
>
<el-option v-for="item in props.layouts" :key="item.id" :value="item.id" :label="item.name" />
</el-select>
<button class="text-command" type="button" @click="emit('edit-layout')">
<ArchiveIcon name="clock" />
编辑时间表
</button>
</div>
<div class="detail-field">
<span>课表群</span>
<el-select v-model="plan.associatedGroup" clearable placeholder="未分组">
<el-option v-for="group in props.groups" :key="group.id" :value="group.id" :label="group.name" />
</el-select>
</div>
<label class="detail-field inline">
<span>启用课表</span>
<el-switch v-model="plan.isEnabled" />
</label>
<el-button
type="primary"
class="save-plan"
:disabled="props.disabled"
:loading="props.saving"
@click="emit('save')"
>
保存课表
</el-button>
</el-form>
</template>
<style scoped>
.plan-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-field {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--ch-text-secondary);
font-size: var(--el-font-size-small);
}
.detail-field :deep(.el-select),
.detail-field :deep(.el-input) {
width: 100%;
}
.detail-field small {
color: var(--ch-text-muted);
font-size: var(--el-font-size-extra-small);
line-height: 1.4;
}
.detail-field.inline {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.inline-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.inline-fields :deep(.el-input-number) {
width: 74px;
}
.inline-fields span {
white-space: nowrap;
}
.text-command {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 5px;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--el-color-primary);
font: inherit;
cursor: pointer;
}
.text-command .archive-icon {
width: 16px;
height: 16px;
}
.save-plan {
margin-top: 4px;
width: 100%;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -243,7 +243,7 @@ function fillSample(): void {
<el-table-column prop="endTime" label="结束" width="90" /> <el-table-column prop="endTime" label="结束" width="90" />
<el-table-column label="类型" width="100"> <el-table-column label="类型" width="100">
<template #default="{ row }"> <template #default="{ row }">
{{ row.type === 'break' ? '课间' : row.type === 'divider' ? '分割线' : '上课' }} {{ row.type === 'break' ? '课间休息' : row.type === 'divider' ? '分割线' : '上课' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="跳过" width="80"> <el-table-column label="跳过" width="80">
File diff suppressed because it is too large. Load diff
+111 -13
View File
@@ -1363,15 +1363,40 @@ async function runPlanEditorChecks(win) {
await win.loadURL(new URL('/plan-editor', TARGET_URL).href); await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
const ready = await waitFor( const ready = await waitFor(
win, 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( record(
'课表档案在管理端页面内展示周课表和选科侧栏', '课表档案默认进入单表编辑并展示选科侧栏',
ready && ready &&
(await win.webContents.executeJavaScript( (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 assignment = await win.webContents.executeJavaScript(`(() => {
const cell = document.querySelector('.lesson-cell'); const cell = document.querySelector('.lesson-cell');
if (!cell) return null; if (!cell) return null;
@@ -1416,6 +1441,7 @@ async function runPlanEditorChecks(win) {
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`, `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( await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:nth-child(2)').click()`, `document.querySelector('.archive-tabs button:nth-child(2)').click()`,
); );
@@ -1423,22 +1449,77 @@ async function runPlanEditorChecks(win) {
'时间表页四种时间点与保存入口可见', '时间表页四种时间点与保存入口可见',
await waitFor( await waitFor(
win, 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 quickAdd = await win.webContents.executeJavaScript(`(async () => {
const before = document.querySelectorAll('.time-row').length; const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click(); const token = localStorage.getItem('classhelper.token');
await new Promise(resolve => setTimeout(resolve, 180)); const headers = {
const rows = document.querySelectorAll('.time-row'); 'Content-Type': 'application/json',
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value); ...(token ? { Authorization: 'Bearer ' + token } : {}),
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 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( record(
'时间表新建课时按快速时长生成起止时间', '时间表新建上课点按默认 40 分钟生成起止时间',
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45, 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( await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`, `(() => {[...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()`); await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`)); 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.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'), path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
(await win.webContents.capturePage()).toPNG(), (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( await win.webContents.executeJavaScript(
`document.querySelector('.management-menu [data-path="/dashboard"]').click()`, `document.querySelector('.management-menu [data-path="/dashboard"]').click()`,
); );