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