feat: 辅料功能、康正源拉取价格接口
This commit is contained in:
65
apps/desktop/src/main/cache.ts
Normal file
65
apps/desktop/src/main/cache.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
89
apps/desktop/src/main/menu.ts
Normal file
89
apps/desktop/src/main/menu.ts
Normal 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();
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user