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:
李琦
2026-08-14 17:46:50 +08:00
parent f15bf10553
commit beba5e8feb
105 changed files with 16261 additions and 1253 deletions

View 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();
}

View File

@@ -0,0 +1,24 @@
import { app, nativeImage } from 'electron';
import { getMainWindow } from './window';
/**
* 未读角标:按平台分支实现
* - macDock 图标原生支持文字角标,直接 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, '');
}
}

View 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);
}

View 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();
}

View 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();
});
}

View 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));
});
});
}

View 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/webelectron-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);
});
}

View 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;
}

View 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();
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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 提供的受限 APIcontextBridge / ipcRenderer 等)
*/
/**
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
* 为什么在 preload 画:主进程没有 DOM/canvaspreload 运行在渲染进程内有 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;