feat: 重做灵动岛编辑器与课表档案时间表编辑
灵动岛 - 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。 - 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。 - 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。 - preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。 课表档案 - 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。 - 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。 - 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。 - 「课间」统一改称「课间休息」。 验证 - pnpm typecheck、pnpm lint 通过。 - verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。 - ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
No files matched your search
@@ -36,8 +36,13 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
|
||||
## 编辑自定义方案
|
||||
|
||||
“设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。
|
||||
方案支持复制、删除、JSON 导入导出;组件支持日期、时钟、倒计时、课程、文本、分隔符、通知/作业/叫人及组合容器。
|
||||
分别编辑常态 rows 与无消息展开 expandedRows,拖动调整结构,设置内容、外观、尺寸与隐藏条件。
|
||||
页面上方排列当前组件,右上角选择配置方案;刷新图标重置当前方案,加号新建方案,文件夹菜单提供方案设置、复制、删除及 JSON 导入导出。
|
||||
下方组件库常驻显示文本、分割线、课程表、日期、时钟、天气简报、倒计时和组合容器,并保留班级通知、作业和叫人组件。
|
||||
从组件库拖入上方的行或容器以添加,拖动已有组件调整顺序或跨行移动;也可点击组件库添加到选中的容器或当前行。
|
||||
分别编辑常态 rows 与无消息展开 expandedRows,点选组件打开内容、外观、尺寸与隐藏条件设置;行可以新增、排序、设为主要行或删除。
|
||||
“进入编辑模式”切换为实际内容预览,支持预览缩放;退出后回到组件标签排列。轮播和滚动容器在编辑时展示全部子组件,便于选择和排列。
|
||||
倒计时可指定目标名称与日期,也可按课表倒数至上课、下课或放学。
|
||||
天气简报需填写地点名称及经纬度,使用 [Open-Meteo](https://open-meteo.com/en/docs) 的天气数据,每 15 分钟更新;断网时保留本次客户端运行中的缓存并标明“缓存”。天气提示根据天气概况生成,不提供官方气象预警。
|
||||
方案保存到本机 config.json.islandLayout,不增加服务端同步。
|
||||
编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。
|
||||
保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。
|
||||
@@ -56,6 +61,14 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
|
||||
## 课程与课件
|
||||
|
||||
准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。
|
||||
|
||||
自定义常驻岛的「课程表」组件可选「当前时间点 / 下一节课 / 今日时间点」:
|
||||
「今日时间点」显示当天全部时间点,只有当前项展开显示起止时间,其余折叠只显示课程名;
|
||||
课间休息与「默认隐藏」的上课时间点折叠时完全隐藏,轮到它们时才展开;分割线显示一条竖线,行动不显示。
|
||||
课中课程表不再常驻显示倒计时,只在最后一分钟由既有的「下课提醒」页显示「距下课还剩」;
|
||||
倒计时阈值沿用本机提醒设置(默认 60 秒)。没有时间点数据时退回按课表条目显示课程。
|
||||
经典 fixed 岛的课中提醒条保持原有倒计时行为。
|
||||
|
||||
课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。
|
||||
客户端不会自动执行收到的文件,见[课件](courseware.md)。
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。
|
||||
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,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -3,10 +3,12 @@ import type { IslandComponentKind } from '@classhelper/shared';
|
||||
defineProps<{ kind: IslandComponentKind }>();
|
||||
const paths: Record<IslandComponentKind, string> = {
|
||||
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',
|
||||
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
|
||||
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',
|
||||
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',
|
||||
|
||||
@@ -5,13 +5,16 @@ import {
|
||||
resolveClassStatus,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
type IslandTimePointDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import WeatherBrief from './WeatherBrief.vue';
|
||||
const props = defineProps<{
|
||||
node: IslandComponentNode;
|
||||
state: IslandState;
|
||||
now: number;
|
||||
schedules: ScheduleDto[];
|
||||
timePoints: IslandTimePointDto[];
|
||||
editing: boolean;
|
||||
selectedId: string;
|
||||
alertNodeId: string;
|
||||
@@ -38,20 +41,69 @@ function timeAt(time: string) {
|
||||
d.setHours(h!, m!, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
const elapsed = computed(() =>
|
||||
current.value
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(props.now - timeAt(current.value.startTime)) /
|
||||
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)),
|
||||
),
|
||||
)
|
||||
: 0,
|
||||
/**
|
||||
* 今天的时间点:优先用服务端下发的完整时间表(含课间/分割线/默认隐藏),
|
||||
* 没有时回退到课表条目,保证旧服务端与离线缓存仍能显示课程。
|
||||
*/
|
||||
const dayPoints = computed<IslandTimePointDto[]>(() => {
|
||||
const source = props.timePoints.length
|
||||
? props.timePoints
|
||||
: today.value.map<IslandTimePointDto>((s) => ({
|
||||
id: s.id,
|
||||
timeType: 'LESSON',
|
||||
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(() =>
|
||||
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(
|
||||
() =>
|
||||
@@ -62,11 +114,11 @@ const hidden = computed(
|
||||
(r) =>
|
||||
(r === 'in-class' && props.state.inClass) ||
|
||||
(r === 'no-message' && !props.state.active) ||
|
||||
(r === 'no-class' && !current.value),
|
||||
(r === 'no-class' && !currentLesson.value),
|
||||
)),
|
||||
);
|
||||
const visibleChildren = computed(() =>
|
||||
props.node.kind === 'slide'
|
||||
props.node.kind === 'slide' && !props.editing
|
||||
? props.node.children.filter(
|
||||
(_, i) =>
|
||||
i ===
|
||||
@@ -95,6 +147,18 @@ const content = computed(() => {
|
||||
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
|
||||
: n.settings.text;
|
||||
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 time =
|
||||
n.settings.target === 'school'
|
||||
@@ -111,13 +175,6 @@ const content = computed(() => {
|
||||
}
|
||||
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 n = props.node,
|
||||
a = n.appearance,
|
||||
@@ -177,6 +234,7 @@ const style = computed<CSSProperties>(() => {
|
||||
:state="state"
|
||||
:now="now"
|
||||
:schedules="schedules"
|
||||
:time-points="timePoints"
|
||||
:editing="editing"
|
||||
:selected-id="selectedId"
|
||||
:alert-node-id="alertNodeId"
|
||||
@@ -188,26 +246,33 @@ const style = computed<CSSProperties>(() => {
|
||||
</ComponentNode>
|
||||
</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'">
|
||||
<div v-for="s in lessons" :key="s.id" class="lesson-line">
|
||||
<strong>{{ s.course?.name }}</strong>
|
||||
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
|
||||
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
|
||||
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
|
||||
</small>
|
||||
</div>
|
||||
<span v-if="!lessons.length">暂无课程</span>
|
||||
<div
|
||||
v-if="current && node.settings.showProgress && node.settings.display !== 'next'"
|
||||
class="lesson-progress"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(elapsed * 100)"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`"
|
||||
>
|
||||
<i :style="{ transform: `scaleX(${elapsed})` }" />
|
||||
</div>
|
||||
<template v-for="p in schedulePoints" :key="p.id">
|
||||
<span v-if="p.timeType === 'DIVIDER'" class="time-divider" aria-hidden="true">│</span>
|
||||
<div
|
||||
v-else
|
||||
class="lesson-line"
|
||||
:class="{ current: p.id === currentPoint?.id, break: p.timeType === 'BREAK' }"
|
||||
>
|
||||
<strong>{{ p.courseName || p.name || '—' }}</strong>
|
||||
<small v-if="p.id === currentPoint?.id && node.settings.showPeriod" class="lesson-time">
|
||||
{{ p.startTime }}–{{ p.endTime }}
|
||||
</small>
|
||||
<span
|
||||
v-if="p.id === currentPoint?.id && p.timeType === 'LESSON' && node.settings.showProgress"
|
||||
class="lesson-progress"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(elapsed * 100)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="`${p.courseName || '课程'}课程进度,距下课${remaining}秒`"
|
||||
>
|
||||
<i :style="{ transform: `scaleX(${elapsed})` }" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="!schedulePoints.length">暂无课程</span>
|
||||
</template>
|
||||
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
||||
<slot v-if="node.id === alertNodeId" name="alerts" />
|
||||
@@ -238,11 +303,13 @@ const style = computed<CSSProperties>(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule > .lesson-line {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.lesson-line strong {
|
||||
min-width: 0;
|
||||
@@ -250,6 +317,21 @@ const style = computed<CSSProperties>(() => {
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -257,10 +339,11 @@ const style = computed<CSSProperties>(() => {
|
||||
}
|
||||
.lesson-progress {
|
||||
position: absolute;
|
||||
inset: auto 4px 0;
|
||||
inset: auto 0 0;
|
||||
height: 2px;
|
||||
background: #ffffff24;
|
||||
overflow: hidden;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.lesson-progress i {
|
||||
display: block;
|
||||
@@ -272,12 +355,6 @@ const style = computed<CSSProperties>(() => {
|
||||
.schedule > .lesson-line + .lesson-line {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.lesson-remaining {
|
||||
font-size: var(--secondary, 14px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.separator > span {
|
||||
display: block;
|
||||
width: 1px;
|
||||
@@ -299,6 +376,9 @@ const style = computed<CSSProperties>(() => {
|
||||
.rolling > :deep(.component-node) {
|
||||
animation: rolling var(--rolling-duration) linear infinite;
|
||||
}
|
||||
.rolling.node-editing > :deep(.component-node) {
|
||||
animation: none;
|
||||
}
|
||||
.selected {
|
||||
outline: 2px solid #85aaff;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type IslandAppearance,
|
||||
type IslandLayoutSettings,
|
||||
type IslandState,
|
||||
type IslandTimePointDto,
|
||||
type ScheduleDto,
|
||||
type IslandLessonReminder,
|
||||
} from '@classhelper/shared';
|
||||
@@ -20,6 +21,7 @@ const props = defineProps<{
|
||||
settings: IslandLayoutSettings;
|
||||
state: IslandState;
|
||||
schedules: ScheduleDto[];
|
||||
timePoints: IslandTimePointDto[];
|
||||
appearance: IslandAppearance;
|
||||
nativeVisible: boolean;
|
||||
}>();
|
||||
@@ -267,10 +269,19 @@ function visibility() {
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, {
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
});
|
||||
watch(
|
||||
[
|
||||
expanded,
|
||||
scheme,
|
||||
lesson,
|
||||
() => props.state.listExpanded,
|
||||
messages,
|
||||
() => props.timePoints,
|
||||
() => Math.floor(now.value / 60_000),
|
||||
],
|
||||
measure,
|
||||
{ deep: true, flush: 'post' },
|
||||
);
|
||||
watch(
|
||||
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
|
||||
() => {
|
||||
@@ -388,6 +399,7 @@ onUnmounted(() => {
|
||||
:state="state"
|
||||
:now="now"
|
||||
:schedules="schedules"
|
||||
:time-points="timePoints"
|
||||
:editing="false"
|
||||
selected-id=""
|
||||
alert-node-id=""
|
||||
@@ -469,6 +481,7 @@ onUnmounted(() => {
|
||||
:state="state"
|
||||
:now="now"
|
||||
:schedules="schedules"
|
||||
:time-points="timePoints"
|
||||
:editing="false"
|
||||
selected-id=""
|
||||
alert-node-id=""
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
type IslandAppearance,
|
||||
type IslandState,
|
||||
type IslandTimePointDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import FixedIsland from './IslandApp.vue';
|
||||
@@ -14,6 +15,7 @@ import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
||||
const settings = ref(defaultIslandLayouts()),
|
||||
ready = ref(false);
|
||||
const schedules = ref<ScheduleDto[]>([]);
|
||||
const timePoints = ref<IslandTimePointDto[]>([]);
|
||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const nativeVisible = ref(false);
|
||||
const resident = () => settings.value.layout === 'custom';
|
||||
@@ -36,6 +38,7 @@ onMounted(async () => {
|
||||
if (bridge) {
|
||||
bridge.onLayout((value) => (settings.value = value));
|
||||
bridge.onSchedules((value) => (schedules.value = value));
|
||||
bridge.onTimePoints((value) => (timePoints.value = value));
|
||||
bridge.onState((value) => (state.value = value));
|
||||
bridge.onAppearance((value) => (appearance.value = value));
|
||||
bridge.onVisibility((value) => (nativeVisible.value = value));
|
||||
@@ -43,6 +46,7 @@ onMounted(async () => {
|
||||
appearance.value = await bridge.getAppearance();
|
||||
settings.value = await bridge.getLayout();
|
||||
schedules.value = await bridge.getSchedules();
|
||||
timePoints.value = await bridge.getTimePoints();
|
||||
state.value = await bridge.getState();
|
||||
}
|
||||
await nextTick();
|
||||
@@ -55,9 +59,18 @@ onMounted(async () => {
|
||||
:settings="settings"
|
||||
:state="state"
|
||||
:schedules="schedules"
|
||||
:time-points="timePoints"
|
||||
:appearance="appearance"
|
||||
:native-visible="nativeVisible"
|
||||
/>
|
||||
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" />
|
||||
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" />
|
||||
<FixedIsland
|
||||
v-if="ready"
|
||||
v-show="!resident() && !state.coursewareTransfer"
|
||||
:suspended="resident() || !!state.coursewareTransfer"
|
||||
/>
|
||||
<CoursewareTransfer
|
||||
v-if="state.coursewareTransfer"
|
||||
:transfer="state.coursewareTransfer"
|
||||
:appearance="appearance"
|
||||
/>
|
||||
</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>
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listLocalCourseware,
|
||||
} from './courseware.js';
|
||||
import { island } from './island.js';
|
||||
import { getIslandWeather } from './island-weather.js';
|
||||
import { getUpdateProgress } from './auto-update.js';
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
@@ -21,6 +22,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||
export { IPC_CHANNELS };
|
||||
|
||||
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:reveal', (_event, id: string) => revealCourseware(id));
|
||||
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;
|
||||
}
|
||||
@@ -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 { pathToFileURL } from 'node:url';
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
@@ -138,6 +141,7 @@ export interface IslandPushContext {
|
||||
|
||||
export interface IslandClassStatePayload {
|
||||
schedules?: ContractScheduleDto[];
|
||||
timePoints?: ContractIslandTimePointDto[];
|
||||
inClass: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
@@ -364,14 +368,15 @@ class IslandController {
|
||||
if (this.customTimer) clearTimeout(this.customTimer);
|
||||
this.customTimer = null;
|
||||
// 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。
|
||||
if (
|
||||
this.customSize.width !== Math.ceil(value.width) ||
|
||||
this.customSize.height !== Math.ceil(value.height)
|
||||
)
|
||||
const width = Math.ceil(value.width);
|
||||
const height = Math.ceil(value.height);
|
||||
const changed = this.customSize.width !== width || this.customSize.height !== height;
|
||||
if (changed)
|
||||
this.residentMotionUntil =
|
||||
Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
|
||||
this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) };
|
||||
this.syncResidentShape(true);
|
||||
this.customSize = { width, height };
|
||||
// 时钟每分钟触发的重复测量尺寸没变,不要清空触摸形状(那 750ms 会漏掉点击)。
|
||||
if (changed || !this.residentShaped) this.syncResidentShape(true);
|
||||
return;
|
||||
}
|
||||
if (this.customTimer) clearTimeout(this.customTimer);
|
||||
@@ -618,6 +623,7 @@ class IslandController {
|
||||
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
|
||||
this.emitAppearance();
|
||||
this.win?.webContents.send('island:schedules', this.schedules);
|
||||
this.win?.webContents.send('island:time-points', this.timePoints);
|
||||
this.emit();
|
||||
if (this.residentEnabled) {
|
||||
this.residentWasEnabled = true;
|
||||
@@ -634,6 +640,10 @@ class IslandController {
|
||||
getSchedules(): ContractScheduleDto[] {
|
||||
return [...this.schedules];
|
||||
}
|
||||
private timePoints: ContractIslandTimePointDto[] = [];
|
||||
getTimePoints(): ContractIslandTimePointDto[] {
|
||||
return [...this.timePoints];
|
||||
}
|
||||
|
||||
getState(): IslandState {
|
||||
return {
|
||||
@@ -860,6 +870,11 @@ class IslandController {
|
||||
if (this.ready && this.win && !this.win.isDestroyed())
|
||||
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;
|
||||
this.state.inClass = payload.inClass;
|
||||
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
|
||||
@@ -2320,6 +2335,9 @@ export function registerIslandIpc(): void {
|
||||
ipcMain.handle('island:get-schedules', (event) =>
|
||||
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:save-layout', (_event, value: unknown) => {
|
||||
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
|
||||
|
||||
@@ -3,7 +3,13 @@ import { BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { island } from './island.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';
|
||||
export async function verifyLayoutSmoke(
|
||||
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
|
||||
@@ -38,12 +44,30 @@ export async function verifyLayoutSmoke(
|
||||
editingBounds = win.getBounds();
|
||||
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
|
||||
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 (
|
||||
(await editor.webContents.executeJavaScript(
|
||||
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
|
||||
)) &&
|
||||
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;
|
||||
});
|
||||
await check('组件可以包裹进容器且设置独立保存', async () => {
|
||||
await editor.webContents.executeJavaScript(
|
||||
`document.querySelector('.node-handle[data-kind=clock]')?.click()`,
|
||||
);
|
||||
await pause();
|
||||
await editor.webContents.executeJavaScript(
|
||||
`document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
|
||||
);
|
||||
await pause();
|
||||
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();
|
||||
return (
|
||||
@@ -108,6 +136,10 @@ export async function verifyLayoutSmoke(
|
||||
return initial && appearance && advanced;
|
||||
});
|
||||
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(`(() => {
|
||||
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
|
||||
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
|
||||
@@ -118,11 +150,190 @@ export async function verifyLayoutSmoke(
|
||||
return editor.webContents.executeJavaScript(`(() => {
|
||||
const root=document.documentElement, wasDark=root.classList.contains('dark');
|
||||
root.classList.add('dark');
|
||||
const card=document.querySelector('.scheme-card'), style=getComputedStyle(card);
|
||||
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4;
|
||||
const page=document.querySelector('.island-editor-page'), style=getComputedStyle(page);
|
||||
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);
|
||||
})()`);
|
||||
});
|
||||
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) {
|
||||
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
|
||||
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
islandNode,
|
||||
normalizeIslandLayouts,
|
||||
ISLAND_SHADOW_PAD,
|
||||
type IslandTimePointDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
@@ -607,7 +608,9 @@ export async function verifyResidentSmoke(
|
||||
await pause(1100);
|
||||
const half = await win.webContents.executeJavaScript(`(() => {
|
||||
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)
|
||||
throw new Error(
|
||||
@@ -679,6 +682,87 @@ export async function verifyResidentSmoke(
|
||||
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 () => {
|
||||
const appearance = island.getAppearance();
|
||||
const dense = JSON.parse(JSON.stringify(fixture));
|
||||
@@ -743,15 +827,26 @@ export async function verifyResidentSmoke(
|
||||
island.handleAction({ action: 'expand' });
|
||||
await pause(1000);
|
||||
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();
|
||||
return Boolean(
|
||||
hit &&
|
||||
shape.length === 1 &&
|
||||
shape[0]!.width <= hit.width + 18 &&
|
||||
shape[0]!.height <= hit.height + 18 &&
|
||||
JSON.stringify(box) === JSON.stringify(win.getBounds()),
|
||||
);
|
||||
const ok = Boolean(settled && hit && JSON.stringify(box) === JSON.stringify(win.getBounds()));
|
||||
if (!ok) throw new Error(JSON.stringify({ hit, shape, bounds: win.getBounds(), box, settled }));
|
||||
return true;
|
||||
} finally {
|
||||
win.setShape = original;
|
||||
island.setTouchMode(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||||
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
|
||||
*/
|
||||
const bridge: DesktopBridge = {
|
||||
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
|
||||
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
|
||||
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
|
||||
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
IslandLayoutSettings as ContractIslandLayoutSettings,
|
||||
IslandTimePointDto as ContractIslandTimePointDto,
|
||||
IslandWeather,
|
||||
ScheduleDto as ContractScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
@@ -10,6 +12,7 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
|
||||
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
|
||||
*/
|
||||
export interface IslandBridge {
|
||||
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
|
||||
getLayout(): Promise<ContractIslandLayoutSettings>;
|
||||
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
|
||||
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
|
||||
@@ -24,6 +27,8 @@ export interface IslandBridge {
|
||||
onViewport(handler: (value: { width: number; height: number }) => void): () => void;
|
||||
getSchedules(): Promise<ContractScheduleDto[]>;
|
||||
onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
|
||||
getTimePoints(): Promise<ContractIslandTimePointDto[]>;
|
||||
onTimePoints(handler: (value: ContractIslandTimePointDto[]) => void): void;
|
||||
|
||||
onState(handler: (state: IslandState) => void): void;
|
||||
sendAction(
|
||||
@@ -57,6 +62,7 @@ export interface IslandBridge {
|
||||
}
|
||||
|
||||
const bridge: IslandBridge = {
|
||||
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
|
||||
getLayout: () => ipcRenderer.invoke('island:get-layout'),
|
||||
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
|
||||
onLayout: (handler) => {
|
||||
@@ -84,6 +90,10 @@ const bridge: IslandBridge = {
|
||||
onSchedules: (handler) => {
|
||||
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) => {
|
||||
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-form-item>
|
||||
</template>
|
||||
<el-form-item v-else-if="node.kind === 'countdown'" label="倒计时目标">
|
||||
<el-select v-model="node.settings.target" @change="emit('change')">
|
||||
<el-option label="自动:距上课 / 下课" value="auto" />
|
||||
<el-option label="下一节课" value="next" />
|
||||
<el-option label="本节下课" value="end" />
|
||||
<el-option label="放学" value="school" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-else-if="node.kind === 'countdown'">
|
||||
<el-form-item label="倒计时目标">
|
||||
<el-select v-model="node.settings.target" @change="emit('change')">
|
||||
<el-option label="自动:距上课 / 下课" value="auto" />
|
||||
<el-option label="下一节课" value="next" />
|
||||
<el-option label="本节下课" value="end" />
|
||||
<el-option label="放学" value="school" />
|
||||
<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'">
|
||||
<el-form-item label="文字内容">
|
||||
<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'">
|
||||
<el-form-item label="显示课程">
|
||||
<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="today" />
|
||||
<el-option label="今日时间点" value="today" />
|
||||
</el-select>
|
||||
</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-form-item>
|
||||
<el-form-item label="显示课程时间">
|
||||
<el-form-item label="当前时间点显示起止时间">
|
||||
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示上课进度">
|
||||
<el-form-item label="当前课程显示进度">
|
||||
<el-switch v-model="node.settings.showProgress" @change="emit('change')" />
|
||||
</el-form-item>
|
||||
<p class="hint">
|
||||
今日时间点只展开当前项并显示时间段;课间休息与默认隐藏的时间点折叠时完全隐藏,轮到时才展开。
|
||||
下课前一分钟的倒计时由「提醒」设置里的下课提醒负责。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
|
||||
<el-form-item label="排列方向">
|
||||
@@ -80,7 +133,7 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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 v-if="node.kind === 'slide'" label="切换间隔(秒)">
|
||||
<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,
|
||||
type ClassPeriod,
|
||||
type HomeworkDto,
|
||||
type IslandTimePointDto,
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -30,6 +31,8 @@ const TICK_INTERVAL_MS = 1_000;
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
let schedules: ScheduleDto[] = [];
|
||||
/** 今天的完整时间点(含课间/分割线/默认隐藏),自定义岛的课程表组件使用。 */
|
||||
let dayTimePoints: IslandTimePointDto[] = [];
|
||||
let currentPeriod: ClassPeriod | null = null;
|
||||
let inClass = false;
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -47,6 +50,51 @@ export function getIslandSchedules(): ScheduleDto[] {
|
||||
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> {
|
||||
const appStore = useAppStore();
|
||||
@@ -61,13 +109,16 @@ export async function refreshIslandSchedules(): Promise<void> {
|
||||
'schedules',
|
||||
`week:${week}`,
|
||||
async () => {
|
||||
const data = (
|
||||
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week })
|
||||
).days.flatMap((day) => day.schedules);
|
||||
const payload = await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', {
|
||||
classId,
|
||||
week,
|
||||
});
|
||||
const data = payload.days.flatMap((day) => day.schedules);
|
||||
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
|
||||
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
|
||||
throw new Error('课表刷新已被新请求替代');
|
||||
schedules = data;
|
||||
dayTimePoints = buildDayTimePoints(payload.days);
|
||||
scheduleWeek = week;
|
||||
recomputeClassState(true);
|
||||
return data;
|
||||
@@ -78,12 +129,22 @@ export async function refreshIslandSchedules(): Promise<void> {
|
||||
if (result.error && !result.updatedAt) return;
|
||||
if (result.fromCache) {
|
||||
schedules = result.data;
|
||||
dayTimePoints = timePointsFromSchedules(result.data);
|
||||
scheduleWeek = week;
|
||||
recomputeClassState(true);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const day = new Date().toDateString();
|
||||
const appStore = useAppStore();
|
||||
@@ -94,15 +155,19 @@ function recomputeClassState(force = false): void {
|
||||
currentPeriod?.endTime === status.current?.endTime;
|
||||
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;
|
||||
inClass = nextInClass;
|
||||
sentTimePoints = pointsKey;
|
||||
|
||||
window.desktop?.islandSetClassState({
|
||||
inClass,
|
||||
currentPeriodEnd: currentPeriod?.endTime ?? null,
|
||||
week: appStore.currentWeek,
|
||||
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('__connected__', onScheduleUpdated);
|
||||
sentDay = '';
|
||||
sentTimePoints = '';
|
||||
schedules = [];
|
||||
dayTimePoints = [];
|
||||
currentPeriod = null;
|
||||
inClass = false;
|
||||
|
||||
@@ -401,5 +468,6 @@ export function stopIslandBridge(): void {
|
||||
currentPeriodEnd: null,
|
||||
week: useAppStore().currentWeek,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
IslandLayoutSettings as ContractIslandLayoutSettings,
|
||||
IslandTimePointDto as ContractIslandTimePointDto,
|
||||
IslandWeather,
|
||||
ReleaseProgressDto as ContractReleaseProgressDto,
|
||||
ScheduleDto as ContractScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -111,6 +113,8 @@ export interface IslandPushContext {
|
||||
|
||||
export interface IslandClassStatePayload {
|
||||
schedules?: ContractScheduleDto[];
|
||||
/** 今天的完整时间点(含课间/分割线/默认隐藏标记),自定义岛课程表组件使用。 */
|
||||
timePoints?: ContractIslandTimePointDto[];
|
||||
inClass: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
@@ -175,6 +179,7 @@ export interface DesktopBridge {
|
||||
onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
|
||||
|
||||
/* 灵动岛 */
|
||||
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
|
||||
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
|
||||
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
||||
islandRemoveNotification(id: string): void;
|
||||
|
||||
@@ -2224,6 +2224,114 @@ async function main() {
|
||||
`status=${studentLayoutList.status}`,
|
||||
);
|
||||
|
||||
// 8.2.1 时间点默认课程:随布局返回、无条目时回退、覆盖现有课程写入条目
|
||||
if (layoutId && courseId) {
|
||||
const defaultCourseName = courses.payload?.data?.[0]?.name ?? '';
|
||||
const savedLayout = await api(`/time-layouts/${layoutId}`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: {
|
||||
name: '默认时间表',
|
||||
classId,
|
||||
points: [
|
||||
{
|
||||
name: '第 1 节',
|
||||
startTime: '08:00',
|
||||
endTime: '08:40',
|
||||
timeType: 'LESSON',
|
||||
defaultClassId: courseId,
|
||||
isHideDefault: false,
|
||||
breakName: '',
|
||||
actionSet: '{}',
|
||||
},
|
||||
{
|
||||
name: '课间',
|
||||
startTime: '08:40',
|
||||
endTime: '08:50',
|
||||
timeType: 'BREAK',
|
||||
breakName: '课间休息',
|
||||
actionSet: '{}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const savedPoints = savedLayout.payload?.data?.points ?? [];
|
||||
const lessonPoint = savedPoints.find((point) => point.timeType === 'LESSON');
|
||||
record(
|
||||
'时间表保存默认课程并随布局返回',
|
||||
savedLayout.status === 200 &&
|
||||
lessonPoint?.defaultClassId === courseId &&
|
||||
lessonPoint?.defaultCourse?.name === defaultCourseName,
|
||||
`status=${savedLayout.status} default=${lessonPoint?.defaultCourse?.name ?? '-'}`,
|
||||
);
|
||||
|
||||
const defaultPlan = await api('/class-plans', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId,
|
||||
name: '默认课程验证课表',
|
||||
timeLayoutId: layoutId,
|
||||
weekday: 0,
|
||||
weekCountDiv: 0,
|
||||
weekCountDivTotal: 2,
|
||||
weekStart: 1,
|
||||
weekEnd: 30,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
const defaultPlanId = defaultPlan.payload?.data?.id ?? null;
|
||||
|
||||
const activeDefault = await api(`/schedules/active-week?classId=${classId}&week=1`, {
|
||||
token: teacherToken,
|
||||
});
|
||||
const fallback = (activeDefault.payload?.data?.days ?? [])
|
||||
.flatMap((day) => day.schedules ?? [])
|
||||
.find((item) => item.course?.name === defaultCourseName && String(item.id).startsWith('default:'));
|
||||
record(
|
||||
'日课表没有单独条目时回退时间点默认课程',
|
||||
activeDefault.status === 200 && Boolean(fallback),
|
||||
`status=${activeDefault.status} fallback=${fallback ? fallback.course?.name : '-'}`,
|
||||
);
|
||||
|
||||
if (lessonPoint?.id) {
|
||||
const applied = await api(`/time-layouts/${layoutId}/points/${lessonPoint.id}/apply-default`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
});
|
||||
const plansAfterApply = await api(`/class-plans?classId=${classId}`, { token: teacherToken });
|
||||
const appliedPlan = (plansAfterApply.payload?.data ?? []).find((item) => item.id === defaultPlanId);
|
||||
const appliedEntry = appliedPlan?.entries?.find((entry) => entry.timeLayoutItemId === lessonPoint.id);
|
||||
record(
|
||||
'覆盖现有课程写入所有引用课表',
|
||||
applied.status === 200 &&
|
||||
(applied.payload?.data?.plans ?? 0) >= 1 &&
|
||||
appliedEntry?.courseId === courseId,
|
||||
`status=${applied.status} plans=${applied.payload?.data?.plans ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultPlanId) {
|
||||
await api(`/class-plans/${defaultPlanId}`, { method: 'DELETE', token: teacherToken });
|
||||
}
|
||||
}
|
||||
|
||||
// 8.2.2 时间表可通过新路由删除(Web 时间表编辑器使用的接口)
|
||||
const removableLayout = await api('/time-layouts', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { name: '临时删除验证', classId, points: [] },
|
||||
});
|
||||
const removableId = removableLayout.payload?.data?.id ?? null;
|
||||
const removedLayout = removableId
|
||||
? await api(`/time-layouts/${removableId}`, { method: 'DELETE', token: adminToken })
|
||||
: { status: 0 };
|
||||
record(
|
||||
'时间表可通过新路由删除',
|
||||
removableLayout.status === 201 && removedLayout.status === 200,
|
||||
`创建=${removableLayout.status} 删除=${removedLayout.status}`,
|
||||
);
|
||||
|
||||
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
|
||||
const gradesCsv = [
|
||||
'学号,姓名,考试名称,分数,总分,科目',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
dayKeyLocal,
|
||||
resolveClassStatus,
|
||||
type CourseBrief,
|
||||
type ScheduleDto,
|
||||
type ActiveClassPlanDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -20,30 +21,96 @@ export async function loadPlans(classId: string) {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
export function projectPlan(plan: PlanRow): ScheduleDto[] {
|
||||
return plan.entries
|
||||
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
|
||||
.map<ScheduleDto>((e) => ({
|
||||
id: e.legacyScheduleId || e.id,
|
||||
classId: plan.classId,
|
||||
courseId: e.courseId!,
|
||||
course: e.course!,
|
||||
dayOfWeek: plan.weekday || 1,
|
||||
startTime: e.timePoint.startTime,
|
||||
endTime: e.timePoint.endTime,
|
||||
location: e.location,
|
||||
weekStart: plan.weekStart,
|
||||
weekEnd: plan.weekEnd,
|
||||
weekParity:
|
||||
plan.weekCountDivTotal === 2 && plan.weekCountDiv
|
||||
? plan.weekCountDiv === 1
|
||||
? 'ODD'
|
||||
: 'EVEN'
|
||||
: 'ALL',
|
||||
source: 'manual',
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
/** 时间点的默认课程映射(defaultClassId → 课程摘要)。 */
|
||||
export type DefaultCourseMap = Map<string, CourseBrief>;
|
||||
|
||||
/**
|
||||
* 一次性取出时间表里所有默认课程。
|
||||
*
|
||||
* 刻意不在 Prisma 里给 `TimeLayoutItem.defaultClassId` 加关系:该字段早已存在,
|
||||
* 加外键要重建表;这里按 id 查一次课程即可,SQLite/MySQL 都通用。
|
||||
*/
|
||||
export async function loadDefaultCourseMap(
|
||||
points: ReadonlyArray<{ defaultClassId: string | null }>,
|
||||
): Promise<DefaultCourseMap> {
|
||||
const ids = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
|
||||
if (!ids.length) return new Map();
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return new Map(courses.map((course) => [course.id, course]));
|
||||
}
|
||||
|
||||
/** 给时间表时间点附加默认课程,供 Web 编辑器与客户端读取。 */
|
||||
export async function decorateLayout<
|
||||
T extends { classId: string | null; points: Array<{ defaultClassId: string | null }> },
|
||||
>(
|
||||
layout: T,
|
||||
): Promise<
|
||||
Omit<T, 'points'> & { points: Array<T['points'][number] & { defaultCourse: CourseBrief | null }> }
|
||||
> {
|
||||
const defaults = await loadDefaultCourseMap(layout.points);
|
||||
return {
|
||||
...layout,
|
||||
points: layout.points.map((point) => ({
|
||||
...point,
|
||||
// 全校时间表不能携带默认课程。
|
||||
defaultCourse:
|
||||
layout.classId && point.defaultClassId ? (defaults.get(point.defaultClassId) ?? null) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 日课表投影。
|
||||
*
|
||||
* 按**时间表的 LESSON 时间点**遍历,而不是只遍历已保存的条目:
|
||||
* - 有条目:以条目为准(停用则整节不出现,课程为空则是显式空课,不回退);
|
||||
* - 没有条目:回退时间点的默认课程。
|
||||
* 默认课程的回退项没有 `ClassPlanEntry`,用 `default:<planId>:<pointId>` 作为稳定 id,
|
||||
* 仅供读取展示;旧 `/schedules` 写接口不会命中它。
|
||||
*/
|
||||
export function projectPlan(plan: PlanRow, defaults: DefaultCourseMap = new Map()): ScheduleDto[] {
|
||||
const entryOf = new Map(plan.entries.map((entry) => [entry.timeLayoutItemId, entry]));
|
||||
return plan.layout.points
|
||||
.filter((point) => point.timeType === 'LESSON')
|
||||
.flatMap<ScheduleDto>((point) => {
|
||||
const entry = entryOf.get(point.id);
|
||||
if (entry && !entry.isEnabled) return [];
|
||||
// 全校时间表不能携带默认课程:旧数据即使残留 DefaultClassId 也不参与回退,
|
||||
// 否则会把这门课泄漏到别的班级。
|
||||
const fallback =
|
||||
!entry && point.defaultClassId && plan.layout.classId
|
||||
? (defaults.get(point.defaultClassId) ?? null)
|
||||
: null;
|
||||
const course = entry ? (entry.course ?? null) : fallback;
|
||||
if (!course) return [];
|
||||
return [
|
||||
{
|
||||
id: entry?.legacyScheduleId || entry?.id || `default:${plan.id}:${point.id}`,
|
||||
classId: plan.classId,
|
||||
courseId: course.id,
|
||||
course,
|
||||
dayOfWeek: plan.weekday || 1,
|
||||
startTime: point.startTime,
|
||||
endTime: point.endTime,
|
||||
location: entry?.location ?? null,
|
||||
weekStart: plan.weekStart,
|
||||
weekEnd: plan.weekEnd,
|
||||
weekParity:
|
||||
plan.weekCountDivTotal === 2 && plan.weekCountDiv
|
||||
? plan.weekCountDiv === 1
|
||||
? 'ODD'
|
||||
: 'EVEN'
|
||||
: 'ALL',
|
||||
source: 'manual',
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
}
|
||||
|
||||
@@ -103,8 +170,9 @@ export async function resolveActiveClassPlan(
|
||||
candidates.find((p) => p.group?.isGlobal) ??
|
||||
candidates.find((p) => !p.associatedGroup) ??
|
||||
null;
|
||||
const defaults = plan ? await loadDefaultCourseMap(plan.layout.points) : new Map<string, CourseBrief>();
|
||||
const schedules = plan
|
||||
? projectPlan(plan).map((s) => ({
|
||||
? projectPlan(plan, defaults).map((s) => ({
|
||||
...s,
|
||||
dayOfWeek: weekday,
|
||||
weekStart: week,
|
||||
@@ -120,6 +188,8 @@ export async function resolveActiveClassPlan(
|
||||
points: plan.layout.points.map((p) => ({
|
||||
...p,
|
||||
timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION',
|
||||
defaultCourse:
|
||||
plan.layout.classId && p.defaultClassId ? (defaults.get(p.defaultClassId) ?? null) : null,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -79,6 +79,13 @@ layouts.post(
|
||||
);
|
||||
},
|
||||
);
|
||||
layouts.post(
|
||||
'/:id/points/:pointId/apply-default',
|
||||
requirePermission('timetable:manage'),
|
||||
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))),
|
||||
);
|
||||
layouts.delete(
|
||||
'/:id',
|
||||
requirePermission('timetable:manage'),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getMaxTermWeeks } from '../../lib/term.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { planInclude } from '../../lib/class-plan.js';
|
||||
import { decorateLayout, planInclude } from '../../lib/class-plan.js';
|
||||
import type { z } from 'zod';
|
||||
import type {
|
||||
planSchema,
|
||||
@@ -26,14 +26,31 @@ export async function notifyScheduleChanged(classId?: string | null) {
|
||||
export async function listLayouts(user: TokenPayload, classId?: string) {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const ids = classId ? [classId] : await getAccessibleClassIds(user);
|
||||
return prisma.timeLayout.findMany({
|
||||
const rows = await prisma.timeLayout.findMany({
|
||||
where: { OR: [{ classId: null }, { classId: { in: ids } }] },
|
||||
include: { points: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return Promise.all(rows.map((row) => decorateLayout(row)));
|
||||
}
|
||||
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
|
||||
if (id && !(await prisma.timeLayout.findUnique({ where: { id } }))) throw ApiError.notFound('时间表不存在');
|
||||
const pointIds = input.points.flatMap((p) => (p.id ? [p.id] : []));
|
||||
const existing = id
|
||||
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
|
||||
: null;
|
||||
if (id && !existing) throw ApiError.notFound('时间表不存在');
|
||||
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
|
||||
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
|
||||
const points = input.points.map((point) => ({
|
||||
...point,
|
||||
defaultClassId: point.timeType === 'LESSON' && layoutClassId ? (point.defaultClassId ?? null) : null,
|
||||
}));
|
||||
const defaultIds = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
|
||||
if (defaultIds.length && layoutClassId) {
|
||||
const count = await prisma.course.count({
|
||||
where: { id: { in: defaultIds }, classId: layoutClassId },
|
||||
});
|
||||
if (count !== defaultIds.length) throw ApiError.badRequest('默认课程不属于该班级');
|
||||
}
|
||||
const pointIds = points.flatMap((p) => (p.id ? [p.id] : []));
|
||||
if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复');
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const layout = id
|
||||
@@ -48,7 +65,7 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
|
||||
if (pointIds.some((p) => !owned.some((o) => o.id === p)))
|
||||
throw ApiError.badRequest('时间点不属于这份时间表');
|
||||
await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } });
|
||||
for (const [position, point] of input.points.entries()) {
|
||||
for (const [position, point] of points.entries()) {
|
||||
const { id: pointId, ...data } = point;
|
||||
if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } });
|
||||
else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } });
|
||||
@@ -59,7 +76,49 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
|
||||
});
|
||||
});
|
||||
await notifyScheduleChanged();
|
||||
return result;
|
||||
return decorateLayout(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
|
||||
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
|
||||
*/
|
||||
export async function applyDefaultCourse(layoutId: string, pointId: string) {
|
||||
const layout = await prisma.timeLayout.findUnique({
|
||||
where: { id: layoutId },
|
||||
include: { points: true },
|
||||
});
|
||||
if (!layout) throw ApiError.notFound('时间表不存在');
|
||||
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
|
||||
const point = layout.points.find((p) => p.id === pointId);
|
||||
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
|
||||
if (!point.defaultClassId) throw ApiError.badRequest('请先设置默认课程');
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: point.defaultClassId, classId: layout.classId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!course) throw ApiError.badRequest('默认课程不属于该班级');
|
||||
const plans = await prisma.classPlan.findMany({
|
||||
where: { timeLayoutId: layout.id, classId: layout.classId },
|
||||
select: { id: true },
|
||||
});
|
||||
const courseId = point.defaultClassId;
|
||||
await prisma.$transaction(
|
||||
plans.map((plan) =>
|
||||
prisma.classPlanEntry.upsert({
|
||||
where: { classPlanId_timeLayoutItemId: { classPlanId: plan.id, timeLayoutItemId: point.id } },
|
||||
create: {
|
||||
classPlanId: plan.id,
|
||||
timeLayoutItemId: point.id,
|
||||
courseId,
|
||||
isEnabled: true,
|
||||
},
|
||||
update: { courseId, isEnabled: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await notifyScheduleChanged(layout.classId);
|
||||
return { plans: plans.length };
|
||||
}
|
||||
export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
requireClassDeviceClassId,
|
||||
} from '../../lib/access.js';
|
||||
import {
|
||||
loadDefaultCourseMap,
|
||||
loadPlans,
|
||||
planInclude,
|
||||
projectPlan,
|
||||
@@ -52,9 +53,11 @@ export async function listSchedules(
|
||||
result.push(...(await resolveActiveClassPlan(classId, date)).schedules);
|
||||
}
|
||||
} else {
|
||||
for (const plan of await loadPlans(classId))
|
||||
const plans = await loadPlans(classId);
|
||||
const defaults = await loadDefaultCourseMap(plans.flatMap((plan) => plan.layout.points));
|
||||
for (const plan of plans)
|
||||
if (!plan.isOverlay && (!options.dayOfWeek || plan.weekday === options.dayOfWeek))
|
||||
result.push(...projectPlan(plan));
|
||||
result.push(...projectPlan(plan, defaults));
|
||||
}
|
||||
}
|
||||
const byId = new Map<string, ScheduleDto>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type TimePointType = (typeof TIME_POINT_TYPES)[number];
|
||||
@@ -12,6 +12,8 @@ export interface TimePointDto {
|
||||
timeType: TimePointType;
|
||||
isHideDefault: boolean;
|
||||
defaultClassId: string | null;
|
||||
/** 服务端投影出的默认课程;日课表没有单独条目时用它回退。 */
|
||||
defaultCourse?: CourseBrief | null;
|
||||
breakName: string;
|
||||
actionSet: string;
|
||||
}
|
||||
@@ -25,6 +27,8 @@ export interface DailyPlanEntryDto {
|
||||
id: string;
|
||||
timeLayoutItemId: string;
|
||||
courseId: string | null;
|
||||
/** 服务端 include 的课程摘要;课表页据此显示科目。 */
|
||||
course?: CourseBrief | null;
|
||||
isEnabled: boolean;
|
||||
isChangedClass: boolean;
|
||||
location: string | null;
|
||||
@@ -60,6 +64,25 @@ export interface ActiveClassPlanWeekDto {
|
||||
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。 */
|
||||
export function getMaxTermWeeks(configured?: unknown): number {
|
||||
const value = Number(configured);
|
||||
|
||||
@@ -507,7 +507,7 @@ export const CLASSISLAND_TIME_TYPES = {
|
||||
*/
|
||||
export const CLASSISLAND_TIME_TYPE_LABELS: Record<number, string> = {
|
||||
0: '上课',
|
||||
1: '课间',
|
||||
1: '课间休息',
|
||||
2: '分割线',
|
||||
3: '行动',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { isDayKey } from './utils.js';
|
||||
|
||||
export const ISLAND_COMPONENTS = [
|
||||
'date',
|
||||
'clock',
|
||||
'weather',
|
||||
'countdown',
|
||||
'schedule',
|
||||
'text',
|
||||
@@ -32,17 +35,25 @@ export const CLASSISLAND_ISLAND_METRICS = {
|
||||
fadedOpacity: 0.05,
|
||||
} as const;
|
||||
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> = {
|
||||
date: '日期',
|
||||
clock: '时钟',
|
||||
weather: '天气简报',
|
||||
countdown: '倒计时',
|
||||
schedule: '课程表',
|
||||
text: '文本',
|
||||
separator: '分隔符',
|
||||
slide: '轮播',
|
||||
rolling: '滚动',
|
||||
group: '分组',
|
||||
stack: '堆叠',
|
||||
separator: '分割线',
|
||||
slide: '轮播容器',
|
||||
rolling: '滚动容器',
|
||||
group: '分组容器',
|
||||
stack: '堆叠容器',
|
||||
notification: '通知',
|
||||
homework: '作业',
|
||||
call: '叫人',
|
||||
@@ -71,7 +82,12 @@ export interface IslandComponentNode {
|
||||
template: boolean;
|
||||
hour12: 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';
|
||||
count: number;
|
||||
showPeriod: boolean;
|
||||
@@ -165,6 +181,11 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
|
||||
hour12: false,
|
||||
seconds: true,
|
||||
target: 'auto',
|
||||
targetDate: '',
|
||||
countdownTitle: '目标日',
|
||||
locationName: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
display: 'current',
|
||||
count: 5,
|
||||
showPeriod: true,
|
||||
@@ -260,7 +281,18 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
|
||||
template: s.template === true,
|
||||
hour12: s.hour12 === true,
|
||||
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',
|
||||
count: clamp(s.count, 1, 40, 5),
|
||||
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',
|
||||
back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3',
|
||||
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>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const CLASS_PLAN_SAMPLE = `{
|
||||
"Name": "周一作息",
|
||||
"Layouts": [
|
||||
{ "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": "" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -243,7 +243,7 @@ function fillSample(): void {
|
||||
<el-table-column prop="endTime" label="结束" width="90" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.type === 'break' ? '课间' : row.type === 'divider' ? '分割线' : '上课' }}
|
||||
{{ row.type === 'break' ? '课间休息' : row.type === 'divider' ? '分割线' : '上课' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="跳过" width="80">
|
||||
|
||||
@@ -1363,15 +1363,40 @@ async function runPlanEditorChecks(win) {
|
||||
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
|
||||
const ready = await waitFor(
|
||||
win,
|
||||
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
|
||||
`Boolean(document.querySelector('.course-table')) && Boolean(document.querySelector('.plan-explorer')) && Boolean(document.querySelector('.subject-palette'))`,
|
||||
);
|
||||
record(
|
||||
'课表档案在管理端页面内展示周课表和选科侧栏',
|
||||
'课表档案默认进入单表编辑并展示选科侧栏',
|
||||
ready &&
|
||||
(await win.webContents.executeJavaScript(
|
||||
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4 && getComputedStyle(document.querySelector('.archive-editor')).position !== 'fixed' && Boolean(document.querySelector('.archive-page .page-title'))`,
|
||||
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4 && getComputedStyle(document.querySelector('.archive-editor')).position !== 'fixed' && Boolean(document.querySelector('.archive-page .page-title')) && Boolean(document.querySelector('.course-table-head'))`,
|
||||
)),
|
||||
);
|
||||
await waitFor(win, `!document.body.innerText.includes('正在加载')`);
|
||||
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../docs/screenshots/plan-editor-list.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.inspector-tabs button:nth-child(2)').click()`,
|
||||
);
|
||||
record(
|
||||
'课表信息面板可编辑触发规则',
|
||||
await waitFor(
|
||||
win,
|
||||
`Boolean(document.querySelector('.plan-details')) && document.body.innerText.includes('启用星期') && document.body.innerText.includes('轮周设定') && document.body.innerText.includes('生效周次')`,
|
||||
),
|
||||
);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.inspector-tabs button:first-child').click()`,
|
||||
);
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
|
||||
const weekReady = await waitFor(
|
||||
win,
|
||||
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
|
||||
);
|
||||
record('底部视图切换可进入周课表', weekReady);
|
||||
const assignment = await win.webContents.executeJavaScript(`(() => {
|
||||
const cell = document.querySelector('.lesson-cell');
|
||||
if (!cell) return null;
|
||||
@@ -1416,6 +1441,7 @@ async function runPlanEditorChecks(win) {
|
||||
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`,
|
||||
),
|
||||
);
|
||||
await waitFor(win, `!document.querySelector('.archive-tabs button:nth-child(2)')?.disabled`);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.archive-tabs button:nth-child(2)').click()`,
|
||||
);
|
||||
@@ -1423,22 +1449,77 @@ async function runPlanEditorChecks(win) {
|
||||
'时间表页四种时间点与保存入口可见',
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动')`,
|
||||
`document.body.innerText.includes('课间休息') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动') && Boolean(document.querySelector('.layout-toolbar'))`,
|
||||
),
|
||||
);
|
||||
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
|
||||
const before = document.querySelectorAll('.time-row').length;
|
||||
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 180));
|
||||
const rows = document.querySelectorAll('.time-row');
|
||||
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value);
|
||||
const minutes = (value) => { const [h, m] = (value || '').split(':').map(Number); return h * 60 + m; };
|
||||
return { before, after: rows.length, diff: minutes(values[1]) - minutes(values[0]) };
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const token = localStorage.getItem('classhelper.token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
};
|
||||
const classes = await fetch('/api/classes', { headers }).then(r => r.json()).catch(() => null);
|
||||
const pickedName = document.querySelector('.class-picker input')?.value ?? '';
|
||||
const klass = (classes?.data ?? []).find(c => c.name === pickedName) ?? classes?.data?.[0];
|
||||
let created = null;
|
||||
if (klass) {
|
||||
// 探针课表可能占满全天;用接口建一张空时间表作为可逆验收数据,随后由 UI 刷新选中。
|
||||
created = await fetch('/api/time-layouts', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: 'UI 冒烟时间表', classId: klass.id, points: [] }),
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
}
|
||||
[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();
|
||||
await wait(200);
|
||||
if (document.querySelector('.el-message-box'))
|
||||
document.querySelector('.el-message-box__btns .el-button--primary')?.click();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await wait(100);
|
||||
if ([...document.querySelectorAll('.layout-explorer .plan-item')].some(b => b.textContent.includes('UI 冒烟时间表'))) break;
|
||||
}
|
||||
[...document.querySelectorAll('.layout-explorer .plan-item')].find(b => b.textContent.includes('UI 冒烟时间表'))?.click();
|
||||
await wait(250);
|
||||
const before = document.querySelectorAll('.time-point-row').length;
|
||||
const addButton = [...document.querySelectorAll('.layout-toolbar button')].find(b => b.textContent.trim() === '上课');
|
||||
addButton?.click();
|
||||
await wait(200);
|
||||
const rows = document.querySelectorAll('.time-point-row');
|
||||
const timeText = document.querySelector('.time-point-row.selected .point-time')?.textContent?.trim() ?? '';
|
||||
const match = timeText.match(/(\\d{2}:\\d{2})\\s*[–-]\\s*(\\d{2}:\\d{2})/);
|
||||
const minutes = (value) => { const [h, m] = value.split(':').map(Number); return h * 60 + m; };
|
||||
return {
|
||||
before,
|
||||
after: rows.length,
|
||||
diff: match ? minutes(match[2]) - minutes(match[1]) : -1,
|
||||
timeText,
|
||||
klass: klass?.name ?? '',
|
||||
created: created?.data?.id ?? null,
|
||||
addDisabled: addButton ? addButton.disabled : null,
|
||||
activeLayout: document.querySelector('.layout-explorer .plan-item.active strong')?.textContent?.trim() ?? '',
|
||||
};
|
||||
})()`);
|
||||
record(
|
||||
'时间表新建课时按快速时长生成起止时间',
|
||||
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
|
||||
'时间表新建上课点按默认 40 分钟生成起止时间',
|
||||
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 40,
|
||||
JSON.stringify(quickAdd),
|
||||
);
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="时间轴视图"]').click()`);
|
||||
record(
|
||||
'时间表时间轴视图与右侧时间点详情可用',
|
||||
await waitFor(
|
||||
win,
|
||||
`Boolean(document.querySelector('.time-block')) && Boolean(document.querySelector('.point-inspector')) && Boolean(document.querySelector('.timeline-ruler'))`,
|
||||
),
|
||||
);
|
||||
await sleep(400);
|
||||
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../docs/screenshots/plan-editor-timeline.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`,
|
||||
);
|
||||
@@ -1457,11 +1538,28 @@ async function runPlanEditorChecks(win) {
|
||||
);
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
|
||||
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`));
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
|
||||
await waitFor(win, `Boolean(document.querySelector('.course-table'))`);
|
||||
await sleep(300);
|
||||
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
if (quickAdd.created) {
|
||||
// 清理本次新建的临时时间表,避免开发库留下验收数据。
|
||||
await win.webContents.executeJavaScript(`(async () => {
|
||||
const token = localStorage.getItem('classhelper.token');
|
||||
try {
|
||||
await fetch('/api/time-layouts/' + ${JSON.stringify(quickAdd.created)}, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
});
|
||||
} catch {
|
||||
// 清理失败不阻断验收(隔离库会整体丢弃)。
|
||||
}
|
||||
})()`);
|
||||
}
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.management-menu [data-path="/dashboard"]').click()`,
|
||||
);
|
||||
|
||||