feat: 订单双视图、工作台日历组件、医生排班管理与挂号语音提醒
- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典 - 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel - 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗 - 日志与通知:新增排班变更日志页、排班变更通知视图 - 挂号提醒:新增挂号语音播报资源与 register-notify 工具 - 桌面端:新增 apps/desktop 壳及 desktop 工具方法 - 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
This commit is contained in:
30
apps/desktop/src/main/auto-launch.ts
Normal file
30
apps/desktop/src/main/auto-launch.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { app } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* 开机自启:基于系统登录项实现(Windows 写 HKCU\...\Run 注册表、mac 写「系统设置 → 登录项」),
|
||||
* 无需管理员权限;系统登录项本身就是持久化存储,无需额外落盘记录状态。
|
||||
*/
|
||||
|
||||
/** 读取当前开机自启状态(直接以系统登录项为准,避免自维护状态与系统不同步) */
|
||||
export function getAutoLaunchEnabled(): boolean {
|
||||
// 开发模式下登录项指向 electron 开发二进制,无意义,统一视为关闭
|
||||
if (!app.isPackaged) return false;
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置开机自启
|
||||
* --hidden 参数让开机自启时静默进托盘(主进程入口据此不弹主窗口),避免开机打扰
|
||||
*/
|
||||
export function setAutoLaunchEnabled(enable: boolean): boolean {
|
||||
if (!app.isPackaged) {
|
||||
log.warn('开发模式不支持设置开机自启(登录项会错误指向开发二进制)');
|
||||
return false;
|
||||
}
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enable,
|
||||
args: enable ? ['--hidden'] : [],
|
||||
});
|
||||
return getAutoLaunchEnabled();
|
||||
}
|
||||
24
apps/desktop/src/main/badge.ts
Normal file
24
apps/desktop/src/main/badge.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { app, nativeImage } from 'electron';
|
||||
|
||||
import { getMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 未读角标:按平台分支实现
|
||||
* - mac:Dock 图标原生支持文字角标,直接 setBadge
|
||||
* - Windows:任务栏无原生数字角标,用「覆盖图标」实现(图由 preload 侧 canvas 绘制成 dataURL 传来,
|
||||
* 因为主进程没有 DOM/canvas,在渲染侧画图是社区标准做法)
|
||||
*/
|
||||
export function applyBadge(count: number, image?: string): void {
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock?.setBadge(count > 0 ? String(Math.min(count, 99)) : '');
|
||||
return;
|
||||
}
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
if (count > 0 && image) {
|
||||
win.setOverlayIcon(nativeImage.createFromDataURL(image), `${count} 条未读消息`);
|
||||
} else {
|
||||
// 数量归零或没有角标图时清除覆盖图标
|
||||
win.setOverlayIcon(null, '');
|
||||
}
|
||||
}
|
||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* 桌面端全局配置
|
||||
* 打包模式下渲染层加载的是本地 dist,所有「按环境调整」的地址都收敛在这里,
|
||||
* 并支持环境变量覆盖,便于联调测试环境时不改代码切换上游。
|
||||
*/
|
||||
|
||||
/** 应用标识:必须与 electron-builder 的 appId 一致,否则 Windows 系统通知不显示 */
|
||||
export const APP_ID = 'com.xiaokang.xkadmin';
|
||||
|
||||
/** 自定义协议,注册为特权协议后 app://local/ 等价一个「本地 https 站点」(安全上下文) */
|
||||
export const APP_SCHEME = 'app';
|
||||
export const APP_HOST = 'local';
|
||||
export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`;
|
||||
export const APP_INDEX_URL = `${APP_ORIGIN}/`;
|
||||
|
||||
/** 开发模式加载 web-antd 的 vite dev server(端口对齐 apps/web-antd/.env.development 的 VITE_PORT=5666) */
|
||||
export const DEV_SERVER_URL = process.env.XK_DESKTOP_DEV_URL ?? 'http://localhost:5666';
|
||||
|
||||
/**
|
||||
* /api/admin/* 的转发上游(仅打包后的 app:// 协议使用;dev 模式加载 vite dev server,
|
||||
* 由 vite 代理转发到本地后端 localhost:18001,不经过这里)
|
||||
* 地址来源:apps/web-antd/vite.config.ts 代理注释中的生产目标 https://api.xiaokang88.com/api/admin/
|
||||
*/
|
||||
export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.xiaokang88.com';
|
||||
|
||||
/**
|
||||
* /im-api/* 的转发上游(转发规则对齐开发代理:/im-api/* → 上游 /api/*;同样仅打包后生效,
|
||||
* dev 模式由 vite 代理转发到本地 IM localhost:12080)
|
||||
* 地址来源:vite.config.ts 代理注释的生产目标 https://api.ws.g.xiaokang88.com/api/,与生产 WS 同主机
|
||||
*/
|
||||
export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com';
|
||||
|
||||
/**
|
||||
* 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致)
|
||||
* 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页
|
||||
* TODO(待确认):更新包静态托管地址
|
||||
*/
|
||||
export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/';
|
||||
|
||||
/** 全局快捷键:唤起/隐藏主窗口(CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd) */
|
||||
export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X';
|
||||
|
||||
/**
|
||||
* 解析运行时资源路径(图标等)
|
||||
* 打包后 extraResources 位于 process.resourcesPath,开发时直接读工程 resources 目录
|
||||
*/
|
||||
export function getResourcePath(name: string): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, name)
|
||||
: join(app.getAppPath(), 'resources', name);
|
||||
}
|
||||
81
apps/desktop/src/main/index.ts
Normal file
81
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { app, Menu } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { APP_ID } from './config';
|
||||
import { registerIpcHandlers } from './ipc';
|
||||
import { registerAppProtocol, registerSchemePrivileges } from './protocol';
|
||||
import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut';
|
||||
import { createTray } from './tray';
|
||||
import { initUpdater } from './updater';
|
||||
import { createMainWindow, getMainWindow, markQuitting, showMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 主进程入口:负责应用生命周期编排
|
||||
* 启动顺序:单实例锁 → 特权协议注册(必须在 ready 前)→ ready 后依次初始化
|
||||
* 协议服务 / IPC / 主窗口 / 托盘 / 全局快捷键 / 自动更新
|
||||
*/
|
||||
|
||||
// 显式设置应用名:userData / 日志目录会随之落到 %APPDATA%\萧康云医,
|
||||
// 不设置的话 Electron 会取 package.json 的 name(@vben/desktop),生成嵌套目录不利于运维排查
|
||||
app.setName('萧康云医');
|
||||
|
||||
// Windows 通知归属与任务栏分组标识,必须与 electron-builder 的 appId 一致,
|
||||
// 否则系统通知不显示或显示为「electron.app.Electron」
|
||||
app.setAppUserModelId(APP_ID);
|
||||
|
||||
// 单实例锁:避免多开导致托盘、自动更新、本地设置互相干扰
|
||||
if (app.requestSingleInstanceLock()) {
|
||||
// 特权协议必须在 app ready 之前注册,否则 app:// 无法成为安全上下文
|
||||
registerSchemePrivileges();
|
||||
|
||||
// 重复启动(用户再次双击图标)时唤起已有窗口
|
||||
app.on('second-instance', () => showMainWindow());
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// electron-log 初始化:主进程日志落 userData/logs,便于排查线上问题
|
||||
log.initialize();
|
||||
|
||||
// mac 必须保留应用菜单,否则 Cmd+C/V/Q 等系统快捷键失效;Windows 隐藏菜单栏
|
||||
if (process.platform === 'darwin') {
|
||||
Menu.setApplicationMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ role: 'appMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' },
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
|
||||
registerAppProtocol();
|
||||
registerIpcHandlers();
|
||||
|
||||
// 开机自启带 --hidden 参数:静默启动进托盘,不弹主窗口打扰用户
|
||||
const startHidden = process.argv.includes('--hidden');
|
||||
createMainWindow({ startHidden });
|
||||
|
||||
createTray();
|
||||
registerGlobalShortcut();
|
||||
initUpdater();
|
||||
});
|
||||
|
||||
// mac 点击 Dock 图标恢复窗口(关窗只是隐藏,符合平台惯例)
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) showMainWindow();
|
||||
});
|
||||
|
||||
// 常驻托盘模式:窗口全部关闭也不退出(close 已被拦截为隐藏,这里是兜底),退出走托盘菜单/Cmd+Q
|
||||
app.on('window-all-closed', () => {
|
||||
// 故意留空:不调用 app.quit()
|
||||
});
|
||||
|
||||
// 任何退出路径(托盘退出 / Cmd+Q / 自动更新安装)都先放行窗口 close 拦截
|
||||
app.on('before-quit', () => markQuitting());
|
||||
|
||||
// 退出前注销全局快捷键,避免残留占用
|
||||
app.on('will-quit', () => unregisterGlobalShortcut());
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
117
apps/desktop/src/main/ipc.ts
Normal file
117
apps/desktop/src/main/ipc.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { app, ipcMain, Notification } from 'electron';
|
||||
|
||||
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
|
||||
import { applyBadge } from './badge';
|
||||
import { printHtml, type PrintHtmlOptions } from './printer';
|
||||
import { getSettings, updateSettings, type DesktopSettings } from './settings';
|
||||
import { refreshTrayMenu } from './tray';
|
||||
import { checkForUpdates } from './updater';
|
||||
import { getMainWindow, isTrustedPageUrl, showMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* IPC 汇总:preload 暴露的 desktopAPI 全部经由这里进入主进程
|
||||
* 所有 handler 先校验调用方来源(app:// 或开发服务器),
|
||||
* 防止页面内嵌第三方 iframe 等场景越权调用桌面能力
|
||||
*/
|
||||
|
||||
/** 校验 IPC 调用来源,不可信来源直接抛错拒绝 */
|
||||
function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void {
|
||||
const url = event.senderFrame?.url ?? '';
|
||||
if (!isTrustedPageUrl(url)) {
|
||||
throw new Error(`拒绝来自不可信来源的 IPC 调用: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统通知参数(route 用于点击通知后让渲染端跳转对应页面) */
|
||||
interface NotifyPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// 应用版本号(渲染端「关于/设置」展示用)
|
||||
ipcMain.handle('desktop:get-version', (event) => {
|
||||
assertTrustedSender(event);
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// 系统通知:点击后唤起主窗口,并把目标路由回传渲染端执行 router.push
|
||||
ipcMain.handle('desktop:notify', (event, payload: NotifyPayload) => {
|
||||
assertTrustedSender(event);
|
||||
if (!Notification.isSupported()) return false;
|
||||
const notification = new Notification({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
});
|
||||
notification.on('click', () => {
|
||||
showMainWindow();
|
||||
if (payload.route) {
|
||||
getMainWindow()?.webContents.send('desktop:navigate', payload.route);
|
||||
}
|
||||
});
|
||||
notification.show();
|
||||
return true;
|
||||
});
|
||||
|
||||
// 未读角标(mac Dock 数字 / Windows 任务栏覆盖图标,图由 preload canvas 绘制)
|
||||
ipcMain.handle('desktop:set-badge', (event, payload: { count: number; image?: string }) => {
|
||||
assertTrustedSender(event);
|
||||
applyBadge(payload.count, payload.image);
|
||||
});
|
||||
|
||||
// 任务栏闪烁提醒:仅窗口未聚焦时触发(聚焦状态下闪烁是干扰),聚焦后由窗口 focus 事件自动清除
|
||||
ipcMain.handle('desktop:flash-frame', (event) => {
|
||||
assertTrustedSender(event);
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isFocused()) win.flashFrame(true);
|
||||
});
|
||||
|
||||
// 开机自启读取/设置(设置后同步刷新托盘菜单勾选态)
|
||||
ipcMain.handle('desktop:get-auto-launch', (event) => {
|
||||
assertTrustedSender(event);
|
||||
return getAutoLaunchEnabled();
|
||||
});
|
||||
ipcMain.handle('desktop:set-auto-launch', (event, enable: boolean) => {
|
||||
assertTrustedSender(event);
|
||||
const result = setAutoLaunchEnabled(enable);
|
||||
refreshTrayMenu();
|
||||
return result;
|
||||
});
|
||||
|
||||
// 打印机列表(渲染端打印设置下拉用)
|
||||
ipcMain.handle('desktop:get-printers', async (event) => {
|
||||
assertTrustedSender(event);
|
||||
const win = getMainWindow();
|
||||
if (!win) return [];
|
||||
return await win.webContents.getPrintersAsync();
|
||||
});
|
||||
|
||||
// 静默打印:渲染端拼好完整 HTML 传入,主进程隐藏窗口出纸
|
||||
ipcMain.handle('desktop:print-html', (event, options: PrintHtmlOptions) => {
|
||||
assertTrustedSender(event);
|
||||
return printHtml(options);
|
||||
});
|
||||
|
||||
// 桌面端设置(打印机选择、静默开关等)读取/增量更新
|
||||
ipcMain.handle('desktop:get-settings', (event) => {
|
||||
assertTrustedSender(event);
|
||||
return getSettings();
|
||||
});
|
||||
ipcMain.handle('desktop:set-settings', (event, patch: Partial<DesktopSettings>) => {
|
||||
assertTrustedSender(event);
|
||||
return updateSettings(patch);
|
||||
});
|
||||
|
||||
// 手动检查更新(渲染端设置页/关于入口)
|
||||
ipcMain.handle('desktop:check-update', (event) => {
|
||||
assertTrustedSender(event);
|
||||
checkForUpdates(true);
|
||||
});
|
||||
|
||||
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
|
||||
ipcMain.handle('desktop:focus-window', (event) => {
|
||||
assertTrustedSender(event);
|
||||
showMainWindow();
|
||||
});
|
||||
}
|
||||
75
apps/desktop/src/main/printer.ts
Normal file
75
apps/desktop/src/main/printer.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* 静默打印:隐藏窗口加载 HTML → webContents.print 直接出纸
|
||||
* 为什么用隐藏窗口:渲染层的处方打印本来就是拼好的完整 HTML(含内联样式),
|
||||
* 加载到独立隐藏窗口打印可以完全复用现有模板,且不阻塞/不干扰主窗口。
|
||||
* 为什么落临时文件而不是 data: URL:处方 HTML 可能较大,data URL 有长度上限风险,
|
||||
* 临时文件 loadFile 对任意大小都稳定,打完即删。
|
||||
*/
|
||||
|
||||
export interface PrintHtmlOptions {
|
||||
/** 完整的打印 HTML(含样式),由渲染层拼好传入 */
|
||||
html: string;
|
||||
/** 目标打印机名称;不传时使用系统默认打印机 */
|
||||
deviceName?: string;
|
||||
/** 是否静默打印(true 不弹系统打印对话框) */
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export interface PrintResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行打印,返回是否成功与失败原因(渲染层据此提示用户)
|
||||
*/
|
||||
export async function printHtml(options: PrintHtmlOptions): Promise<PrintResult> {
|
||||
const { html, deviceName, silent = true } = options;
|
||||
// 临时文件带时间戳防并发打印互相覆盖
|
||||
const tempFile = join(app.getPath('temp'), `xk-print-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
|
||||
await writeFile(tempFile, html, 'utf8');
|
||||
|
||||
return new Promise<PrintResult>((resolve) => {
|
||||
let settled = false;
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { sandbox: true },
|
||||
});
|
||||
|
||||
/** 统一收尾:销毁隐藏窗口、清理临时文件、只 resolve 一次 */
|
||||
const finish = (ok: boolean, message?: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
unlink(tempFile).catch(() => {});
|
||||
if (!ok) log.warn(`静默打印失败: ${message}`);
|
||||
resolve({ ok, message });
|
||||
};
|
||||
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
win.webContents.print(
|
||||
{
|
||||
silent,
|
||||
deviceName: deviceName || undefined,
|
||||
printBackground: true,
|
||||
},
|
||||
(success, failureReason) => finish(success, success ? undefined : failureReason),
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.once('did-fail-load', (_event, _code, desc) => finish(false, desc));
|
||||
|
||||
// 兜底超时:防止打印机脱机等场景下隐藏窗口泄漏
|
||||
setTimeout(() => finish(false, '打印超时(请检查打印机状态)'), 60_000);
|
||||
|
||||
win.loadFile(tempFile).catch((error: unknown) => {
|
||||
finish(false, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
}
|
||||
108
apps/desktop/src/main/protocol.ts
Normal file
108
apps/desktop/src/main/protocol.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol } from 'electron';
|
||||
|
||||
import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config';
|
||||
|
||||
/**
|
||||
* app:// 自定义协议:桌面端的「本地 nginx」
|
||||
* 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback
|
||||
* (等价 nginx 的 try_files $uri /index.html),因此无需把前端路由改成 hash 模式
|
||||
* 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 ——
|
||||
* 渲染层看到的是同源请求,天然规避 CORS,后端与 nginx 都不需要任何改动
|
||||
*/
|
||||
|
||||
/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */
|
||||
type ElectronFetchInit = RequestInit & {
|
||||
/** 转发出去的请求不再进入自定义协议处理器,防止死循环 */
|
||||
bypassCustomProtocolHandlers?: boolean;
|
||||
/** 携带流式 body 时 fetch 规范要求显式声明半双工 */
|
||||
duplex?: 'half';
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册特权协议,必须在 app ready 之前调用
|
||||
* standard/secure 让 app:// 成为「安全上下文」,navigator.clipboard、getUserMedia、
|
||||
* IndexedDB、crypto.subtle 等浏览器能力才可用;stream 支持流式响应(音频等大文件)
|
||||
*/
|
||||
export function registerSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
corsEnabled: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 web dist 根目录
|
||||
* 打包后位于 resources/web(electron-builder extraResources 拷入,不进 asar);
|
||||
* 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist
|
||||
*/
|
||||
export function getWebRoot(): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, 'web')
|
||||
: join(__dirname, '../../../web-antd/dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass)
|
||||
* 保留原请求的方法 / 头 / 流式请求体,Bearer token 请求头原样透传
|
||||
*/
|
||||
function proxyRequest(target: string, request: Request): Promise<Response> {
|
||||
const init: ElectronFetchInit = {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
bypassCustomProtocolHandlers: true,
|
||||
};
|
||||
if (request.body) init.duplex = 'half';
|
||||
return net.fetch(target, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态文件解析:带路径穿越防护 + SPA fallback
|
||||
* 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由
|
||||
*/
|
||||
function resolveStaticFile(webRoot: string, pathname: string): string {
|
||||
const indexHtml = join(webRoot, 'index.html');
|
||||
try {
|
||||
const filePath = normalize(join(webRoot, decodeURIComponent(pathname)));
|
||||
// 防路径穿越:解析结果必须仍在 webRoot 目录内
|
||||
if (!filePath.startsWith(normalize(webRoot))) return indexHtml;
|
||||
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath;
|
||||
} catch {
|
||||
// decode 失败等异常场景直接回 index.html,不让协议处理器抛错
|
||||
}
|
||||
return indexHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 app:// 协议处理器,在 app ready 之后调用
|
||||
*/
|
||||
export function registerAppProtocol(): void {
|
||||
const webRoot = getWebRoot();
|
||||
protocol.handle(APP_SCHEME, (request) => {
|
||||
const url = new URL(request.url);
|
||||
// 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin,路径直接透传
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request);
|
||||
}
|
||||
// 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/*
|
||||
if (url.pathname.startsWith('/im-api/')) {
|
||||
const stripped = url.pathname.slice('/im-api'.length);
|
||||
return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request);
|
||||
}
|
||||
// 其余全部按静态资源处理(net.fetch file:// 会按扩展名自动带上正确的 Content-Type)
|
||||
const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString();
|
||||
return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit);
|
||||
});
|
||||
}
|
||||
67
apps/desktop/src/main/settings.ts
Normal file
67
apps/desktop/src/main/settings.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* 桌面端本地设置持久化(userData/desktop-settings.json)
|
||||
* 为什么不用 electron-store:目前只需存打印机等少量键值,直接读写 JSON 文件即可,
|
||||
* 避免引入额外依赖(electron-store 新版为纯 ESM,与 CJS 主进程产物不兼容,还会拖入 ajv 等重依赖)。
|
||||
*/
|
||||
|
||||
/** 桌面端设置项定义 */
|
||||
export interface DesktopSettings {
|
||||
/** 静默打印使用的打印机名称,空串表示未选择 */
|
||||
printerName: string;
|
||||
/** 是否启用静默打印(true 时打印处方不弹系统打印对话框) */
|
||||
silentPrint: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: DesktopSettings = {
|
||||
printerName: '',
|
||||
silentPrint: false,
|
||||
};
|
||||
|
||||
/** 内存缓存,避免每次 IPC 调用都读磁盘 */
|
||||
let cache: DesktopSettings | null = null;
|
||||
|
||||
/** 设置文件落在 userData 目录,随应用卸载数据目录一起清理 */
|
||||
function getSettingsFilePath(): string {
|
||||
return join(app.getPath('userData'), 'desktop-settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取设置:文件不存在或解析失败时回退默认值,保证任何情况下不影响应用启动
|
||||
*/
|
||||
export function getSettings(): DesktopSettings {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const filePath = getSettingsFilePath();
|
||||
if (existsSync(filePath)) {
|
||||
// JSON.parse 返回 any,显式收窄成 Partial 再与默认值合并,保证字段完整
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<DesktopSettings>;
|
||||
const merged: DesktopSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
cache = merged;
|
||||
return merged;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('读取桌面端设置失败,回退默认值', error);
|
||||
}
|
||||
cache = { ...DEFAULT_SETTINGS };
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量更新设置并写盘:写盘失败只告警(内存态仍生效),下次成功写入时自动恢复
|
||||
*/
|
||||
export function updateSettings(patch: Partial<DesktopSettings>): DesktopSettings {
|
||||
const next = { ...getSettings(), ...patch };
|
||||
cache = next;
|
||||
try {
|
||||
writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
log.warn('写入桌面端设置失败', error);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
21
apps/desktop/src/main/shortcut.ts
Normal file
21
apps/desktop/src/main/shortcut.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { MAIN_WINDOW_SHORTCUT } from './config';
|
||||
import { toggleMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 全局快捷键:任何界面下按 CommandOrControl+Alt+X 快速唤起/隐藏主窗口
|
||||
* 注册失败(快捷键被其他应用占用)只记录警告,不影响应用启动
|
||||
*/
|
||||
export function registerGlobalShortcut(): void {
|
||||
const ok = globalShortcut.register(MAIN_WINDOW_SHORTCUT, () => toggleMainWindow());
|
||||
if (!ok) {
|
||||
log.warn(`全局快捷键 ${MAIN_WINDOW_SHORTCUT} 注册失败(可能被其他应用占用)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用退出前注销全部快捷键,避免残留占用 */
|
||||
export function unregisterGlobalShortcut(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
78
apps/desktop/src/main/tray.ts
Normal file
78
apps/desktop/src/main/tray.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { app, Menu, nativeImage, Tray } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
|
||||
import { getResourcePath } from './config';
|
||||
import { checkForUpdates } from './updater';
|
||||
import { markQuitting, showMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 系统托盘:应用常驻入口
|
||||
* - Windows 单击托盘图标恢复主窗口;mac 单击弹出菜单(跟随平台惯例)
|
||||
* - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出
|
||||
* 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失(Electron 经典坑)
|
||||
*/
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */
|
||||
function buildTrayMenu(): Menu {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主窗口',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '开机自启',
|
||||
type: 'checkbox',
|
||||
checked: getAutoLaunchEnabled(),
|
||||
// 开发模式登录项无意义,置灰避免误操作
|
||||
enabled: app.isPackaged,
|
||||
click: (menuItem) => {
|
||||
setAutoLaunchEnabled(menuItem.checked);
|
||||
refreshTrayMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '检查更新',
|
||||
click: () => checkForUpdates(true),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
// 先标记退出,放行主窗口 close 拦截,再走正常退出流程
|
||||
markQuitting();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** 刷新托盘菜单(开机自启状态从渲染端 IPC 修改后也要同步勾选态) */
|
||||
export function refreshTrayMenu(): void {
|
||||
tray?.setContextMenu(buildTrayMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建系统托盘
|
||||
* 图标用打包的应用图标缩放(mac 菜单栏建议 18px、Windows 托盘 16px 的倍数由系统处理)
|
||||
* TODO(待确认):正式图标到位后,mac 建议换单色 Template 图以适配深浅菜单栏
|
||||
*/
|
||||
export function createTray(): void {
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(getResourcePath('icon.png'));
|
||||
const trayIcon = process.platform === 'darwin' ? icon.resize({ width: 18, height: 18 }) : icon.resize({ width: 16, height: 16 });
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip('萧康云医');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
// Windows 单击直接唤起主窗口;mac 上设置了 contextMenu 后单击默认弹菜单,不重复绑定
|
||||
if (process.platform === 'win32') {
|
||||
tray.on('click', () => showMainWindow());
|
||||
}
|
||||
} catch (error) {
|
||||
// 托盘创建失败(图标缺失等)不阻塞应用启动,仅损失常驻入口
|
||||
log.error('创建系统托盘失败', error);
|
||||
}
|
||||
}
|
||||
139
apps/desktop/src/main/updater.ts
Normal file
139
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { DOWNLOAD_PAGE_URL } from './config';
|
||||
import { getMainWindow, markQuitting, showMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 自动更新(electron-updater + generic 静态托管)
|
||||
* - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发
|
||||
* - autoDownload:发现新版本自动后台下载,进度事件转发渲染端展示
|
||||
* - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」
|
||||
* - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」
|
||||
*/
|
||||
|
||||
/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */
|
||||
let isManualCheck = false;
|
||||
/** 检查中防重入 */
|
||||
let isChecking = false;
|
||||
|
||||
/** 把更新事件转发给渲染端(web 侧在 basic.vue 里做轻量提示) */
|
||||
function sendToRenderer(type: string, data?: unknown): void {
|
||||
getMainWindow()?.webContents.send('desktop:update-event', { type, data });
|
||||
}
|
||||
|
||||
/** 判断是否 mac 未签名导致的更新失败(这类错误重试无意义,直接引导手动下载) */
|
||||
function isMacSignatureError(message: string): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
/code signature|not signed|codesign|could not get code signature/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml,检查必然失败)
|
||||
*/
|
||||
export function initUpdater(): void {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
autoUpdater.logger = log;
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info(`发现新版本: ${info.version}`);
|
||||
sendToRenderer('available', { version: info.version });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
sendToRenderer('not-available');
|
||||
if (isManualCheck) {
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '检查更新',
|
||||
message: `当前已是最新版本(v${app.getVersion()})`,
|
||||
});
|
||||
}
|
||||
isManualCheck = false;
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
sendToRenderer('progress', { percent: Math.round(progress.percent) });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', async (info) => {
|
||||
sendToRenderer('downloaded', { version: info.version });
|
||||
// 唤起窗口再弹对话框,避免用户看不到安装确认
|
||||
showMainWindow();
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
title: '发现新版本',
|
||||
message: `新版本 v${info.version} 已下载完成`,
|
||||
detail: '立即重启安装,或在退出应用时自动安装。',
|
||||
buttons: ['立即重启安装', '退出时自动安装'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response === 0) {
|
||||
// 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住
|
||||
markQuitting();
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', async (error) => {
|
||||
const message = error?.message ?? String(error);
|
||||
log.error('自动更新失败', message);
|
||||
sendToRenderer('error', { message });
|
||||
// mac 未签名:自动更新走不通,引导用户手动下载安装
|
||||
if (isMacSignatureError(message)) {
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '检查更新',
|
||||
message: '当前安装包不支持自动更新',
|
||||
detail: '请前往下载页获取最新版本手动安装。',
|
||||
buttons: ['打开下载页', '取消'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||
} else if (isManualCheck) {
|
||||
dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: '检查更新',
|
||||
message: '检查更新失败,请稍后重试',
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
isManualCheck = false;
|
||||
isChecking = false;
|
||||
});
|
||||
|
||||
// 启动延时自动检查一次 + 每 4 小时轮询(内部工具常开着,轮询保证及时收到发版)
|
||||
setTimeout(() => checkForUpdates(false), 10_000);
|
||||
setInterval(() => checkForUpdates(false), 4 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发一次更新检查
|
||||
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈,自动轮询保持安静)
|
||||
*/
|
||||
export async function checkForUpdates(manual: boolean): Promise<void> {
|
||||
if (!app.isPackaged) {
|
||||
if (manual) {
|
||||
dialog.showMessageBox({ type: 'info', title: '检查更新', message: '开发模式不支持检查更新' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isChecking) return;
|
||||
isChecking = true;
|
||||
isManualCheck = manual;
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch {
|
||||
// 错误已在 error 事件里统一处理
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
}
|
||||
212
apps/desktop/src/main/window.ts
Normal file
212
apps/desktop/src/main/window.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, Notification, session, shell, systemPreferences } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { APP_INDEX_URL, APP_ORIGIN, DEV_SERVER_URL, getResourcePath } from './config';
|
||||
|
||||
/**
|
||||
* 主窗口管理
|
||||
* - 加载目标:开发模式加载 web-antd 的 vite dev server(未起服务时自动重试);打包模式加载 app://local/
|
||||
* - 安全基线:contextIsolation + sandbox 开启,webSecurity 不关闭;window.open 一律交给系统浏览器
|
||||
* - 关闭行为:双平台都是「关窗隐藏、应用常驻」(Windows 走托盘、mac 走 Dock),真正退出走托盘菜单/Cmd+Q
|
||||
* - 权限控制:只放行业务需要的媒体(IM 音视频)/ 通知 / 剪贴板 / 全屏,其余默认拒绝
|
||||
*/
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
/** 标记「真正退出」:常驻模式下 close 只是隐藏,只有 quit 流程才放行关闭 */
|
||||
let isQuitting = false;
|
||||
/** Windows 首次关窗提示「已最小化到托盘」,只提示一次避免打扰 */
|
||||
let hasShownHideTip = false;
|
||||
/** 加载失败重试定时器,避免叠加多个重试 */
|
||||
let retryTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** 主窗口要加载的页面地址:打包后走自定义协议,开发走 dev server */
|
||||
const PAGE_URL = app.isPackaged ? APP_INDEX_URL : DEV_SERVER_URL;
|
||||
|
||||
export function markQuitting(): void {
|
||||
isQuitting = true;
|
||||
}
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
/** 判断 URL 是否属于信任的页面来源(本地协议或开发服务器),IPC 与权限校验共用 */
|
||||
export function isTrustedPageUrl(url: string): boolean {
|
||||
return url.startsWith(APP_ORIGIN) || url.startsWith(DEV_SERVER_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话级权限控制:白名单内且来源可信才放行
|
||||
* media 供 IM 音视频通话;notifications 供 HTML5 Notification(业务主要走主进程通知,这里兜底)
|
||||
*/
|
||||
function setupPermissionHandlers(): void {
|
||||
const allowedPermissions = new Set([
|
||||
'media',
|
||||
'notifications',
|
||||
'clipboard-read',
|
||||
'clipboard-sanitized-write',
|
||||
'fullscreen',
|
||||
]);
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const trusted = isTrustedPageUrl(webContents.getURL());
|
||||
if (!trusted || !allowedPermissions.has(permission)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
// mac 首次使用摄像头/麦克风必须先过系统 TCC 授权弹窗,否则页面侧拿不到设备
|
||||
if (permission === 'media' && process.platform === 'darwin') {
|
||||
Promise.all([
|
||||
systemPreferences.askForMediaAccess('microphone'),
|
||||
systemPreferences.askForMediaAccess('camera'),
|
||||
])
|
||||
.then(() => callback(true))
|
||||
// TCC 弹窗异常时仍放行,让页面侧走自己的错误提示
|
||||
.catch(() => callback(true));
|
||||
return;
|
||||
}
|
||||
callback(true);
|
||||
});
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => {
|
||||
return allowedPermissions.has(permission) && isTrustedPageUrl(requestingOrigin);
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载失败时的内置错误页(数据 URL 形式,不依赖任何本地文件) */
|
||||
function buildErrorPageUrl(reason: string): string {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>加载失败</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; align-items: center; justify-content: center; height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #333; }
|
||||
.box { text-align: center; }
|
||||
.title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||
.desc { font-size: 13px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="title">页面加载失败,正在自动重试…</div>
|
||||
<div class="desc">请检查网络连接(${reason})</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
* @param options.startHidden 开机自启(--hidden)场景下静默启动,不弹窗打扰用户
|
||||
*/
|
||||
export function createMainWindow(options: { startHidden: boolean }): BrowserWindow {
|
||||
setupPermissionHandlers();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1100,
|
||||
minHeight: 700,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: '萧康云医',
|
||||
// Windows/Linux 窗口与任务栏图标;mac 使用打包进 app 的 icns,无需在此指定
|
||||
icon: process.platform === 'darwin' ? undefined : getResourcePath('icon.png'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 首次渲染完成再显示,避免白屏闪烁;静默启动场景保持隐藏(用户从托盘/快捷键唤起)
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (!options.startHidden) mainWindow?.show();
|
||||
});
|
||||
|
||||
// window.open / target=_blank:外链一律交给系统默认浏览器,不在应用内开新窗(安全 + 体验)
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// 页面内导航限制在信任来源内,跳外部地址时转交系统浏览器
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (isTrustedPageUrl(url)) return;
|
||||
event.preventDefault();
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 加载失败(dev server 未启动 / 断网等)时展示内置错误页并定时重试
|
||||
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc, _url, isMainFrame) => {
|
||||
if (!isMainFrame) return;
|
||||
// ERR_ABORTED(-3)是导航被主动取消(如重复 loadURL),不算失败
|
||||
if (errorCode === -3) return;
|
||||
log.warn(`主窗口加载失败: ${errorCode} ${errorDesc}`);
|
||||
mainWindow?.loadURL(buildErrorPageUrl(errorDesc || String(errorCode)));
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
// 开发模式快速重试等 vite 起服务;打包模式放慢节奏等网络恢复
|
||||
retryTimer = setTimeout(() => mainWindow?.loadURL(PAGE_URL), app.isPackaged ? 5000 : 1500);
|
||||
});
|
||||
|
||||
// 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame)
|
||||
mainWindow.on('focus', () => mainWindow?.flashFrame(false));
|
||||
|
||||
// F12 切换 DevTools:内部管理系统保留排障入口
|
||||
mainWindow.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === 'F12') {
|
||||
mainWindow?.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭行为:双平台都拦截为隐藏(Windows 常驻托盘 / mac 常驻 Dock),退出流程放行
|
||||
mainWindow.on('close', (event) => {
|
||||
if (isQuitting) return;
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
// Windows 首次隐藏时提示托盘位置,避免用户以为应用被关闭
|
||||
if (process.platform === 'win32' && !hasShownHideTip && Notification.isSupported()) {
|
||||
hasShownHideTip = true;
|
||||
new Notification({
|
||||
title: '萧康云医仍在运行',
|
||||
body: '应用已最小化到系统托盘,点击托盘图标可重新打开窗口',
|
||||
}).show();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.loadURL(PAGE_URL);
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
/** 唤起主窗口:还原最小化、显示并聚焦(通知点击 / 托盘 / 第二实例 / 快捷键共用) */
|
||||
export function showMainWindow(): void {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
/** 切换主窗口显隐(全局快捷键使用) */
|
||||
export function toggleMainWindow(): void {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isVisible() && mainWindow.isFocused()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
showMainWindow();
|
||||
}
|
||||
}
|
||||
102
apps/desktop/src/preload/index.ts
Normal file
102
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* 预加载脚本:通过 contextBridge 向渲染层暴露受控的 desktopAPI
|
||||
* - 渲染层(web-antd)通过 window.desktopAPI 使用桌面能力;浏览器环境没有该对象,
|
||||
* 前端封装层(apps/web-antd/src/util/desktop.ts)会自动 no-op 降级
|
||||
* - sandbox 模式下本文件必须是 CJS 产物(electron-vite 已按包配置输出 CJS),
|
||||
* 且只能使用 Electron 提供的受限 API(contextBridge / ipcRenderer 等)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
|
||||
* 为什么在 preload 画:主进程没有 DOM/canvas;preload 运行在渲染进程内有 DOM,
|
||||
* 由主进程 setOverlayIcon 消费,这是 Windows 数字角标的社区标准做法
|
||||
*/
|
||||
function drawBadgeImage(count: number): string | undefined {
|
||||
try {
|
||||
const size = 32;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return undefined;
|
||||
// 红色圆底
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f5222d';
|
||||
ctx.fill();
|
||||
// 白色数字,超过 99 显示 99+
|
||||
const text = count > 99 ? '99+' : String(count);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${text.length > 2 ? 13 : 18}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, size / 2, size / 2 + 1);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
// canvas 不可用时退化为无图(主进程会清除角标),不影响业务
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露给渲染层的桌面能力集合,字段语义与 web 侧 desktop.ts 封装一一对应 */
|
||||
const desktopAPI = {
|
||||
/** 渲染层判断是否运行在桌面端的唯一标记 */
|
||||
isElectron: true as const,
|
||||
/** 当前平台:win32 / darwin / linux */
|
||||
platform: process.platform,
|
||||
/** 获取桌面端版本号(关于/设置展示用) */
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('desktop:get-version'),
|
||||
/** 发送系统通知;route 为点击通知后要跳转的前端路由 */
|
||||
notify: (options: { title: string; body: string; route?: string }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:notify', options),
|
||||
/** 设置未读角标数量(0 表示清除);Windows 角标图在此侧 canvas 生成 */
|
||||
setBadge: (count: number): Promise<void> =>
|
||||
ipcRenderer.invoke('desktop:set-badge', {
|
||||
count,
|
||||
image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined,
|
||||
}),
|
||||
/** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */
|
||||
flashFrame: (): Promise<void> => ipcRenderer.invoke('desktop:flash-frame'),
|
||||
/** 读取开机自启状态 */
|
||||
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke('desktop:get-auto-launch'),
|
||||
/** 设置开机自启,返回设置后的实际状态 */
|
||||
setAutoLaunch: (enable: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:set-auto-launch', enable),
|
||||
/** 获取系统打印机列表(打印设置下拉用) */
|
||||
getPrinters: (): Promise<Array<{ name: string; displayName: string; isDefault: boolean }>> =>
|
||||
ipcRenderer.invoke('desktop:get-printers'),
|
||||
/** 静默打印:传入拼好的完整 HTML,主进程隐藏窗口出纸 */
|
||||
printHTML: (options: {
|
||||
html: string;
|
||||
deviceName?: string;
|
||||
silent?: boolean;
|
||||
}): Promise<{ ok: boolean; message?: string }> =>
|
||||
ipcRenderer.invoke('desktop:print-html', options),
|
||||
/** 读取桌面端设置(打印机名称、静默打印开关等) */
|
||||
getSettings: (): Promise<{ printerName: string; silentPrint: boolean }> =>
|
||||
ipcRenderer.invoke('desktop:get-settings'),
|
||||
/** 增量更新桌面端设置,返回更新后的完整设置 */
|
||||
setSettings: (
|
||||
patch: Partial<{ printerName: string; silentPrint: boolean }>,
|
||||
): Promise<{ printerName: string; silentPrint: boolean }> =>
|
||||
ipcRenderer.invoke('desktop:set-settings', patch),
|
||||
/** 手动检查更新(结果由主进程弹窗反馈) */
|
||||
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
|
||||
/** 唤起并聚焦主窗口 */
|
||||
focusWindow: (): Promise<void> => ipcRenderer.invoke('desktop:focus-window'),
|
||||
/** 监听「通知点击跳转」事件:主进程通知被点击后回传目标路由,渲染层执行 router.push */
|
||||
onNavigate: (callback: (route: string) => void): void => {
|
||||
ipcRenderer.on('desktop:navigate', (_event, route: string) => callback(route));
|
||||
},
|
||||
/** 监听更新事件(available / progress / downloaded / not-available / error),渲染层做轻量提示 */
|
||||
onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => {
|
||||
ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload));
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
|
||||
|
||||
/** 供 web 侧类型声明引用(apps/web-antd/src/types/desktop.d.ts 与此保持同步) */
|
||||
export type DesktopAPI = typeof desktopAPI;
|
||||
Reference in New Issue
Block a user