diff --git a/apps/desktop/.npmrc b/apps/desktop/.npmrc new file mode 100644 index 00000000..960d3531 --- /dev/null +++ b/apps/desktop/.npmrc @@ -0,0 +1,4 @@ +# 桌面端本地打包:强制 electron / electron-builder 走国内镜像 +# (仅根 .npmrc 时,pnpm exec electron-builder 子进程常仍打 GitHub 导致 ETIMEDOUT) +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 710bef69..e4335978 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -34,16 +34,41 @@ pnpm build:antd-desktop | `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) | | `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 | | `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666`) | +| `XK_DESKTOP_OPEN_API_VERSION_URL` | 覆盖桌面版本 OpenAPI 完整 URL(默认 `{API_UPSTREAM}/open-api/desktop/version/latest`) | +| `XK_DESKTOP_VERSION` | 打包时跳过交互,强制写入该 semver(CI 用;也可用 `--version=1.0.1`) | | `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** | | `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 | 未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。 +## 打包与版本确认 + +`pnpm -F @vben/desktop run pack`(以及根目录 `build:desktop` / `build:antd-desktop`)会先交互确认版本: + +``` +上次打包版本号:1.0.0 + 1) 使用上次版本号(1.0.0) + 2) 输入新的版本号 +``` + +确认后写入 `apps/desktop/package.json` 的 `version`,再执行 electron-builder。 + +产物目录 `apps/desktop/release/`: + +| 文件 | 用途 | +|---|---| +| `xk-desktop-{version}-win-x64.exe` | 自动更新 / latest.yml 引用(ASCII) | +| `萧康医药 {version}.exe` | 人工或后台上传用友好文件名(由 pack 脚本复制生成) | + ## 发布自动更新 `release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs` 的 `publish.url`): -- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap` +- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap`(自动更新);另可将 `萧康医药 {version}.exe` 作对外下载名 - mac:`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`(zip 是 mac 自动更新必需格式) -前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。 +前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,再在后台「系统管理 → 桌面端更新」登记并发布。 + +桌面端行为:启动约 10 秒后检查一次,之后每 **3 小时**轮询;发现新版本后台下载,完成后提示重启安装(后台勾选「强制更新」则下载完直接重启)。检查前会请求 OpenAPI 元数据以覆盖 feed / 强制标记。 + +Windows 桌面端使用自绘顶栏(与系统窗口按钮同一行):左上角「萧康云医」菜单含刷新 / 检查更新 / 关于。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6bf74c20..8e7320ad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@vben/desktop", - "version": "1.0.0", + "version": "0.1.0", "private": true, "description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)", "author": "萧康云医", @@ -8,7 +8,8 @@ "scripts": { "dev": "electron-vite dev", "build": "electron-vite build", - "pack": "electron-builder --config electron-builder.config.cjs", + "pack": "node ./scripts/pack.mjs", + "pack:raw": "node ./scripts/run-builder.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/desktop/scripts/pack.mjs b/apps/desktop/scripts/pack.mjs new file mode 100644 index 00000000..e4a01c7e --- /dev/null +++ b/apps/desktop/scripts/pack.mjs @@ -0,0 +1,196 @@ +/** + * 桌面打包脚本:交互确认版本号 → electron-builder → 复制友好文件名 + * + * 交互: + * 上次打包版本号:x.y.z + * 1) 使用上次版本号 + * 2) 输入新版本号 + * + * 产物: + * - 自动更新用 ASCII 名:xk-desktop-{version}-win-x64.exe(latest.yml 引用) + * - 人工/后台上传用:萧康医药 {version}.exe + * + * 非交互(CI):XK_DESKTOP_VERSION=1.2.3 或 --version=1.2.3 + */ +import { spawn } from 'node:child_process'; +import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = join(__dirname, '..'); +const packageJsonPath = join(desktopRoot, 'package.json'); +const releaseDir = join(desktopRoot, 'release'); + +/** semver 简校验 */ +function isValidVersion(v) { + return /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(v); +} + +function readPkg() { + return JSON.parse(readFileSync(packageJsonPath, 'utf8')); +} + +function writePkgVersion(version) { + const pkg = readPkg(); + pkg.version = version; + writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); +} + +/** 从 argv / 环境变量取强制版本(跳过交互) */ +function getForcedVersion() { + const arg = process.argv.find((a) => a.startsWith('--version=')); + if (arg) return arg.slice('--version='.length).trim(); + if (process.env.XK_DESKTOP_VERSION) return String(process.env.XK_DESKTOP_VERSION).trim(); + return ''; +} + +function ask(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(String(answer || '').trim()); + }); + }); +} + +/** + * 交互确认本次打包版本 + * @returns {Promise} + */ +async function resolveVersion() { + const forced = getForcedVersion(); + if (forced) { + if (!isValidVersion(forced)) { + throw new Error(`无效版本号: ${forced}(请用如 1.0.1)`); + } + console.log(`使用指定版本号: ${forced}`); + return forced; + } + + if (!process.stdin.isTTY) { + const current = readPkg().version || '1.0.0'; + console.log(`非交互环境,沿用 package.json 版本: ${current}`); + return current; + } + + const last = readPkg().version || '1.0.0'; + console.log(''); + console.log('========== 桌面端版本确认 =========='); + console.log(`上次打包版本号:${last}`); + console.log('请选择:'); + console.log(` 1) 使用上次版本号(${last})`); + console.log(' 2) 输入新的版本号'); + console.log(''); + + let choice = await ask('请输入选项 [1/2](默认 1): '); + if (choice === '' || choice === '1') { + return last; + } + if (choice !== '2') { + console.log('未识别选项,默认使用上次版本号'); + return last; + } + + for (;;) { + const next = await ask('请输入新版本号(如 1.0.1): '); + if (isValidVersion(next)) { + return next; + } + console.log('格式不正确,请使用 semver,例如 1.0.1'); + } +} + +function runBuilder() { + // electron-builder 下载 Electron / NSIS 等会打 GitHub;国内网络常超时。 + // 根 .npmrc 有镜像配置,但 spawn 出的进程不一定带上,这里强制写入环境变量。 + const electronMirror = + process.env.ELECTRON_MIRROR || 'https://npmmirror.com/mirrors/electron/'; + const builderBinariesMirror = + process.env.ELECTRON_BUILDER_BINARIES_MIRROR || + 'https://npmmirror.com/mirrors/electron-builder-binaries/'; + const env = { + ...process.env, + ELECTRON_MIRROR: electronMirror, + ELECTRON_BUILDER_BINARIES_MIRROR: builderBinariesMirror, + // 兼容 app-builder-lib 读取的 npm_config_* 形态 + npm_config_electron_mirror: electronMirror, + npm_config_electron_builder_binaries_mirror: builderBinariesMirror, + }; + console.log(`Electron 镜像: ${electronMirror}`); + console.log(`Builder 工具链镜像: ${builderBinariesMirror}`); + return new Promise((resolve, reject) => { + const child = spawn( + 'pnpm exec electron-builder --config electron-builder.config.cjs', + { + cwd: desktopRoot, + env, + shell: true, + stdio: 'inherit', + }, + ); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`electron-builder 失败,退出码 ${code}`)); + }); + }); +} + +/** + * 复制 Windows 安装包为「萧康医药 {version}.exe」方便后台/人工上传 + * 保留原 ASCII 文件名供 electron-updater 的 latest.yml 使用 + */ +function copyFriendlyWinExe(version) { + if (!existsSync(releaseDir)) { + console.warn(`未找到 release 目录: ${releaseDir}`); + return; + } + const files = readdirSync(releaseDir); + // 优先精确匹配 builder artifactName + const exact = `xk-desktop-${version}-win-x64.exe`; + let src = files.includes(exact) ? exact : ''; + if (!src) { + src = + files.find( + (f) => + f.endsWith('.exe') && + f.includes(version) && + !f.includes('萧康') && + !f.toLowerCase().includes('blockmap'), + ) || ''; + } + if (!src) { + console.warn('未找到 Windows 安装包,跳过友好文件名复制'); + return; + } + const destName = `萧康医药 ${version}.exe`; + const from = join(releaseDir, src); + const to = join(releaseDir, destName); + copyFileSync(from, to); + console.log(`\n已生成上传用安装包: ${to}`); + console.log(`(自动更新仍使用: ${from})`); +} + +async function main() { + const version = await resolveVersion(); + const current = readPkg().version; + if (version !== current) { + writePkgVersion(version); + console.log(`已写入 package.json version = ${version}`); + } else { + console.log(`版本号保持 ${version}`); + } + + console.log('\n开始 electron-builder 打包…\n'); + await runBuilder(); + copyFriendlyWinExe(version); + console.log('\n打包流程结束'); +} + +main().catch((err) => { + console.error(`\n❌ ${err.message || err}`); + process.exit(1); +}); diff --git a/apps/desktop/scripts/run-builder.mjs b/apps/desktop/scripts/run-builder.mjs new file mode 100644 index 00000000..3434cb0f --- /dev/null +++ b/apps/desktop/scripts/run-builder.mjs @@ -0,0 +1,44 @@ +/** + * 直接跑 electron-builder(跳过版本交互),并注入国内镜像环境变量 + * 避免打 GitHub 超时:connect ETIMEDOUT 20.x.x.x:443 + */ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +const electronMirror = + process.env.ELECTRON_MIRROR || 'https://npmmirror.com/mirrors/electron/'; +const builderBinariesMirror = + process.env.ELECTRON_BUILDER_BINARIES_MIRROR || + 'https://npmmirror.com/mirrors/electron-builder-binaries/'; + +const env = { + ...process.env, + ELECTRON_MIRROR: electronMirror, + ELECTRON_BUILDER_BINARIES_MIRROR: builderBinariesMirror, + npm_config_electron_mirror: electronMirror, + npm_config_electron_builder_binaries_mirror: builderBinariesMirror, +}; + +console.log(`Electron 镜像: ${electronMirror}`); +console.log(`Builder 工具链镜像: ${builderBinariesMirror}`); + +const child = spawn( + 'pnpm exec electron-builder --config electron-builder.config.cjs', + { + cwd: desktopRoot, + env, + shell: true, + stdio: 'inherit', + }, +); + +child.on('close', (code) => { + process.exit(code ?? 1); +}); +child.on('error', (err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/desktop/src/main/cache.ts b/apps/desktop/src/main/cache.ts new file mode 100644 index 00000000..855c457f --- /dev/null +++ b/apps/desktop/src/main/cache.ts @@ -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 { + 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 { + 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), + }); + } +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index d2eef74a..71333d30 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -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'; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c5cd6a46..4cf40e44 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 11cbd4af..070ef8af 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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); diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts new file mode 100644 index 00000000..815bd9fb --- /dev/null +++ b/apps/desktop/src/main/menu.ts @@ -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(); +} diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 7fc1b0a5..e52a221d 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -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()); diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index dc5a3ad3..ba1d2ee3 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -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 { + 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; + }; + 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 { + 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 { + 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 { + 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 { + 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 { - 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; } } diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 6691c2c8..a4c0ace0 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -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(); } }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dcfb3446..b1dd2fad 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -84,12 +84,20 @@ const desktopAPI = { ipcRenderer.invoke('desktop:set-settings', patch), /** 手动检查更新(结果由主进程弹窗反馈) */ checkUpdate: (): Promise => ipcRenderer.invoke('desktop:check-update'), + /** 刷新主窗口页面 */ + reload: (): Promise => ipcRenderer.invoke('desktop:reload'), + /** 关于弹窗 */ + showAbout: (): Promise => ipcRenderer.invoke('desktop:show-about'), /** 唤起并聚焦主窗口 */ focusWindow: (): Promise => 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)); diff --git a/apps/web-antd/src/api/core/file-gallery.ts b/apps/web-antd/src/api/core/file-gallery.ts index 8a0862b0..480f6850 100644 --- a/apps/web-antd/src/api/core/file-gallery.ts +++ b/apps/web-antd/src/api/core/file-gallery.ts @@ -97,12 +97,32 @@ export async function getFileTypes() { return requestClient.get(`${prefix}file-types`); } -export async function syncFileGalleryFromOss() { +/** 规划 OSS 同步日期分组(full 全部 / incremental 从库内最新日期起) */ +export async function planFileGalleryOssSync(data?: { mode?: 'full' | 'incremental' }) { + return requestClient.post<{ + mode: 'full' | 'incremental'; + prefixes: string[]; + total: number; + since_date: string; + }>(`${prefix}sync-oss-plan`, data ?? {}); +} + +/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1) */ +export async function syncFileGalleryFromOss(data?: { + limit?: number; + marker?: string; + prefix?: string; + finalize?: number; +}) { return requestClient.post<{ scanned: number; skipped: number; synced: number; - }>(`${prefix}sync-oss`); + next_marker: string; + done: boolean; + folder_created: number; + prefix: string; + }>(`${prefix}sync-oss`, data ?? {}); } /** 文件加入自定义分组 */ diff --git a/apps/web-antd/src/app.vue b/apps/web-antd/src/app.vue index 5d779892..2da55098 100644 --- a/apps/web-antd/src/app.vue +++ b/apps/web-antd/src/app.vue @@ -7,8 +7,8 @@ import { useUserStore as useVbenUserStore } from '@vben/stores'; import { App, ConfigProvider, theme } from 'ant-design-vue'; -import { antdLocale } from '#/locales'; import { GlobalContextMenu } from '#/components/context-menu'; +import { antdLocale } from '#/locales'; import { useWebSocket } from '#/views/business/chat/composables/useWebSocket'; import { useChatStore } from '#/views/business/chat/stores/chat'; import { useUserStore } from '#/views/business/chat/stores/user'; @@ -20,15 +20,13 @@ const userStore = useUserStore(); const chatStore = useChatStore(); const vbenUserStore = useVbenUserStore(); -// 用户信息加载状态跟踪 const userInfoLoaded = ref(false); -// 监听userInfo加载完成 watch( () => vbenUserStore.userInfo, (newUserInfo) => { if (newUserInfo) { - console.log('用户信息已加载:', newUserInfo) + console.log('用户信息已加载:', newUserInfo); userStore.currentUser = newUserInfo; userStore.login(newUserInfo); userInfoLoaded.value = true; @@ -56,20 +54,12 @@ const tokenTheme = computed(() => { }); const initWebsocket = () => { - // 确保用户信息已加载 if (!userStore.currentUser) return; - - // 初始化数据库 chatStore.initDB(); - - // 加载好友列表(使用用户ID) chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.doctor_id); - - // 创建WebSocket连接 connectWebSocket(); }; -// 当userInfo加载完成后再执行初始化 watch(userInfoLoaded, (loaded) => { if (loaded) { initWebsocket(); diff --git a/apps/web-antd/src/components/form/components/upload-oss-file.vue b/apps/web-antd/src/components/form/components/upload-oss-file.vue index fb350c03..a0eaf74a 100644 --- a/apps/web-antd/src/components/form/components/upload-oss-file.vue +++ b/apps/web-antd/src/components/form/components/upload-oss-file.vue @@ -1,18 +1,20 @@