feat: 辅料功能、康正源拉取价格接口

This commit is contained in:
李琦
2026-09-30 17:02:54 +08:00
parent bfe50a8d67
commit 87c480f391
51 changed files with 4086 additions and 380 deletions

View File

@@ -0,0 +1,65 @@
import { dialog, session } from 'electron';
import log from 'electron-log/main';
import { getMainWindow } from './window';
/**
* 清除缓存
* 托盘入口弹窗二选一:
* - 仅资源缓存:清 HTTP/磁盘缓存,保留 Cookie / localStorage(登录态还在)
* - 清除全部:连带存储数据一起清,登录态丢失,需重新登录
* 清完后 reload 主窗口,让页面立刻吃到干净会话
*/
export type ClearCacheMode = 'resource' | 'all';
/**
* 弹出选择对话框并执行清除
* 取消时不做任何事
*/
export async function promptAndClearCache(): Promise<void> {
const win = getMainWindow();
const { response } = await dialog.showMessageBox(win ?? undefined, {
type: 'question',
title: '清除缓存',
message: '请选择清除范围',
detail:
'「仅资源缓存」保留登录态,适合页面异常时刷新资源;\n「清除全部」会删除本地登录信息,需重新登录。',
buttons: ['仅资源缓存', '清除全部', '取消'],
defaultId: 0,
cancelId: 2,
});
if (response === 2) return;
const mode: ClearCacheMode = response === 1 ? 'all' : 'resource';
await clearCache(mode);
}
/**
* 按模式清除会话缓存,并 reload 主窗口
* @param mode resource=只清 HTTP 缓存;all=缓存+本地存储
*/
export async function clearCache(mode: ClearCacheMode): Promise<void> {
const sess = session.defaultSession;
try {
await sess.clearCache();
if (mode === 'all') {
// 清 cookies / localStorage / indexedDB 等,业务登录态会失效
await sess.clearStorageData();
}
log.info(`清除缓存完成:mode=${mode}`);
getMainWindow()?.webContents.reload();
await dialog.showMessageBox(getMainWindow() ?? undefined, {
type: 'info',
title: '清除缓存',
message: mode === 'all' ? '已清除全部缓存,请重新登录' : '已清除资源缓存',
});
} catch (error) {
log.error('清除缓存失败', error);
await dialog.showMessageBox(getMainWindow() ?? undefined, {
type: 'warning',
title: '清除缓存',
message: '清除缓存失败,请稍后重试',
detail: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -34,12 +34,19 @@ export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.
export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com';
/**
* 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致)
* 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页
* TODO(待确认):更新包静态托管地址
* 更新包兜底页(无 OpenAPI 安装包时的人工下载地址,一般不再走这里)
*/
export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/';
/**
* 桌面版本 OpenAPI:拉取最新发布元数据(version / download_url / force_update)
* 主进程用 net.fetch 直连 API_UPSTREAM(不走 app:// 反代,主进程无 CORS 问题)
* 可用 XK_DESKTOP_OPEN_API_VERSION_URL 覆盖整条 URL 便于联调
*/
export const OPEN_API_DESKTOP_VERSION_URL =
process.env.XK_DESKTOP_OPEN_API_VERSION_URL ??
`${API_UPSTREAM}/open-api/desktop/version/latest`;
/** 全局快捷键:唤起/隐藏主窗口(CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd) */
export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X';

View File

@@ -1,8 +1,9 @@
import { app, Menu } from 'electron';
import { app } from 'electron';
import log from 'electron-log/main';
import { APP_ID } from './config';
import { registerIpcHandlers } from './ipc';
import { createAppMenu } from './menu';
import { registerAppProtocol, registerSchemePrivileges } from './protocol';
import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut';
import { createTray } from './tray';
@@ -35,19 +36,8 @@ if (app.requestSingleInstanceLock()) {
// 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);
}
// 应用菜单:Win/Linux 顶栏业务菜单;mac 保留系统角色并挂刷新/更新/关于
createAppMenu();
registerAppProtocol();
registerIpcHandlers();

View File

@@ -2,6 +2,7 @@ import { app, ipcMain, Notification } from 'electron';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { applyBadge } from './badge';
import { showAboutDialog, reloadMainWindow } from './menu';
import { printHtml, type PrintHtmlOptions } from './printer';
import { getSettings, updateSettings, type DesktopSettings } from './settings';
import { refreshTrayMenu } from './tray';
@@ -109,6 +110,18 @@ export function registerIpcHandlers(): void {
checkForUpdates(true);
});
// 刷新页面(托盘外可选入口)
ipcMain.handle('desktop:reload', (event) => {
assertTrustedSender(event);
reloadMainWindow();
});
// 关于弹窗
ipcMain.handle('desktop:show-about', (event) => {
assertTrustedSender(event);
showAboutDialog();
});
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
ipcMain.handle('desktop:focus-window', (event) => {
assertTrustedSender(event);

View File

@@ -0,0 +1,89 @@
import { app, dialog, Menu } from 'electron';
import { checkForUpdates } from './updater';
import { getMainWindow, markQuitting } from './window';
/**
* 应用菜单
* - macOS:系统菜单栏(不占窗口客户区),保留编辑/窗口角色以保证快捷键
* - Windows/Linux:不挂原生菜单栏,检查更新/刷新等走托盘 + Ctrl+R
*/
/** 刷新当前主窗口页面 */
export function reloadMainWindow(): void {
getMainWindow()?.webContents.reload();
}
/** 「关于」弹窗 */
export function showAboutDialog(): void {
dialog.showMessageBox({
type: 'info',
title: '关于',
message: '萧康云医',
detail: `版本:v${app.getVersion()}\n平台:${process.platform}`,
});
}
/**
* 注册应用菜单
* Win/Linux 设 null,避免占客户区;刷新快捷键在 window.ts 拦截
*/
export function createAppMenu(): void {
if (process.platform === 'darwin') {
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: '检查更新',
click: () => checkForUpdates(true),
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
{ role: 'editMenu' },
{
label: '查看',
submenu: [
{
label: '刷新',
accelerator: 'CmdOrCtrl+R',
click: () => reloadMainWindow(),
},
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
{
label: '帮助',
submenu: [
{
label: '关于萧康云医',
click: () => showAboutDialog(),
},
],
},
]),
);
return;
}
Menu.setApplicationMenu(null);
}
/** 供托盘等复用:退出 */
export function quitApp(): void {
markQuitting();
app.quit();
}

View File

@@ -2,19 +2,33 @@ import { app, Menu, nativeImage, Tray } from 'electron';
import log from 'electron-log/main';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { promptAndClearCache } from './cache';
import { getResourcePath } from './config';
import { checkForUpdates } from './updater';
import { markQuitting, showMainWindow } from './window';
import { getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 系统托盘:应用常驻入口
* - Windows 单击托盘图标恢复主窗口;mac 单击弹出菜单(跟随平台惯例)
* - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出
* - 菜单:显示主窗口 / 开机自启 / 重新启动 / 切换账号 / 清除缓存 / 检查更新 / 退出
* 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失(Electron 经典坑)
*/
let tray: Tray | null = null;
/** 重新启动整个 Electron 进程(保留启动参数,常用于卡死/配置变更后自愈) */
function restartApp(): void {
markQuitting();
app.relaunch();
app.exit(0);
}
/** 唤起主窗口并通知渲染端打开「切换账号」弹窗(未登录时弹窗自行失败即可) */
function openSwitchAccount(): void {
showMainWindow();
getMainWindow()?.webContents.send('desktop:open-switch-account');
}
/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
@@ -34,6 +48,20 @@ function buildTrayMenu(): Menu {
refreshTrayMenu();
},
},
{
label: '重新启动',
click: () => restartApp(),
},
{
label: '切换账号',
click: () => openSwitchAccount(),
},
{
label: '清除缓存',
click: () => {
void promptAndClearCache();
},
},
{
label: '检查更新',
click: () => checkForUpdates(true),
@@ -63,7 +91,10 @@ export function refreshTrayMenu(): void {
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 });
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());

View File

@@ -1,104 +1,287 @@
import { app, dialog, shell } from 'electron';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
/**
* 桌面端版本检查与安装包下载安装
* - 拉取 OpenAPI 最新已发布版本,与本地 app.getVersion 比较
* - 有新版本则下载后台登记的 download_url 安装包,下载完拉起安装程序并退出本应用
* - 启动 10 秒后自动检查一次 + 每 3 小时轮询 + 托盘/菜单手动触发
* - force_update=1 时不询问,直接下载安装;否则弹窗确认
*/
import { DOWNLOAD_PAGE_URL } from './config';
import { createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { app, dialog, net, shell } from 'electron';
import log from 'electron-log/main';
import { OPEN_API_DESKTOP_VERSION_URL } from './config';
import { getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 自动更新(electron-updater + generic 静态托管)
* - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发
* - autoDownload:发现新版本自动后台下载,进度事件转发渲染端展示
* - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」
* - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」
*/
/** OpenAPI 返回的最新版元数据(字段与后端 latest 对齐) */
interface DesktopLatestMeta {
version?: string;
download_url?: string;
force_update?: number;
changelog?: string;
title?: string;
}
/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */
let isManualCheck = false;
/** 检查中防重入 */
let isChecking = false;
/** 下载中防重入 */
let isDownloading = 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)
);
/**
* 拉取 OpenAPI 最新版元数据
*/
async function fetchLatestMeta(): Promise<DesktopLatestMeta | null> {
const platform = process.platform;
const url = `${OPEN_API_DESKTOP_VERSION_URL}?platform=${encodeURIComponent(platform)}`;
try {
const response = await net.fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
log.warn(`OpenAPI 版本查询失败 HTTP ${response.status}`);
return null;
}
const body = (await response.json()) as {
code?: number;
result?: DesktopLatestMeta | Record<string, never>;
};
if (body.code !== 0 || !body.result || typeof body.result !== 'object') {
return null;
}
const result = body.result as DesktopLatestMeta;
if (!result.version) {
return null;
}
return result;
} catch (error) {
log.warn('OpenAPI 版本查询异常', error);
return null;
}
}
/**
* 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml,检查必然失败)
* 简易 semver 比较:remote > local 返回 true
* 只比主版本三段数字,后缀忽略(与后台 version_compare 主路径对齐)
*/
export function initUpdater(): void {
if (!app.isPackaged) return;
function isNewerVersion(remote: string, local: string): boolean {
const parse = (v: string): number[] => {
const core = String(v || '0')
.trim()
.replace(/^v/i, '')
.split(/[-+]/)[0];
return core.split('.').map((n) => {
const num = Number.parseInt(n, 10);
return Number.isFinite(num) ? num : 0;
});
};
const a = parse(remote);
const b = parse(local);
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i += 1) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x > y) return true;
if (x < y) return false;
}
return false;
}
autoUpdater.logger = log;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
/** 根据下载 URL 推断安装包扩展名 */
function guessInstallerExt(url: string): string {
const clean = url.split('?')[0].toLowerCase();
if (clean.endsWith('.dmg')) return '.dmg';
if (clean.endsWith('.zip')) return '.zip';
if (clean.endsWith('.appimage')) return '.AppImage';
if (clean.endsWith('.exe')) return '.exe';
if (process.platform === 'win32') return '.exe';
if (process.platform === 'darwin') return '.dmg';
return '.zip';
}
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()})`,
});
/**
* 下载安装包到临时目录,进度转发渲染端
* @returns 本地文件绝对路径
*/
async function downloadInstaller(
url: string,
version: string,
): Promise<string> {
const dest = join(
app.getPath('temp'),
`xk-desktop-${version}${guessInstallerExt(url)}`,
);
log.info(`开始下载安装包: ${url} -> ${dest}`);
const response = await net.fetch(url);
if (!response.ok || !response.body) {
throw new Error(`下载失败 HTTP ${response.status}`);
}
const total = Number(response.headers.get('content-length') || 0);
let received = 0;
const nodeStream = Readable.fromWeb(
response.body as import('node:stream/web').ReadableStream,
);
nodeStream.on('data', (chunk: Buffer) => {
received += chunk.length;
if (total > 0) {
const percent = Math.min(99, Math.round((received / total) * 100));
sendToRenderer('progress', { percent });
}
isManualCheck = false;
});
await pipeline(nodeStream, createWriteStream(dest));
sendToRenderer('progress', { percent: 100 });
return dest;
}
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('progress', { percent: Math.round(progress.percent) });
});
/**
* 拉起安装包并退出本应用(让安装程序替换文件)
*/
async function openInstallerAndQuit(filePath: string): Promise<void> {
const err = await shell.openPath(filePath);
if (err) {
throw new Error(`无法打开安装包: ${err}`);
}
markQuitting();
// 稍等让安装程序启动,再退出当前进程
setTimeout(() => {
app.quit();
}, 800);
}
autoUpdater.on('update-downloaded', async (info) => {
sendToRenderer('downloaded', { version: info.version });
// 唤起窗口再弹对话框,避免用户看不到安装确认
/**
* 发现新版本后的下载安装流程
*/
async function promptAndInstall(meta: DesktopLatestMeta): Promise<void> {
const version = String(meta.version || '').trim();
const downloadUrl = String(meta.download_url || '').trim();
if (!version || !downloadUrl) {
throw new Error('发布记录缺少版本号或安装包地址');
}
const force = Number(meta.force_update) === 1;
const changelog = String(meta.changelog || '').trim();
if (!force) {
showMainWindow();
const { response } = await dialog.showMessageBox({
type: 'info',
title: '发现新版本',
message: `发现新版本 v${version}`,
detail:
(changelog ? `${changelog}\n\n` : '') +
'下载完成后将自动打开安装程序,请按提示完成安装。',
buttons: ['立即下载安装', '稍后'],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) {
sendToRenderer('available', { version, deferred: true });
return;
}
}
if (isDownloading) return;
isDownloading = true;
try {
sendToRenderer('available', { version });
const filePath = await downloadInstaller(downloadUrl, version);
sendToRenderer('downloaded', { version, path: filePath });
if (force) {
log.info(`强制更新:打开安装包 v${version}`);
await openInstallerAndQuit(filePath);
return;
}
showMainWindow();
const { response } = await dialog.showMessageBox({
type: 'question',
title: '发现新版本',
message: `新版本 v${info.version} 已下载完成`,
detail: '立即重启安装,或在退出应用时自动安装。',
buttons: ['立即重启安装', '退出时自动安装'],
title: '下载完成',
message: `新版本 v${version} 已下载完成`,
detail: '即将打开安装程序,请关闭本应用后完成安装。',
buttons: ['打开安装程序', '稍后'],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
// 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住
markQuitting();
autoUpdater.quitAndInstall();
await openInstallerAndQuit(filePath);
}
});
} finally {
isDownloading = false;
}
}
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({
/**
* 初始化:仅打包后生效
*/
export function initUpdater(): void {
if (!app.isPackaged) return;
setTimeout(() => checkForUpdates(false), 10_000);
setInterval(() => checkForUpdates(false), 3 * 60 * 60 * 1000);
}
/**
* 触发一次版本检查
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈)
*/
export async function checkForUpdates(manual: boolean): Promise<void> {
if (!app.isPackaged) {
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: '当前安装包不支持自动更新',
detail: '请前往下载页获取最新版本手动安装。',
buttons: ['打开下载页', '取消'],
defaultId: 0,
cancelId: 1,
message: '开发模式不支持检查更新',
});
if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL);
} else if (isManualCheck) {
}
return;
}
if (isChecking || isDownloading) return;
isChecking = true;
isManualCheck = manual;
try {
const meta = await fetchLatestMeta();
if (!meta?.version) {
sendToRenderer('not-available');
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: `当前已是最新版本(v${app.getVersion()})`,
detail: '暂无已发布的桌面版本记录',
});
}
return;
}
const local = app.getVersion();
if (!isNewerVersion(meta.version, local)) {
sendToRenderer('not-available');
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: `当前已是最新版本(v${local})`,
});
}
return;
}
const downloadUrl = String(meta.download_url || '').trim();
if (!downloadUrl) {
throw new Error('新版本未上传安装包,请联系管理员');
}
await promptAndInstall(meta);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error('检查/下载更新失败', message);
sendToRenderer('error', { message });
if (isManualCheck) {
dialog.showMessageBox({
type: 'warning',
title: '检查更新',
@@ -106,34 +289,8 @@ export function initUpdater(): void {
detail: message,
});
}
} finally {
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

@@ -107,6 +107,7 @@ function buildErrorPageUrl(reason: string): string {
export function createMainWindow(options: { startHidden: boolean }): BrowserWindow {
setupPermissionHandlers();
// 使用系统默认标题栏;Win 无应用菜单栏(业务入口在托盘),避免占客户区高度
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
@@ -163,10 +164,21 @@ export function createMainWindow(options: { startHidden: boolean }): BrowserWind
// 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame)
mainWindow.on('focus', () => mainWindow?.flashFrame(false));
// F12 切换 DevTools:内部管理系统保留排障入口
// F12 切换 DevTools;Win 无原生菜单时用 Ctrl+R 刷新(对齐菜单「刷新」)
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
if (input.type !== 'keyDown') return;
if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
return;
}
if (
process.platform === 'win32' &&
(input.control || input.meta) &&
String(input.key).toLowerCase() === 'r' &&
!input.alt &&
!input.shift
) {
mainWindow?.webContents.reload();
}
});

View File

@@ -84,12 +84,20 @@ const desktopAPI = {
ipcRenderer.invoke('desktop:set-settings', patch),
/** 手动检查更新(结果由主进程弹窗反馈) */
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
/** 刷新主窗口页面 */
reload: (): Promise<void> => ipcRenderer.invoke('desktop:reload'),
/** 关于弹窗 */
showAbout: (): Promise<void> => ipcRenderer.invoke('desktop:show-about'),
/** 唤起并聚焦主窗口 */
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));
},
/** 监听托盘「切换账号」:主进程唤起窗口后发此事件,渲染层打开 SwitchAccountModal */
onOpenSwitchAccount: (callback: () => void): void => {
ipcRenderer.on('desktop:open-switch-account', () => callback());
},
/** 监听更新事件(available / progress / downloaded / not-available / error),渲染层做轻量提示 */
onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => {
ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload));