灵动岛 - 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。 - 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。 - 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。 - preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。 课表档案 - 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。 - 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。 - 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。 - 「课间」统一改称「课间休息」。 验证 - pnpm typecheck、pnpm lint 通过。 - verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。 - ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
111 lines
5.8 KiB
TypeScript
111 lines
5.8 KiB
TypeScript
import type {
|
||
ReleaseProgressDto as ContractReleaseProgressDto,
|
||
CoursewareTransfer,
|
||
} from '@classhelper/shared';
|
||
import { contextBridge, ipcRenderer } from 'electron';
|
||
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||
import type {
|
||
DesktopAppInfo,
|
||
DesktopBridge,
|
||
DesktopStoredConfig,
|
||
IslandClassStatePayload,
|
||
IslandPushContext,
|
||
} from '../types/desktop.js';
|
||
import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||
|
||
/**
|
||
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
||
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
|
||
*
|
||
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
|
||
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
|
||
* 之前就抛 `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'),
|
||
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
|
||
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
|
||
onCoursewareTransfer: (handler) => {
|
||
const listener = (_event: unknown, value: CoursewareTransfer | null) => handler(value);
|
||
ipcRenderer.on('courseware:progress', listener);
|
||
return () => ipcRenderer.removeListener('courseware:progress', listener);
|
||
},
|
||
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
|
||
onIslandEditorOpen: (handler) => {
|
||
const listener = () => handler();
|
||
ipcRenderer.on('island:open-editor', listener);
|
||
return () => ipcRenderer.removeListener('island:open-editor', listener);
|
||
},
|
||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
||
smokeCredentials:
|
||
process.env.ELECTRON_SMOKE_TEST === '1'
|
||
? {
|
||
username: process.env.ELECTRON_SMOKE_USER ?? '',
|
||
password: process.env.ELECTRON_SMOKE_PASSWORD ?? '',
|
||
classCode: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
|
||
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
|
||
}
|
||
: undefined,
|
||
getUpdateProgress: () => ipcRenderer.invoke('client:get-update-progress'),
|
||
onUpdateProgress: (handler: (progress: ContractReleaseProgressDto) => void) => {
|
||
const listener = (_event: unknown, value: ContractReleaseProgressDto) => handler(value);
|
||
ipcRenderer.on('client:update-progress', listener);
|
||
return () => ipcRenderer.removeListener('client:update-progress', listener);
|
||
},
|
||
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
|
||
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
|
||
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
|
||
clearConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.clearConfig),
|
||
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
|
||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
|
||
|
||
/* 更新检查(主进程直连 GitHub,见 main/update.ts) */
|
||
checkForUpdates: (force = false): Promise<UpdateInfo> =>
|
||
ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force),
|
||
ignoreUpdateVersion: (version: string): Promise<void> =>
|
||
ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version),
|
||
onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => {
|
||
ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info));
|
||
},
|
||
|
||
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
|
||
ipcRenderer.send('island:push', payload);
|
||
},
|
||
islandRemoveNotification: (id: string): void => {
|
||
ipcRenderer.send('island:remove-notification', id);
|
||
},
|
||
islandSetClassState: (payload: IslandClassStatePayload): void => {
|
||
ipcRenderer.send('island:class-state', payload);
|
||
},
|
||
islandGetState: () => ipcRenderer.invoke('island:get-state'),
|
||
islandSetAppearance: (appearance): void => {
|
||
ipcRenderer.send('island:set-appearance', appearance);
|
||
},
|
||
islandGetAppearance: () => ipcRenderer.invoke('island:get-appearance'),
|
||
/** 灵动岛点了"标为已读":主进程转交渲染进程同步通知中心 */
|
||
onIslandMarkRead: (handler: (id: string) => void): void => {
|
||
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
|
||
},
|
||
/** 灵动岛点了"标为已读"(多条通知的整批):一次同步这一批 */
|
||
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
|
||
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
|
||
},
|
||
|
||
/* 开机自启 / 日志 / 服务端联动 */
|
||
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
|
||
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
|
||
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
|
||
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
|
||
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
|
||
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
|
||
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
|
||
};
|
||
|
||
contextBridge.exposeInMainWorld('desktop', bridge);
|