diff --git a/docs/app/island.md b/docs/app/island.md index 71ed947..67ac4f5 100644 --- a/docs/app/island.md +++ b/docs/app/island.md @@ -36,8 +36,13 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。 ## 编辑自定义方案 “设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。 -方案支持复制、删除、JSON 导入导出;组件支持日期、时钟、倒计时、课程、文本、分隔符、通知/作业/叫人及组合容器。 -分别编辑常态 rows 与无消息展开 expandedRows,拖动调整结构,设置内容、外观、尺寸与隐藏条件。 +页面上方排列当前组件,右上角选择配置方案;刷新图标重置当前方案,加号新建方案,文件夹菜单提供方案设置、复制、删除及 JSON 导入导出。 +下方组件库常驻显示文本、分割线、课程表、日期、时钟、天气简报、倒计时和组合容器,并保留班级通知、作业和叫人组件。 +从组件库拖入上方的行或容器以添加,拖动已有组件调整顺序或跨行移动;也可点击组件库添加到选中的容器或当前行。 +分别编辑常态 rows 与无消息展开 expandedRows,点选组件打开内容、外观、尺寸与隐藏条件设置;行可以新增、排序、设为主要行或删除。 +“进入编辑模式”切换为实际内容预览,支持预览缩放;退出后回到组件标签排列。轮播和滚动容器在编辑时展示全部子组件,便于选择和排列。 +倒计时可指定目标名称与日期,也可按课表倒数至上课、下课或放学。 +天气简报需填写地点名称及经纬度,使用 [Open-Meteo](https://open-meteo.com/en/docs) 的天气数据,每 15 分钟更新;断网时保留本次客户端运行中的缓存并标明“缓存”。天气提示根据天气概况生成,不提供官方气象预警。 方案保存到本机 config.json.islandLayout,不增加服务端同步。 编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。 保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。 @@ -56,6 +61,14 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。 ## 课程与课件 准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。 + +自定义常驻岛的「课程表」组件可选「当前时间点 / 下一节课 / 今日时间点」: +「今日时间点」显示当天全部时间点,只有当前项展开显示起止时间,其余折叠只显示课程名; +课间休息与「默认隐藏」的上课时间点折叠时完全隐藏,轮到它们时才展开;分割线显示一条竖线,行动不显示。 +课中课程表不再常驻显示倒计时,只在最后一分钟由既有的「下课提醒」页显示「距下课还剩」; +倒计时阈值沿用本机提醒设置(默认 60 秒)。没有时间点数据时退回按课表条目显示课程。 +经典 fixed 岛的课中提醒条保持原有倒计时行为。 + 课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。 客户端不会自动执行收到的文件,见[课件](courseware.md)。 diff --git a/docs/app/schedules.md b/docs/app/schedules.md index 66765c4..6ed9711 100644 --- a/docs/app/schedules.md +++ b/docs/app/schedules.md @@ -8,11 +8,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。 Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。 -编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的课表交互实现,作为管理端内的普通页面展示,沿用全站的导航、间距、控件及浅色/深色主题。顶部切换课表、时间表、科目和调课;默认以周网格展示课程,右侧提供科目简称面板,底部可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一页面,侧栏和顶部导航始终可用。 +编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的课表交互实现,作为管理端内的普通页面展示,沿用全站的导航、间距、控件及浅色/深色主题。顶部切换课表、时间表、科目和调课;课表页默认进入单表编辑:左侧按课表群分组的课表列表,中间逐节选择科目,右侧切换「科目 / 课表信息」。底部仍可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一页面,侧栏和顶部导航始终可用。 -点击每天列头的「新课表」选择时间表并创建日课表。点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在「详细信息」中维护。 +点击每天列头的「新课表」选择时间表并创建日课表。周视图点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在右侧「课表信息」中维护。列表视图提供批量排课、复制粘贴、拖动交换课程等操作。 -列表视图保留批量排课、复制粘贴、拖动交换课程等操作;时间表页支持拖动时间点排序、任意分钟输入,以及上课、课间、分割线、行动四种时间点。设置的课程/课间分钟数用于新建时间点的默认起止时间。编辑完成点击「保存」或按 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。 +时间表页采用 ClassIsland 式编辑器:左侧时间表列表(新建、复制、搜索),顶部按类型添加上课、课间休息、分割线、行动,中间可在列表视图与时间轴视图之间切换,右侧编辑选中时间点,左下设置默认上课/课间时长,右下缩放。时间轴视图可按分钟拖动时间点的开始/结束把柄,并按 1 分钟取整;列表视图可拖动排序。保存仍走页面顶部「保存」或 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。 + +上课时间点可以设置一个默认课程。日课表里**没有单独条目**的课时会回退到该默认课程;已有条目(包括显式空课)不会被覆盖。「覆盖现有课程」会把这门课写入所有引用该时间表的日课表。默认隐藏的上课时间点只在轮到时才在主界面显示;课间休息类型代表下课时间,折叠时完全隐藏,轮到它时才展开。 完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。 diff --git a/docs/screenshots/island-editor/component-editor-dark.png b/docs/screenshots/island-editor/component-editor-dark.png new file mode 100644 index 0000000..36594f0 Binary files /dev/null and b/docs/screenshots/island-editor/component-editor-dark.png differ diff --git a/docs/screenshots/island-editor/component-editor-light.png b/docs/screenshots/island-editor/component-editor-light.png new file mode 100644 index 0000000..37522a9 Binary files /dev/null and b/docs/screenshots/island-editor/component-editor-light.png differ diff --git a/docs/screenshots/plan-editor-list.png b/docs/screenshots/plan-editor-list.png new file mode 100644 index 0000000..27cc515 Binary files /dev/null and b/docs/screenshots/plan-editor-list.png differ diff --git a/docs/screenshots/plan-editor-live-api.png b/docs/screenshots/plan-editor-live-api.png index 1c2c6cc..2ed3b54 100644 Binary files a/docs/screenshots/plan-editor-live-api.png and b/docs/screenshots/plan-editor-live-api.png differ diff --git a/docs/screenshots/plan-editor-shell.png b/docs/screenshots/plan-editor-shell.png new file mode 100644 index 0000000..0d83a7a Binary files /dev/null and b/docs/screenshots/plan-editor-shell.png differ diff --git a/docs/screenshots/plan-editor-timeline.png b/docs/screenshots/plan-editor-timeline.png new file mode 100644 index 0000000..43d745b Binary files /dev/null and b/docs/screenshots/plan-editor-timeline.png differ diff --git a/packages/desktop-client/src/island/ComponentIcon.vue b/packages/desktop-client/src/island/ComponentIcon.vue index 03fa9f9..38b897d 100644 --- a/packages/desktop-client/src/island/ComponentIcon.vue +++ b/packages/desktop-client/src/island/ComponentIcon.vue @@ -3,10 +3,12 @@ import type { IslandComponentKind } from '@classhelper/shared'; defineProps<{ kind: IslandComponentKind }>(); const paths: Record = { clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2', + weather: + 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5', date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2', schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1', countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4', - text: 'M5 6V4h14v2M12 4v16m-4 0h8', + text: 'M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm1 5h10M7 12h7m-7 4h10', separator: 'M12 4v16M5 8v8m14-8v8', notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z', homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5', diff --git a/packages/desktop-client/src/island/ComponentNode.vue b/packages/desktop-client/src/island/ComponentNode.vue index d4c2c7f..daa062c 100644 --- a/packages/desktop-client/src/island/ComponentNode.vue +++ b/packages/desktop-client/src/island/ComponentNode.vue @@ -5,13 +5,16 @@ import { resolveClassStatus, type IslandComponentNode, type IslandState, + type IslandTimePointDto, type ScheduleDto, } from '@classhelper/shared'; +import WeatherBrief from './WeatherBrief.vue'; const props = defineProps<{ node: IslandComponentNode; state: IslandState; now: number; schedules: ScheduleDto[]; + timePoints: IslandTimePointDto[]; editing: boolean; selectedId: string; alertNodeId: string; @@ -38,20 +41,69 @@ function timeAt(time: string) { d.setHours(h!, m!, 0, 0); return d.getTime(); } -const elapsed = computed(() => - current.value - ? Math.max( - 0, - Math.min( - 1, - (props.now - timeAt(current.value.startTime)) / - Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)), - ), - ) - : 0, +/** + * 今天的时间点:优先用服务端下发的完整时间表(含课间/分割线/默认隐藏), + * 没有时回退到课表条目,保证旧服务端与离线缓存仍能显示课程。 + */ +const dayPoints = computed(() => { + const source = props.timePoints.length + ? props.timePoints + : today.value.map((s) => ({ + id: s.id, + timeType: 'LESSON', + name: '', + courseName: s.course?.name ?? null, + startTime: s.startTime, + endTime: s.endTime, + isHideDefault: false, + })); + return [...source].sort((a, b) => a.startTime.localeCompare(b.startTime)); +}); +const currentPoint = computed( + () => + dayPoints.value.find( + (point) => + point.timeType !== 'DIVIDER' && + point.timeType !== 'ACTION' && + props.now >= timeAt(point.startTime) && + props.now < timeAt(point.endTime), + ) ?? null, ); +const currentLesson = computed(() => (currentPoint.value?.timeType === 'LESSON' ? currentPoint.value : null)); +/** 课程表组件按 ClassIsland 主界面的折叠/展开规则挑选时间点。 */ +const schedulePoints = computed(() => { + const display = props.node.settings.display; + if (display === 'next') + return dayPoints.value + .filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now) + .slice(0, 1); + if (display === 'current') { + if (currentPoint.value) return [currentPoint.value]; + return dayPoints.value + .filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now) + .slice(0, 1); + } + const visible = dayPoints.value.filter( + (point) => + point.timeType === 'DIVIDER' || + point.id === currentPoint.value?.id || + (point.timeType === 'LESSON' && !point.isHideDefault), + ); + const count = Math.max(1, props.node.settings.count); + if (visible.length <= count) return visible; + const index = visible.findIndex((point) => point.id === currentPoint.value?.id); + const start = Math.max(0, Math.min(index - Math.floor(count / 2), visible.length - count)); + return visible.slice(start, start + count); +}); +const elapsed = computed(() => { + const point = currentLesson.value; + if (!point) return 0; + const start = timeAt(point.startTime); + const end = timeAt(point.endTime); + return Math.max(0, Math.min(1, (props.now - start) / Math.max(1, end - start))); +}); const remaining = computed(() => - current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0, + currentLesson.value ? Math.max(0, Math.ceil((timeAt(currentLesson.value.endTime) - props.now) / 1000)) : 0, ); const hidden = computed( () => @@ -62,11 +114,11 @@ const hidden = computed( (r) => (r === 'in-class' && props.state.inClass) || (r === 'no-message' && !props.state.active) || - (r === 'no-class' && !current.value), + (r === 'no-class' && !currentLesson.value), )), ); const visibleChildren = computed(() => - props.node.kind === 'slide' + props.node.kind === 'slide' && !props.editing ? props.node.children.filter( (_, i) => i === @@ -95,6 +147,18 @@ const content = computed(() => { .replaceAll('{course}', current.value?.course?.name || '暂无课程') : n.settings.text; if (n.kind === 'countdown') { + if (n.settings.target === 'date') { + if (!n.settings.targetDate) return '请设置目标日期'; + const [year, month, day] = n.settings.targetDate.split('-').map(Number); + const days = Math.round( + (Date.UTC(year!, month! - 1, day!) - Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000, + ); + return days > 0 + ? `距${n.settings.countdownTitle} ${days} 天` + : days === 0 + ? `${n.settings.countdownTitle}就在今天` + : `${n.settings.countdownTitle}已过 ${-days} 天`; + } const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value); const time = n.settings.target === 'school' @@ -111,13 +175,6 @@ const content = computed(() => { } return ''; }); -const lessons = computed(() => - props.node.settings.display === 'today' - ? today.value.slice(0, props.node.settings.count) - : [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter( - (s): s is ScheduleDto => Boolean(s), - ), -); const style = computed(() => { const n = props.node, a = n.appearance, @@ -177,6 +234,7 @@ const style = computed(() => { :state="state" :now="now" :schedules="schedules" + :time-points="timePoints" :editing="editing" :selected-id="selectedId" :alert-node-id="alertNodeId" @@ -188,26 +246,33 @@ const style = computed(() => { + diff --git a/packages/desktop-client/src/island/WeatherBrief.vue b/packages/desktop-client/src/island/WeatherBrief.vue new file mode 100644 index 0000000..cd61d99 --- /dev/null +++ b/packages/desktop-client/src/island/WeatherBrief.vue @@ -0,0 +1,91 @@ + + + diff --git a/packages/desktop-client/src/main/ipc.ts b/packages/desktop-client/src/main/ipc.ts index 47d879e..aab3c4a 100644 --- a/packages/desktop-client/src/main/ipc.ts +++ b/packages/desktop-client/src/main/ipc.ts @@ -6,6 +6,7 @@ import { listLocalCourseware, } from './courseware.js'; import { island } from './island.js'; +import { getIslandWeather } from './island-weather.js'; import { getUpdateProgress } from './auto-update.js'; import { app, BrowserWindow, ipcMain, shell } from 'electron'; import type { UpdateInfo } from '@classhelper/shared'; @@ -21,6 +22,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js'; export { IPC_CHANNELS }; export function registerIpcHandlers(): void { + ipcMain.handle('island:weather', (_event, coordinates: unknown) => getIslandWeather(coordinates)); ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id)); ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id)); ipcMain.handle('courseware:local-list', () => listLocalCourseware()); diff --git a/packages/desktop-client/src/main/island-weather.ts b/packages/desktop-client/src/main/island-weather.ts new file mode 100644 index 0000000..05fb086 --- /dev/null +++ b/packages/desktop-client/src/main/island-weather.ts @@ -0,0 +1,73 @@ +import type { IslandWeather } from '@classhelper/shared'; + +const cache = new Map(); +const pending = new Map>(); + +/** 固定天气服务与数值坐标,不接受渲染层传入 URL。失败退避并保留本次运行的缓存。 */ +export async function getIslandWeather( + input: unknown, + request: typeof fetch = fetch, +): Promise { + 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; +} diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 722bf1f..63359a7 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -1,4 +1,7 @@ -import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared'; +import type { + IslandTimePointDto as ContractIslandTimePointDto, + ScheduleDto as ContractScheduleDto, +} from '@classhelper/shared'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { BrowserWindow, ipcMain, screen } from 'electron'; @@ -138,6 +141,7 @@ export interface IslandPushContext { export interface IslandClassStatePayload { schedules?: ContractScheduleDto[]; + timePoints?: ContractIslandTimePointDto[]; inClass: boolean; currentPeriodEnd?: string | null; week?: number; @@ -364,14 +368,15 @@ class IslandController { if (this.customTimer) clearTimeout(this.customTimer); this.customTimer = null; // 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。 - if ( - this.customSize.width !== Math.ceil(value.width) || - this.customSize.height !== Math.ceil(value.height) - ) + const width = Math.ceil(value.width); + const height = Math.ceil(value.height); + const changed = this.customSize.width !== width || this.customSize.height !== height; + if (changed) this.residentMotionUntil = Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0); - this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) }; - this.syncResidentShape(true); + this.customSize = { width, height }; + // 时钟每分钟触发的重复测量尺寸没变,不要清空触摸形状(那 750ms 会漏掉点击)。 + if (changed || !this.residentShaped) this.syncResidentShape(true); return; } if (this.customTimer) clearTimeout(this.customTimer); @@ -618,6 +623,7 @@ class IslandController { // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 this.emitAppearance(); this.win?.webContents.send('island:schedules', this.schedules); + this.win?.webContents.send('island:time-points', this.timePoints); this.emit(); if (this.residentEnabled) { this.residentWasEnabled = true; @@ -634,6 +640,10 @@ class IslandController { getSchedules(): ContractScheduleDto[] { return [...this.schedules]; } + private timePoints: ContractIslandTimePointDto[] = []; + getTimePoints(): ContractIslandTimePointDto[] { + return [...this.timePoints]; + } getState(): IslandState { return { @@ -860,6 +870,11 @@ class IslandController { if (this.ready && this.win && !this.win.isDestroyed()) this.win.webContents.send('island:schedules', this.schedules); } + if (Array.isArray(payload.timePoints)) { + this.timePoints = payload.timePoints.slice(0, 100); + if (this.ready && this.win && !this.win.isDestroyed()) + this.win.webContents.send('island:time-points', this.timePoints); + } const wasInClass = this.state.inClass; this.state.inClass = payload.inClass; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; @@ -2320,6 +2335,9 @@ export function registerIslandIpc(): void { ipcMain.handle('island:get-schedules', (event) => event.sender === island.getWindow()?.webContents ? island.getSchedules() : [], ); + ipcMain.handle('island:get-time-points', (event) => + event.sender === island.getWindow()?.webContents ? island.getTimePoints() : [], + ); ipcMain.handle('island:get-layout', () => getConfig().islandLayout); ipcMain.handle('island:save-layout', (_event, value: unknown) => { const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout; diff --git a/packages/desktop-client/src/main/smoke-layout.ts b/packages/desktop-client/src/main/smoke-layout.ts index 02d56fc..a0c6bd7 100644 --- a/packages/desktop-client/src/main/smoke-layout.ts +++ b/packages/desktop-client/src/main/smoke-layout.ts @@ -3,7 +3,13 @@ import { BrowserWindow } from 'electron'; import path from 'node:path'; import { island } from './island.js'; import { getConfig, saveConfig } from './config.js'; -import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared'; +import { + defaultIslandLayouts, + islandNode, + normalizeIslandLayouts, + ISLAND_SHADOW_PAD, +} from '@classhelper/shared'; +import { getIslandWeather } from './island-weather.js'; import { verifyResidentSmoke } from './smoke-resident.js'; export async function verifyLayoutSmoke( check: (name: string, action: () => Promise | boolean) => Promise, @@ -38,12 +44,30 @@ export async function verifyLayoutSmoke( editingBounds = win.getBounds(); await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)'); await pause(); + if (process.env.ISLAND_SHOTS_DIR) { + fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); + const bounds = editor.getContentBounds(); + editor.setContentSize(1600, 1000); + for (const theme of ['light', 'dark']) { + await editor.webContents.executeJavaScript( + `document.documentElement.classList.toggle('dark', ${theme === 'dark'})`, + ); + await pause(); + fs.writeFileSync( + path.join(process.env.ISLAND_SHOTS_DIR, `component-editor-${theme}.png`), + (await editor.webContents.capturePage()).toPNG(), + ); + } + await editor.webContents.executeJavaScript(`document.documentElement.classList.remove('dark')`); + editor.setContentSize(bounds.width, bounds.height); + await pause(); + } return ( (await editor.webContents.executeJavaScript( 'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))', )) && editor.webContents.executeJavaScript( - `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`, + `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return document.querySelectorAll('.component-library > .component-options:first-of-type .library-item').length===11 && !document.querySelector('.layout-editor, iframe'); })()`, ) ); }); @@ -66,12 +90,16 @@ export async function verifyLayoutSmoke( return true; }); await check('组件可以包裹进容器且设置独立保存', async () => { + await editor.webContents.executeJavaScript( + `document.querySelector('.node-handle[data-kind=clock]')?.click()`, + ); + await pause(); await editor.webContents.executeJavaScript( `document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`, ); await pause(); await editor.webContents.executeJavaScript( - `(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`, + `(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组容器'); if(!option)throw Error('容器菜单未出现');option.click(); })()`, ); await pause(); return ( @@ -108,6 +136,10 @@ export async function verifyLayoutSmoke( return initial && appearance && advanced; }); await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => { + await editor.webContents.executeJavaScript( + `document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`, + ); + await pause(); return editor.webContents.executeJavaScript(`(() => { const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect(); const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect(); @@ -118,11 +150,190 @@ export async function verifyLayoutSmoke( return editor.webContents.executeJavaScript(`(() => { const root=document.documentElement, wasDark=root.classList.contains('dark'); root.classList.add('dark'); - const card=document.querySelector('.scheme-card'), style=getComputedStyle(card); - const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4; + const page=document.querySelector('.island-editor-page'), style=getComputedStyle(page); + const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelector('.component-library .library-title') && document.querySelector('.scheme-controls'); root.classList.toggle('dark',wasDark);return Boolean(ok); })()`); }); + await check('从常驻组件库拖入容器,阻止将父组件放进自己的后代', async () => { + await editor.webContents.executeJavaScript( + `document.querySelector('.surface-actions button:last-child')?.click()`, + ); + await pause(); + await editor.webContents.executeJavaScript(`(() => { + const source=document.querySelector('[data-library-kind=text]'), target=document.querySelector('.editor-component.container'); + if(!source||!target)throw Error('缺少组件库或容器'); + const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2; + source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:10,clientY:10})); + source.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x,clientY:y})); + source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x,clientY:y})); + })()`); + await pause(); + const group = getConfig() + .islandLayout.schemes[0]!.rows.flatMap((r) => r.nodes) + .find((n) => n.kind === 'group'); + if (!group?.children.some((n) => n.kind === 'text')) throw Error('组件库内容没有进入容器'); + const before = JSON.stringify(getConfig().islandLayout); + await editor.webContents.executeJavaScript(`(() => { + const source=document.querySelector('.editor-component.container > .node-handle'), target=document.querySelector('.editor-component.container .container-children .node-handle'); + const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2; + source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:22,clientX:10,clientY:10})); + source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:22,clientX:x,clientY:y})); + })()`); + await pause(); + return before === JSON.stringify(getConfig().islandLayout); + }); + await check('常态与无通知展开内容分别保存,日期倒计时可配置并预览', async () => { + const before = JSON.stringify(getConfig().islandLayout.schemes[0]!.rows); + await editor.webContents.executeJavaScript( + `document.querySelectorAll('.surface-tabs button')[1].click();document.querySelector('[data-library-kind=countdown]').click()`, + ); + await pause(); + await editor.webContents.executeJavaScript(`(() => { + const set=(input,value)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));}; + const field=[...document.querySelectorAll('.component-settings .el-form-item')].find(e=>e.textContent.includes('目标名称')); + set(field.querySelector('input'),'运动会'); + document.querySelector('.component-settings .el-date-editor input').focus(); + })()`); + await pause(); + await editor.webContents.executeJavaScript(`(() => { + const picker=document.querySelector('.component-settings .el-date-editor input'); + picker.value='2099-10-25';picker.dispatchEvent(new Event('input',{bubbles:true})); + })()`); + await pause(); + await editor.webContents.executeJavaScript(`(() => { + const picker=document.querySelector('.component-settings .el-date-editor input'); + picker.dispatchEvent(new Event('change',{bubbles:true}));picker.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true}));picker.blur(); + })()`); + await pause(); + const added = getConfig() + .islandLayout.schemes[0]!.expandedRows.flatMap((r) => r.nodes) + .find((n) => n.kind === 'countdown'); + if (added?.settings.targetDate !== '2099-10-25' || added.settings.countdownTitle !== '运动会') + throw Error(JSON.stringify(added?.settings)); + await editor.webContents.executeJavaScript( + `document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`, + ); + await pause(); + const preview = await editor.webContents.executeJavaScript( + `document.querySelector('.island-preview .countdown')?.textContent.includes('距运动会')`, + ); + await editor.webContents.executeJavaScript( + `document.querySelectorAll('.surface-tabs button')[0].click()`, + ); + await pause(); + return preview && JSON.stringify(getConfig().islandLayout.schemes[0]!.rows) === before; + }); + await check('天气坐标校验、并发合并与缓存使用 mock,不访问外网', async () => { + let calls = 0; + const request: typeof fetch = async (input) => { + calls++; + const url = new URL(String(input)); + if (url.origin !== 'https://api.open-meteo.com' || url.searchParams.get('latitude') !== '12.34') + throw Error('请求边界错误'); + return new Response( + JSON.stringify({ current: { temperature_2m: 23.4, weather_code: 2, wind_speed_10m: 8 } }), + ); + }; + const results = await Promise.all([ + getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request), + getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request), + ]); + await getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request); + let invalid = false; + try { + await getIslandWeather({ latitude: 100, longitude: 0 }, request); + } catch { + invalid = true; + } + const weather = islandNode('weather', 'weather-test'), + countdown = islandNode('countdown', 'countdown-test'); + weather.settings.latitude = NaN; + countdown.settings.targetDate = '2026-02-31'; + const config = defaultIslandLayouts(); + config.schemes[0]!.rows[0]!.nodes = [weather, countdown]; + const normalized = normalizeIslandLayouts(config).schemes[0]!.rows[0]!.nodes; + return ( + calls === 1 && + results.every((r) => r.temperature === 23.4 && !r.stale) && + invalid && + normalized[0]!.settings.latitude === null && + normalized[1]!.settings.targetDate === '' + ); + }); + await check('右上角新建、重置和删除方案均可保存并恢复原方案', async () => { + await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="新建方案"]').click()`); + await pause(); + await editor.webContents.executeJavaScript( + `(() => { const input=document.querySelector('.el-message-box__input input'); input.value='组件方案验收';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('.el-message-box__btns .el-button--primary').click(); })()`, + ); + await pause(); + if (getConfig().islandLayout.schemes.length !== 2) throw Error('新建方案没有保存'); + await editor.webContents.executeJavaScript( + `document.querySelector('[aria-label="重置当前方案"]').click()`, + ); + await pause(); + await editor.webContents.executeJavaScript( + `document.querySelector('.el-message-box__btns .el-button--primary').click()`, + ); + await pause(); + await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`); + await pause(); + await editor.webContents.executeJavaScript( + `[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`, + ); + await pause(); + await editor.webContents.executeJavaScript( + `document.querySelector('.el-message-box__btns .el-button--primary').click()`, + ); + await pause(); + return ( + getConfig().islandLayout.schemes.length === 1 && + getConfig().islandLayout.activeSchemeId === 'default' && + getConfig().islandLayout.schemes[0]!.rows.length === 2 + ); + }); + await check('方案 JSON 导入导出保留容器结构并重新生成标识', async () => { + await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`); + await pause(); + const exported = await editor.webContents.executeJavaScript(`(async () => { + let blob; const originalURL=URL.createObjectURL, originalClick=HTMLAnchorElement.prototype.click; + try { + URL.createObjectURL=value=>{blob=value;return 'blob:editor-smoke';};HTMLAnchorElement.prototype.click=()=>{}; + [...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='导出方案').click(); + } finally { URL.createObjectURL=originalURL;HTMLAnchorElement.prototype.click=originalClick; } + const text=await blob.text(), parsed=JSON.parse(text); + const input=document.querySelector('.island-editor-page input[type=file]'), transfer=new DataTransfer(); + transfer.items.add(new File([text],'组件方案.json',{type:'application/json'}));input.files=transfer.files; + input.dispatchEvent(new Event('change',{bubbles:true}));return parsed; + })()`); + await pause(); + const layouts = getConfig().islandLayout; + const imported = layouts.schemes.find((s) => s.id === layouts.activeSchemeId)!; + const shape = (nodes: ReturnType[]): string => + JSON.stringify( + nodes.map((n) => ({ kind: n.kind, children: shape(n.children), settings: n.settings })), + ); + const preserved = + exported.format === 'classhelper-island-layout' && + imported.id !== exported.scheme.id && + imported.rows.every( + (row, index) => + row.id !== exported.scheme.rows[index].id && + shape(row.nodes) === shape(exported.scheme.rows[index].nodes), + ); + await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`); + await pause(); + await editor.webContents.executeJavaScript( + `[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`, + ); + await pause(); + await editor.webContents.executeJavaScript( + `document.querySelector('.el-message-box__btns .el-button--primary').click()`, + ); + await pause(); + return preserved && getConfig().islandLayout.schemes.length === 1; + }); if (process.env.ISLAND_SHOTS_DIR) { await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`); fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts index bcc5d5d..566227d 100644 --- a/packages/desktop-client/src/main/smoke-resident.ts +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -5,6 +5,7 @@ import { islandNode, normalizeIslandLayouts, ISLAND_SHADOW_PAD, + type IslandTimePointDto, type ScheduleDto, } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; @@ -607,7 +608,9 @@ export async function verifyResidentSmoke( await pause(1100); const half = await win.webContents.executeJavaScript(`(() => { const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}'); - return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50); + const text=root?.textContent??''; + const progress=Number(root?.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow')); + return Boolean(root&&text.includes('数学')&&progress===50&&(${layout === 'custom' ? "text.includes('09:00–09:45')&&!text.includes('距下课')" : "text.includes('距下课')"})); })()`); if (!half) throw new Error( @@ -679,6 +682,87 @@ export async function verifyResidentSmoke( await pause(800); } }); + await check('自定义岛课程表按时间点折叠:课间折叠隐藏、当前项展开显示时间段', async () => { + const scheduleLayout = JSON.parse(JSON.stringify(fixture)); + const node = islandNode('schedule', 'time-point-schedule'); + node.settings.display = 'today'; + node.settings.count = 10; + scheduleLayout.schemes[0].rows[0].nodes = [node]; + saveConfig({ islandLayout: { ...scheduleLayout, layout: 'custom' } }); + island.syncCustomLayout(); + const timePoints: IslandTimePointDto[] = [ + { + id: 'p-math', + timeType: 'LESSON', + name: '第 1 节', + courseName: '数学', + startTime: '09:00', + endTime: '09:45', + isHideDefault: false, + }, + { + id: 'p-break', + timeType: 'BREAK', + name: '课间休息', + courseName: null, + startTime: '09:45', + endTime: '09:55', + isHideDefault: false, + }, + { + id: 'p-chinese', + timeType: 'LESSON', + name: '第 2 节', + courseName: '语文', + startTime: '09:55', + endTime: '10:40', + isHideDefault: false, + }, + ]; + const setTime = async (hour: number, minute: number) => { + const at = dateAt(hour, minute).getTime(); + await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`); + island.setLessonReminderTestTime(at); + }; + try { + await win.webContents.executeJavaScript( + `window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`, + ); + island.setClassState({ inClass: false, schedules: lessonFixtures, timePoints }); + await setTime(9, 20); + await pause(900); + const duringClass = await win.webContents.executeJavaScript(`(() => { + const text=document.querySelector('.resident-compact')?.textContent??''; + return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:00–09:45'), otherPeriod: text.includes('09:55–10:40') }; + })()`); + await setTime(9, 50); + await pause(900); + const duringBreak = await win.webContents.executeJavaScript(`(() => { + const text=document.querySelector('.resident-compact')?.textContent??''; + return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:45–09:55') }; + })()`); + if (!( + duringClass.math && + duringClass.chinese && + !duringClass.rest && + duringClass.period && + !duringClass.otherPeriod + )) + throw new Error('课中时间点折叠错误:' + JSON.stringify(duringClass)); + if (!(duringBreak.math && duringBreak.chinese && duringBreak.rest && duringBreak.period)) + throw new Error('课间展开错误:' + JSON.stringify(duringBreak)); + return true; + } finally { + await win.webContents.executeJavaScript( + `Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`, + ); + island.setLessonReminderTestTime(null); + island.setClassState({ inClass: false, schedules: [], timePoints: [] }); + saveConfig({ islandLayout: fixture }); + island.syncCustomLayout(); + await pause(800); + } + }); await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => { const appearance = island.getAppearance(); const dense = JSON.parse(JSON.stringify(fixture)); @@ -743,15 +827,26 @@ export async function verifyResidentSmoke( island.handleAction({ action: 'expand' }); await pause(1000); island.handleAction({ action: 'collapse' }); - await pause(1000); + // 收起后渲染进程要重新量宽并走 750ms 的触摸形状延时;尺寸稳定前不要断言。 + let settled = false; + const deadline = Date.now() + 3500; + while (Date.now() < deadline) { + await pause(100); + const current = island.getHitRect(); + if ( + current && + shape.length === 1 && + shape[0]!.width <= current.width + 18 && + shape[0]!.height <= current.height + 18 + ) { + settled = true; + break; + } + } const hit = island.getHitRect(); - return Boolean( - hit && - shape.length === 1 && - shape[0]!.width <= hit.width + 18 && - shape[0]!.height <= hit.height + 18 && - JSON.stringify(box) === JSON.stringify(win.getBounds()), - ); + const ok = Boolean(settled && hit && JSON.stringify(box) === JSON.stringify(win.getBounds())); + if (!ok) throw new Error(JSON.stringify({ hit, shape, bounds: win.getBounds(), box, settled })); + return true; } finally { win.setShape = original; island.setTouchMode(false); diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index f807e4d..fadf7cd 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -23,6 +23,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared'; * 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。 */ const bridge: DesktopBridge = { + getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates), downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id), revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id), listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'), diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index 5a323cd..883fff7 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -1,5 +1,7 @@ import type { IslandLayoutSettings as ContractIslandLayoutSettings, + IslandTimePointDto as ContractIslandTimePointDto, + IslandWeather, ScheduleDto as ContractScheduleDto, } from '@classhelper/shared'; import { contextBridge, ipcRenderer } from 'electron'; @@ -10,6 +12,7 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared'; * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 */ export interface IslandBridge { + getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise; getLayout(): Promise; saveLayout(value: ContractIslandLayoutSettings): Promise; onLayout(handler: (value: ContractIslandLayoutSettings) => void): void; @@ -24,6 +27,8 @@ export interface IslandBridge { onViewport(handler: (value: { width: number; height: number }) => void): () => void; getSchedules(): Promise; onSchedules(handler: (value: ContractScheduleDto[]) => void): void; + getTimePoints(): Promise; + onTimePoints(handler: (value: ContractIslandTimePointDto[]) => void): void; onState(handler: (state: IslandState) => void): void; sendAction( @@ -57,6 +62,7 @@ export interface IslandBridge { } const bridge: IslandBridge = { + getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates), getLayout: () => ipcRenderer.invoke('island:get-layout'), saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value), onLayout: (handler) => { @@ -84,6 +90,10 @@ const bridge: IslandBridge = { onSchedules: (handler) => { ipcRenderer.on('island:schedules', (_event, value) => handler(value)); }, + getTimePoints: () => ipcRenderer.invoke('island:get-time-points'), + onTimePoints: (handler) => { + ipcRenderer.on('island:time-points', (_event, value) => handler(value)); + }, onState: (handler) => { ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state)); diff --git a/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue b/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue index df08302..59bf108 100644 --- a/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue +++ b/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue @@ -37,14 +37,63 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const; - - - - - - - - + +