diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index cdd0c377..5400dfa9 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -10,6 +10,7 @@ alwaysApply: true 3. 有封装好的方法、组件需要复用,不要重复造轮子 4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show) 5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化 +6. 卡片强调样式:**禁止**用左侧竖色条(`border-left: Npx solid 色值` / 左侧色块)做卡片强调或分类标识;统一用「1px 同色系边框 + 微光」——`border: 1px solid hsl(var(--主题色) / 30%)` 配 `box-shadow: 0 0 6px hsl(var(--主题色) / 18%)`;历史页面暂不强制回改,新增/改动的卡片必须遵守 # 全局架构规范 diff --git a/.npmrc b/.npmrc index 7549542d..ea2d746e 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,4 @@ registry=https://registry.npmmirror.com +# Electron 二进制与 electron-builder 打包工具链的国内镜像(不配置在国内网络下大概率下载超时失败) +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 00000000..dcfe6825 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,2 @@ +out/ +release/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000..294d9789 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,46 @@ +# @vben/desktop — 萧康云医 PC 桌面端 + +Electron 壳工程:打包加载 `apps/web-antd` 的生产构建产物,提供桌面通知、开机自启、系统托盘、未读角标、静默打印、全局快捷键与自动更新能力。 + +## 常用命令(在仓库根目录执行) + +```bash +# 开发:并行启动 web-antd dev server(:5666)与 Electron 壳(壳会自动等待/重试连接 dev server) +pnpm dev:desktop + +# 打包:turbo 先构建 web-antd dist,再构建主进程并出安装包(产物在 apps/desktop/release/) +pnpm build:desktop +``` + +- Windows 机器出 NSIS 安装包;mac 包必须在 macOS 机器上执行同一命令(electron-builder 不支持交叉编译 mac 包)。 +- 不需要打桌面包的同事 / CI,可设置环境变量 `ELECTRON_SKIP_BINARY_DOWNLOAD=1` 跳过 Electron 二进制下载,不影响 web 开发。 + +## 环境切换(dev 走本地,打包走生产) + +| 模式 | 页面来源 | API 走向 | +|---|---|---| +| `pnpm dev:desktop` | `http://localhost:5666`(web-antd vite dev server) | vite 代理转发到**本地后端**:`/api/*` → `localhost:18001`、`/im-api/*` → `localhost:12080`(见 `apps/web-antd/vite.config.ts`) | +| 打包后的安装包 | `app://local/`(内置 web-antd 生产 dist) | 主进程协议处理器转发到**生产上游**:`/api/admin/*` → `api.xiaokang88.com`、`/im-api/*` → `api.ws.g.xiaokang88.com/api`(见 `src/main/config.ts`,可用环境变量覆盖) | + +即:开发调试自动对接本地后端(与网页开发完全一致),只有 `pnpm build:desktop` 出的安装包才请求生产环境,无需手动切换。 + +## 环境变量 + +| 变量 | 用途 | +|---|---| +| `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) | +| `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 | +| `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666`) | +| `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** | +| `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 | + +未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。 + +## 发布自动更新 + +`release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs` 的 `publish.url`): + +- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap` +- mac:`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`(zip 是 mac 自动更新必需格式) + +前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。 diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs new file mode 100644 index 00000000..879364d4 --- /dev/null +++ b/apps/desktop/electron-builder.config.cjs @@ -0,0 +1,78 @@ +/** + * electron-builder 打包配置 + * 用 JS 配置而不是 yml:mac 公证需要按「环境变量是否配齐」动态开关。 + * + * 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错): + * - Windows / mac 代码签名:配置了 CSC_LINK + CSC_KEY_PASSWORD(或 mac 钥匙串里有 + * Developer ID 证书)时 electron-builder 自动签名;否则自动跳过,只输出告警 + * - mac 公证:APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID 三者齐全才启用 + * - 未签名 mac 包仍可内部分发:首次打开需右键「打开」,且自动更新会降级为提示手动下载 + */ +const canNotarize = Boolean( + process.env.APPLE_ID && + process.env.APPLE_APP_SPECIFIC_PASSWORD && + process.env.APPLE_TEAM_ID, +); + +/** @type {import('electron-builder').Configuration} */ +const config = { + // appId 必须与主进程 setAppUserModelId 一致,否则 Windows 系统通知不显示 + appId: 'com.xiaokang.xkadmin', + productName: '萧康云医', + // 安装包文件名用 ASCII,避免中文名在静态托管/下载链接上出编码问题 + artifactName: 'xk-desktop-${version}-${os}-${arch}.${ext}', + directories: { + output: 'release', + buildResources: 'resources', + }, + files: ['out/**'], + extraResources: [ + // web-antd 生产构建产物:放 resources/web(不进 asar),为后续渲染层热更新留路 + { from: '../web-antd/dist', to: 'web' }, + // 应用图标供运行时使用(托盘、Windows 窗口图标) + { from: 'resources/icon.png', to: 'icon.png' }, + ], + asar: true, + compression: 'normal', + win: { + // TODO(待确认):正式图标待提供,当前用 web-antd logo 预生成的 ico 占位 + // 直接给 .ico 让 builder 跳过 png 转换(本机 builder 的 wasm 图标工具会报内存错误) + icon: 'resources/icon.ico', + target: [{ target: 'nsis', arch: ['x64'] }], + }, + nsis: { + // 非一键安装:允许用户选择安装目录,符合企业内部软件分发习惯 + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: '萧康云医', + }, + mac: { + icon: 'resources/icon.png', + category: 'public.app-category.medical', + // dmg 给用户安装,zip 是 electron-updater 在 mac 自动更新的必需格式,缺一不可 + target: [ + { target: 'dmg', arch: ['x64', 'arm64'] }, + { target: 'zip', arch: ['x64', 'arm64'] }, + ], + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'resources/entitlements.mac.plist', + entitlementsInherit: 'resources/entitlements.mac.plist', + // IM 音视频通话必需的隐私用途描述,缺失时 mac 上调用摄像头/麦克风直接崩溃 + extendInfo: { + NSCameraUsageDescription: '视频问诊需要使用摄像头', + NSMicrophoneUsageDescription: '语音/视频通话需要使用麦克风', + }, + notarize: canNotarize, + }, + publish: { + provider: 'generic', + // TODO(待确认):更新包静态托管地址,需与主进程 config.ts 的 DOWNLOAD_PAGE_URL 保持一致 + url: 'https://static.xiaokang88.com/xk-desktop/', + }, +}; + +module.exports = config; diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 00000000..162224df --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; + +/** + * electron-vite 构建配置 + * 只构建主进程与预加载脚本,不配置 renderer: + * - 开发模式渲染层直接加载 web-antd 的 vite dev server(http://localhost:5666) + * - 打包模式渲染层加载 app://local/(web-antd 的生产 dist,由主进程自定义协议提供服务) + * externalizeDepsPlugin:dependencies(electron-updater / electron-log)保持外部化, + * 由 electron-builder 按生产依赖收集进安装包,避免打包器转译这类含运行时文件读取的库出问题 + */ +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'out/main', + lib: { entry: 'src/main/index.ts' }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'out/preload', + lib: { entry: 'src/preload/index.ts' }, + }, + }, +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..6bf74c20 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,27 @@ +{ + "name": "@vben/desktop", + "version": "1.0.0", + "private": true, + "description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)", + "author": "萧康云医", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "pack": "electron-builder --config electron-builder.config.cjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "electron-log": "catalog:", + "electron-updater": "^6.8.9" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vben/web-antd": "workspace:*", + "electron": "^43.4.0", + "electron-builder": "catalog:", + "electron-vite": "catalog:", + "typescript": "catalog:", + "vite": "^7.3.6" + } +} diff --git a/apps/desktop/resources/entitlements.mac.plist b/apps/desktop/resources/entitlements.mac.plist new file mode 100644 index 00000000..f3950fd2 --- /dev/null +++ b/apps/desktop/resources/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + + diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 00000000..dddf3784 Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 00000000..8d7f47ae Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/src/main/auto-launch.ts b/apps/desktop/src/main/auto-launch.ts new file mode 100644 index 00000000..ddde864c --- /dev/null +++ b/apps/desktop/src/main/auto-launch.ts @@ -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(); +} diff --git a/apps/desktop/src/main/badge.ts b/apps/desktop/src/main/badge.ts new file mode 100644 index 00000000..86de4d3b --- /dev/null +++ b/apps/desktop/src/main/badge.ts @@ -0,0 +1,24 @@ +import { app, nativeImage } from 'electron'; + +import { getMainWindow } from './window'; + +/** + * 未读角标:按平台分支实现 + * - mac:Dock 图标原生支持文字角标,直接 setBadge + * - Windows:任务栏无原生数字角标,用「覆盖图标」实现(图由 preload 侧 canvas 绘制成 dataURL 传来, + * 因为主进程没有 DOM/canvas,在渲染侧画图是社区标准做法) + */ +export function applyBadge(count: number, image?: string): void { + if (process.platform === 'darwin') { + app.dock?.setBadge(count > 0 ? String(Math.min(count, 99)) : ''); + return; + } + const win = getMainWindow(); + if (!win) return; + if (count > 0 && image) { + win.setOverlayIcon(nativeImage.createFromDataURL(image), `${count} 条未读消息`); + } else { + // 数量归零或没有角标图时清除覆盖图标 + win.setOverlayIcon(null, ''); + } +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 00000000..d2eef74a --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -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); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 00000000..c5cd6a46 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -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(); +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 00000000..11cbd4af --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -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) => { + assertTrustedSender(event); + return updateSettings(patch); + }); + + // 手动检查更新(渲染端设置页/关于入口) + ipcMain.handle('desktop:check-update', (event) => { + assertTrustedSender(event); + checkForUpdates(true); + }); + + // 渲染端主动唤起主窗口(如页内提醒的「查看」动作) + ipcMain.handle('desktop:focus-window', (event) => { + assertTrustedSender(event); + showMainWindow(); + }); +} diff --git a/apps/desktop/src/main/printer.ts b/apps/desktop/src/main/printer.ts new file mode 100644 index 00000000..8dfcd29d --- /dev/null +++ b/apps/desktop/src/main/printer.ts @@ -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 { + 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((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)); + }); + }); +} diff --git a/apps/desktop/src/main/protocol.ts b/apps/desktop/src/main/protocol.ts new file mode 100644 index 00000000..c06cb8eb --- /dev/null +++ b/apps/desktop/src/main/protocol.ts @@ -0,0 +1,108 @@ +import { existsSync, statSync } from 'node:fs'; +import { join, normalize } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { app, net, protocol } from 'electron'; + +import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config'; + +/** + * app:// 自定义协议:桌面端的「本地 nginx」 + * 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback + * (等价 nginx 的 try_files $uri /index.html),因此无需把前端路由改成 hash 模式 + * 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 —— + * 渲染层看到的是同源请求,天然规避 CORS,后端与 nginx 都不需要任何改动 + */ + +/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */ +type ElectronFetchInit = RequestInit & { + /** 转发出去的请求不再进入自定义协议处理器,防止死循环 */ + bypassCustomProtocolHandlers?: boolean; + /** 携带流式 body 时 fetch 规范要求显式声明半双工 */ + duplex?: 'half'; +}; + +/** + * 注册特权协议,必须在 app ready 之前调用 + * standard/secure 让 app:// 成为「安全上下文」,navigator.clipboard、getUserMedia、 + * IndexedDB、crypto.subtle 等浏览器能力才可用;stream 支持流式响应(音频等大文件) + */ +export function registerSchemePrivileges(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + corsEnabled: true, + }, + }, + ]); +} + +/** + * 解析 web dist 根目录 + * 打包后位于 resources/web(electron-builder extraResources 拷入,不进 asar); + * 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist + */ +export function getWebRoot(): string { + return app.isPackaged + ? join(process.resourcesPath, 'web') + : join(__dirname, '../../../web-antd/dist'); +} + +/** + * 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass) + * 保留原请求的方法 / 头 / 流式请求体,Bearer token 请求头原样透传 + */ +function proxyRequest(target: string, request: Request): Promise { + 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); + }); +} diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts new file mode 100644 index 00000000..d93a6609 --- /dev/null +++ b/apps/desktop/src/main/settings.ts @@ -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; + 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 { + const next = { ...getSettings(), ...patch }; + cache = next; + try { + writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8'); + } catch (error) { + log.warn('写入桌面端设置失败', error); + } + return next; +} diff --git a/apps/desktop/src/main/shortcut.ts b/apps/desktop/src/main/shortcut.ts new file mode 100644 index 00000000..f4d37bfe --- /dev/null +++ b/apps/desktop/src/main/shortcut.ts @@ -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(); +} diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000..7fc1b0a5 --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -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); + } +} diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 00000000..dc5a3ad3 --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -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 { + 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 new file mode 100644 index 00000000..6691c2c8 --- /dev/null +++ b/apps/desktop/src/main/window.ts @@ -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 = ` + + + + 加载失败 + + + +
+
页面加载失败,正在自动重试…
+
请检查网络连接(${reason})
+
+ +`; + 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(); + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 00000000..dcfb3446 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,102 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +/** + * 预加载脚本:通过 contextBridge 向渲染层暴露受控的 desktopAPI + * - 渲染层(web-antd)通过 window.desktopAPI 使用桌面能力;浏览器环境没有该对象, + * 前端封装层(apps/web-antd/src/util/desktop.ts)会自动 no-op 降级 + * - sandbox 模式下本文件必须是 CJS 产物(electron-vite 已按包配置输出 CJS), + * 且只能使用 Electron 提供的受限 API(contextBridge / ipcRenderer 等) + */ + +/** + * Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL + * 为什么在 preload 画:主进程没有 DOM/canvas;preload 运行在渲染进程内有 DOM, + * 由主进程 setOverlayIcon 消费,这是 Windows 数字角标的社区标准做法 + */ +function drawBadgeImage(count: number): string | undefined { + try { + const size = 32; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return undefined; + // 红色圆底 + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fillStyle = '#f5222d'; + ctx.fill(); + // 白色数字,超过 99 显示 99+ + const text = count > 99 ? '99+' : String(count); + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${text.length > 2 ? 13 : 18}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, size / 2, size / 2 + 1); + return canvas.toDataURL('image/png'); + } catch { + // canvas 不可用时退化为无图(主进程会清除角标),不影响业务 + return undefined; + } +} + +/** 暴露给渲染层的桌面能力集合,字段语义与 web 侧 desktop.ts 封装一一对应 */ +const desktopAPI = { + /** 渲染层判断是否运行在桌面端的唯一标记 */ + isElectron: true as const, + /** 当前平台:win32 / darwin / linux */ + platform: process.platform, + /** 获取桌面端版本号(关于/设置展示用) */ + getVersion: (): Promise => ipcRenderer.invoke('desktop:get-version'), + /** 发送系统通知;route 为点击通知后要跳转的前端路由 */ + notify: (options: { title: string; body: string; route?: string }): Promise => + ipcRenderer.invoke('desktop:notify', options), + /** 设置未读角标数量(0 表示清除);Windows 角标图在此侧 canvas 生成 */ + setBadge: (count: number): Promise => + ipcRenderer.invoke('desktop:set-badge', { + count, + image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined, + }), + /** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */ + flashFrame: (): Promise => ipcRenderer.invoke('desktop:flash-frame'), + /** 读取开机自启状态 */ + getAutoLaunch: (): Promise => ipcRenderer.invoke('desktop:get-auto-launch'), + /** 设置开机自启,返回设置后的实际状态 */ + setAutoLaunch: (enable: boolean): Promise => + ipcRenderer.invoke('desktop:set-auto-launch', enable), + /** 获取系统打印机列表(打印设置下拉用) */ + getPrinters: (): Promise> => + 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 => ipcRenderer.invoke('desktop:check-update'), + /** 唤起并聚焦主窗口 */ + focusWindow: (): Promise => 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; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..b49951dc --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "useUnknownInCatchVariables": true + }, + "include": ["src", "electron.vite.config.ts"] +} diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index acb384ca..3a5965db 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -52,6 +52,7 @@ "js-base64": "^3.7.7", "lodash-es": "^4.17.21", "lucide-vue-next": "^0.487.0", + "lunar-typescript": "^1.8.6", "markdown-it": "^14.1.0", "md-editor-v3": "^5.4.5", "pinia": "catalog:", diff --git a/apps/web-antd/public/audio/register-notify.mp3 b/apps/web-antd/public/audio/register-notify.mp3 new file mode 100644 index 00000000..0493d6f1 Binary files /dev/null and b/apps/web-antd/public/audio/register-notify.mp3 differ diff --git a/apps/web-antd/src/components/form/components/store-multi-search.vue b/apps/web-antd/src/components/form/components/store-multi-search.vue index 5dd49b80..3f0b1cd1 100644 --- a/apps/web-antd/src/components/form/components/store-multi-search.vue +++ b/apps/web-antd/src/components/form/components/store-multi-search.vue @@ -14,6 +14,8 @@ import { watch, } from 'vue'; +import { useUserStore } from '@vben/stores'; + import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue'; import { useDebounceFn, useVModel } from '@vueuse/core'; import { Empty, Input, Spin, Tag } from 'ant-design-vue'; @@ -50,10 +52,49 @@ const props = withDefaults( }, ); +/** + * 模块级搜索结果缓存(所有实例共享):key = `${userId}|${storeType}|${keyword}` + * 门店数据变更低频,TTL 内相同关键词直接命中缓存零等待,避免每敲一个字都等接口转圈。 + * key 必须拼登录用户 id:不同账号数据权限不同(店管只能搜到本店可见门店), + * 切换账号后不允许在 TTL 内命中上一账号的结果,拼 id 后 key 天然隔离 + */ +const searchResultCache = new Map< + string, + { at: number; items: StoreSearchItem[] } +>(); +const SEARCH_CACHE_TTL = 5 * 60 * 1000; +const SEARCH_CACHE_MAX = 200; + +function readSearchCache(key: string): null | StoreSearchItem[] { + const hit = searchResultCache.get(key); + if (!hit) return null; + if (Date.now() - hit.at > SEARCH_CACHE_TTL) { + searchResultCache.delete(key); + return null; + } + return hit.items; +} + +function writeSearchCache(key: string, items: StoreSearchItem[]) { + // 简单容量控制:超限整体清空(低频触发,无需 LRU) + if (searchResultCache.size >= SEARCH_CACHE_MAX) { + searchResultCache.clear(); + } + searchResultCache.set(key, { at: Date.now(), items }); +} + const emits = defineEmits<{ 'update:value': [value: number[]]; }>(); +const userStore = useUserStore(); + +/** 当前登录用户 id(缓存隔离维度;后端返回的 admin 行主键是 id,兜底 vben 的 userId) */ +function currentUserId(): number | string { + const info = userStore.userInfo as null | Record; + return info?.id ?? info?.userId ?? 0; +} + const mValue = useVModel(props, 'value', emits, { passive: true, defaultValue: [], @@ -143,14 +184,36 @@ async function openPanel() { updatePanelPlacement(); } +/** 请求序号:只采纳最后一次请求的响应,避免快速输入时慢响应覆盖新结果 */ +let fetchSeq = 0; + async function fetchOptions() { const keyword = searchKeyword.value.trim(); if (!keyword) { + // 递增序号让在途请求作废,避免清空后慢响应又把旧结果写回来 + fetchSeq += 1; + loading.value = false; options.value = []; showDropdown.value = false; highlightIndex.value = -1; return; } + + const cacheKey = `${currentUserId()}|${props.storeType ?? 'all'}|${keyword}`; + const cachedItems = readSearchCache(cacheKey); + if (cachedItems) { + // 命中缓存:直接渲染,不发请求不转 loading(体感「秒出」) + fetchSeq += 1; + loading.value = false; + options.value = cachedItems; + await openPanel(); + highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1; + await nextTick(); + updatePanelPlacement(); + return; + } + + const seq = (fetchSeq += 1); loading.value = true; await openPanel(); try { @@ -161,15 +224,21 @@ async function fetchOptions() { : {}), limit: 20, }); - options.value = res?.items ?? []; + if (seq !== fetchSeq) return; + const items = res?.items ?? []; + writeSearchCache(cacheKey, items); + options.value = items; highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1; await nextTick(); updatePanelPlacement(); } catch { + if (seq !== fetchSeq) return; options.value = []; highlightIndex.value = -1; } finally { - loading.value = false; + if (seq === fetchSeq) { + loading.value = false; + } } } @@ -399,7 +468,13 @@ watch(visibleOptions, (list) => { color="blue" @close="() => removeSelected(item.id)" > - {{ item.name }}【{{ item.id }}】 + + + {{ item.name }}【{{ item.id }}】 + @@ -416,6 +491,26 @@ watch(visibleOptions, (list) => { flex-wrap: wrap; gap: 6px; margin-top: 8px; + + /* 单个超长门店名不能撑破容器:限宽 + 省略号,完整名走 title 悬停提示 */ + :deep(.ant-tag) { + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; + /* 间距交给 gap 控制,去掉 antd 自带右外边距避免换行时右侧不齐 */ + margin-inline-end: 0; + } + + :deep(.ant-tag-close-icon) { + flex-shrink: 0; + } +} + +.store-multi-search__tag-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/apps/web-antd/src/components/loading/PrescriptionLoading.vue b/apps/web-antd/src/components/loading/PrescriptionLoading.vue index 4996776f..232e3b06 100644 --- a/apps/web-antd/src/components/loading/PrescriptionLoading.vue +++ b/apps/web-antd/src/components/loading/PrescriptionLoading.vue @@ -666,12 +666,12 @@ onUnmounted(() => { width: 100%; height: 100%; min-height: 220px; - //background: #ffffff; + /* background: #ffffff; */ border-radius: 12px; overflow: hidden; } .loading-wrapper--dark { - //background: #1e2227; + /* background: #1e2227; */ } .loading-canvas { display: block; diff --git a/apps/web-antd/src/components/stat-islands/index.ts b/apps/web-antd/src/components/stat-islands/index.ts new file mode 100644 index 00000000..611ceb84 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/index.ts @@ -0,0 +1,5 @@ +/** + * 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛) + */ +export { default as StatIslands } from './stat-islands.vue'; +export type { StatIslandItem } from './typing'; diff --git a/apps/web-antd/src/components/stat-islands/stat-islands.vue b/apps/web-antd/src/components/stat-islands/stat-islands.vue new file mode 100644 index 00000000..6e765707 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/stat-islands.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/apps/web-antd/src/components/stat-islands/typing.ts b/apps/web-antd/src/components/stat-islands/typing.ts new file mode 100644 index 00000000..f32cc0b7 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/typing.ts @@ -0,0 +1,19 @@ +/** + * 统计岛组件类型定义(独立文件避免 index ↔ 组件循环引用) + */ + +/** 统计岛单项配置 */ +export interface StatIslandItem { + /** 唯一 key */ + key: string; + /** 指标名(小灰字) */ + label: string; + /** 指标值(数字,内部格式化两位小数) */ + value: number | string; + /** iconify 图标名(右上角,规范:SVG 图标不用 emoji) */ + icon: string; + /** 是否强调(主色字 + 更强荧光) */ + emphasize?: boolean; + /** 值前缀,默认 ¥;传空串则不显示前缀 */ + prefix?: string; +} diff --git a/apps/web-antd/src/components/table-action/card-actions.vue b/apps/web-antd/src/components/table-action/card-actions.vue new file mode 100644 index 00000000..f7193758 --- /dev/null +++ b/apps/web-antd/src/components/table-action/card-actions.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/apps/web-antd/src/components/table-action/index.ts b/apps/web-antd/src/components/table-action/index.ts index 00b7e6b6..39d9991a 100644 --- a/apps/web-antd/src/components/table-action/index.ts +++ b/apps/web-antd/src/components/table-action/index.ts @@ -1,2 +1,3 @@ +export { default as CardActions } from './card-actions.vue'; export { default as TableAction } from './table-action.vue'; export type * from './types.d.ts'; diff --git a/apps/web-antd/src/components/view-mode-switch/index.ts b/apps/web-antd/src/components/view-mode-switch/index.ts new file mode 100644 index 00000000..fe39309b --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/index.ts @@ -0,0 +1,3 @@ +export { useViewMode } from './use-view-mode'; +export type { ViewMode } from './use-view-mode'; +export { default as ViewModeSwitch } from './view-mode-switch.vue'; diff --git a/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts b/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts new file mode 100644 index 00000000..0ce529ef --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts @@ -0,0 +1,49 @@ +import type { Ref } from 'vue'; + +import { ref, watch } from 'vue'; + +/** + * 通用「列表/卡片」视图模式类型 + * list=表格列表视图,card=卡片看板视图 + */ +export type ViewMode = 'card' | 'list'; + +/** + * 视图模式记忆 Hook + * + * 为什么封装:商品订单/处方订单/挂号订单等多个列表页都需要「列表/卡片」切换, + * 且要求刷新后记住用户上次选择。统一在这里读写 localStorage, + * 各页面只需传入唯一 storageKey,避免每页复制一份存取逻辑。 + * + * @param storageKey localStorage 键名(建议 `<模块>-view-mode`,如 `product-order-view-mode`) + * @param defaultMode 无记忆时的默认视图,默认列表 + * @returns 响应式视图模式 ref,直接 v-model 给 ViewModeSwitch 组件即可 + */ +export function useViewMode( + storageKey: string, + defaultMode: ViewMode = 'list', +): Ref { + // 初始化时同步读取本地记忆,避免先渲染默认视图再闪切 + const readStored = (): ViewMode => { + try { + const raw = localStorage.getItem(storageKey); + if (raw === 'list' || raw === 'card') return raw; + } catch { + // localStorage 不可用(隐私模式等)时静默回退默认值 + } + return defaultMode; + }; + + const viewMode = ref(readStored()); + + // 切换后立即写回,下次进入页面直接还原 + watch(viewMode, (mode) => { + try { + localStorage.setItem(storageKey, mode); + } catch { + // 写入失败不影响功能,忽略 + } + }); + + return viewMode; +} diff --git a/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue b/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue new file mode 100644 index 00000000..4d32e1fb --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 64448027..835d7065 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -26,6 +26,7 @@ import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransf import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue'; import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue'; import { useAuthStore } from '#/store'; +import { desktop, isElectron } from '#/util/desktop'; import { formatTimeToRelative } from '#/util/tool'; import LoginForm from '#/views/_core/authentication/login.vue'; import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue'; @@ -62,6 +63,15 @@ const getNoticeList = async () => { description: `您有 ${items.length} 条未读消息请注意查收`, duration: 5, }); + // 桌面端且窗口未聚焦时补发系统通知(页内 Toast 此时用户看不到),点击跳消息中心 + if (isElectron() && !document.hasFocus()) { + desktop.notify({ + title: '您有新消息', + body: `您有 ${items.length} 条未读消息请注意查收`, + route: '/notice/list', + }); + desktop.flashFrame(); + } } notifications.value = items.map((item: any) => { const color = resolveNoticeColor(item.type_color); @@ -122,11 +132,35 @@ localStorage.setItem = (key, value) => { window.addEventListener('storage', handleStorageChange); const stopRefreshListen = onNoticeRefresh(() => restartInterval()); +// 桌面端:未读数变化同步任务栏/Dock 角标(浏览器环境 no-op) +watch(unreadCount, (count) => desktop.setBadge(count)); + +// 桌面端:系统通知点击后主进程回传目标路由,这里执行实际跳转 +desktop.onNavigate((route) => { + router.push(route); +}); + +// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责) +desktop.onUpdateEvent((payload) => { + if (payload.type === 'available') { + const version = (payload.data as { version?: string })?.version; + notification.info({ + message: '发现新版本', + description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`, + duration: 5, + }); + } +}); + onUnmounted(() => { clearInterval(intervalId); window.removeEventListener('storage', handleStorageChange); localStorage.setItem = originalSetItem; stopRefreshListen(); + // 布局卸载(登出)时清空桌面角标,避免残留未读数 + desktop.setBadge(0); + // 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push + desktop.offNavigate(); }); const goNoticeList = () => { diff --git a/apps/web-antd/src/types/desktop.d.ts b/apps/web-antd/src/types/desktop.d.ts new file mode 100644 index 00000000..351e422f --- /dev/null +++ b/apps/web-antd/src/types/desktop.d.ts @@ -0,0 +1,37 @@ +/** + * 桌面端(Electron)preload 注入的全局 API 类型声明 + * 形状与 apps/desktop/src/preload/index.ts 暴露的 desktopAPI 保持同步; + * 浏览器环境该对象不存在(可选属性),业务代码统一走 #/util/desktop 封装访问。 + */ +interface Window { + desktopAPI?: { + /** 桌面端标记,恒为 true */ + isElectron: true; + /** 当前平台:win32 / darwin / linux */ + platform: string; + getVersion: () => Promise; + notify: (options: { body: string; route?: string; title: string }) => Promise; + setBadge: (count: number) => Promise; + flashFrame: () => Promise; + getAutoLaunch: () => Promise; + setAutoLaunch: (enable: boolean) => Promise; + getPrinters: () => Promise< + Array<{ displayName: string; isDefault: boolean; name: string }> + >; + printHTML: (options: { + deviceName?: string; + html: string; + silent?: boolean; + }) => Promise<{ message?: string; ok: boolean }>; + getSettings: () => Promise<{ printerName: string; silentPrint: boolean }>; + setSettings: ( + patch: Partial<{ printerName: string; silentPrint: boolean }>, + ) => Promise<{ printerName: string; silentPrint: boolean }>; + checkUpdate: () => Promise; + focusWindow: () => Promise; + onNavigate: (callback: (route: string) => void) => void; + onUpdateEvent: ( + callback: (payload: { data?: unknown; type: string }) => void, + ) => void; + }; +} diff --git a/apps/web-antd/src/util/desktop.ts b/apps/web-antd/src/util/desktop.ts new file mode 100644 index 00000000..3d1de68f --- /dev/null +++ b/apps/web-antd/src/util/desktop.ts @@ -0,0 +1,122 @@ +/** + * 桌面端(Electron)能力封装 + * 桌面端由 apps/desktop 的 preload 脚本注入 window.desktopAPI; + * 浏览器环境没有该对象,本封装的所有方法自动 no-op 降级,保证网页端行为完全不变。 + * 业务代码禁止直接访问 window.desktopAPI,统一走本文件,便于类型收敛与降级兜底。 + */ + +/** 系统打印机信息(Electron getPrintersAsync 的子集) */ +export interface DesktopPrinter { + name: string; + displayName: string; + isDefault: boolean; +} + +/** 桌面端本地设置(与 apps/desktop 主进程 settings.ts 保持同步) */ +export interface DesktopSettings { + /** 静默打印使用的打印机名称,空串表示系统默认打印机 */ + printerName: string; + /** 是否启用静默打印 */ + silentPrint: boolean; +} + +/** 自动更新事件(主进程 updater 转发,渲染层做轻量提示) */ +export interface DesktopUpdateEvent { + type: 'available' | 'downloaded' | 'error' | 'not-available' | 'progress'; + data?: unknown; +} + +/** 判断当前是否运行在 Electron 桌面端 */ +export function isElectron(): boolean { + return typeof window !== 'undefined' && !!window.desktopAPI?.isElectron; +} + +/** 取桥接 API:浏览器环境返回 undefined,调用方全部可选链降级 */ +function api() { + return typeof window === 'undefined' ? undefined : window.desktopAPI; +} + +// onNavigate / onUpdateEvent 的桥接层每调用一次就会新增一个 ipc 监听, +// 而布局组件会随登录/登出反复挂载。这里用「模块级单次桥接 + 可替换回调」避免监听器堆积 +let navigateCallback: ((route: string) => void) | null = null; +let navigateBridged = false; +let updateCallback: ((payload: DesktopUpdateEvent) => void) | null = null; +let updateBridged = false; + +export const desktop = { + /** 发送系统通知;route 为点击通知后要跳转的前端路由(不传则仅唤起窗口) */ + notify(options: { body: string; route?: string; title: string }): void { + api()?.notify(options); + }, + /** 设置未读角标数量,0 表示清除(mac Dock 数字 / Windows 任务栏覆盖图标) */ + setBadge(count: number): void { + api()?.setBadge(count); + }, + /** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */ + flashFrame(): void { + api()?.flashFrame(); + }, + /** 读取开机自启状态(浏览器环境恒为 false) */ + async getAutoLaunch(): Promise { + return (await api()?.getAutoLaunch()) ?? false; + }, + /** 设置开机自启,返回设置后的实际状态 */ + async setAutoLaunch(enable: boolean): Promise { + return (await api()?.setAutoLaunch(enable)) ?? false; + }, + /** 获取系统打印机列表(浏览器环境返回空数组) */ + async getPrinters(): Promise { + return (await api()?.getPrinters()) ?? []; + }, + /** 静默打印:传入拼好的完整 HTML,由主进程隐藏窗口出纸 */ + async printHTML(options: { + deviceName?: string; + html: string; + silent?: boolean; + }): Promise<{ message?: string; ok: boolean }> { + return ( + (await api()?.printHTML(options)) ?? { + ok: false, + message: '当前环境不支持桌面打印', + } + ); + }, + /** 读取桌面端设置 */ + async getSettings(): Promise { + return (await api()?.getSettings()) ?? { printerName: '', silentPrint: false }; + }, + /** 增量更新桌面端设置,返回更新后的完整设置 */ + async setSettings(patch: Partial): Promise { + return ( + (await api()?.setSettings(patch)) ?? { printerName: '', silentPrint: false } + ); + }, + /** 手动检查更新(结果由主进程弹窗反馈) */ + checkUpdate(): void { + api()?.checkUpdate(); + }, + /** 唤起并聚焦主窗口 */ + focusWindow(): void { + api()?.focusWindow(); + }, + /** 监听系统通知点击后的路由跳转请求(重复调用只替换回调,不会堆积监听器) */ + onNavigate(callback: (route: string) => void): void { + navigateCallback = callback; + if (!navigateBridged && api()) { + navigateBridged = true; + api()!.onNavigate((route) => navigateCallback?.(route)); + } + }, + /** 解绑路由跳转回调:布局卸载(登出)时调用,避免残留系统通知被点击后对已登出会话执行跳转 */ + offNavigate(): void { + navigateCallback = null; + }, + /** 监听自动更新事件(重复调用只替换回调,不会堆积监听器) */ + onUpdateEvent(callback: (payload: DesktopUpdateEvent) => void): void { + updateCallback = callback; + if (!updateBridged && api()) { + updateBridged = true; + api()!.onUpdateEvent((payload) => updateCallback?.(payload as DesktopUpdateEvent)); + } + }, +}; diff --git a/apps/web-antd/src/utils/register-notify.ts b/apps/web-antd/src/utils/register-notify.ts new file mode 100644 index 00000000..de65a1fe --- /dev/null +++ b/apps/web-antd/src/utils/register-notify.ts @@ -0,0 +1,144 @@ +/** + * 挂号实时提醒工具(医生端) + * + * xk-api 在挂号支付成功后通过 Go WebSocket 推 message_type=15 的系统消息, + * useWebSocket 收到后调用 handleRegisterNotifyMessage 统一处理: + * - 桌面端(Electron)且窗口未聚焦:弹系统级消息通知 + 任务栏闪烁 + 播放提示音, + * 点击通知唤起窗口并跳转挂号订单页(走 basic.vue 已接线的 desktop.onNavigate) + * - 页面端(浏览器 / 桌面端窗口聚焦中):弹站内 antd notification + 播放提示音 + */ +import { notification } from 'ant-design-vue'; + +import { router } from '#/router'; +import { desktop, isElectron } from '#/util/desktop'; + +// 与 util/tool.ts 的 sys.mp3 同方式引入:经 Vite 处理,生产构建自动带部署 base 路径 +import RegisterNotifyMp3 from '/public/audio/register-notify.mp3'; + +/** 与后端 App\Enum\chat\MessageTypeEnum::REGISTER_NOTIFY 对齐 */ +export const REGISTER_NOTIFY_MESSAGE_TYPE = 15; + +/** 挂号订单列表菜单路由(xk_menu path),通知点击后的统一落点 */ +const REGISTER_ORDER_ROUTE = '/business/register/list'; + +/** 去重窗口:同一挂号单 10 秒内只提醒一次(防多标签页/重复投递叠加提示音) */ +const DEDUPE_WINDOW_MS = 10_000; +const DEDUPE_KEY_PREFIX = 'xk-register-notify:'; + +/** 后端 RegisterDoctorWsNotifyJob 拼装的通知载荷(message_content JSON) */ +interface RegisterNotifyPayload { + body: string; + order_no?: string; + register_id: number; + scene?: string; + title: string; +} + +/** 提示音单例:复用同一个 Audio 对象,避免每次通知都新建请求 */ +let audioInstance: HTMLAudioElement | null = null; + +/** + * 播放通知提示音 + * play() 返回的 Promise 在浏览器自动播放策略拒绝时会 reject(用户从未与页面交互过), + * 这里静默吞掉:医生登录后必然点击过页面,正常场景不会被拦 + */ +function playNotifySound() { + try { + if (!audioInstance) { + audioInstance = new Audio(RegisterNotifyMp3); + } + audioInstance.currentTime = 0; + audioInstance.play().catch(() => {}); + } catch { + // Audio 不可用(极老浏览器)时跳过声音,不影响通知展示 + } +} + +/** + * 解析 WS 消息里的通知载荷 + * message_content 是后端 json_encode 的字符串;解析失败返回 null 直接忽略该条 + */ +function parsePayload(raw: unknown): null | RegisterNotifyPayload { + try { + const data = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!data || typeof data !== 'object') return null; + const record = data as Record; + const registerId = Number(record.register_id); + if (!Number.isFinite(registerId) || registerId <= 0) return null; + return { + register_id: registerId, + title: String(record.title || '新的挂号订单'), + body: String(record.body || ''), + order_no: String(record.order_no || ''), + scene: String(record.scene || ''), + }; + } catch { + return null; + } +} + +/** + * 同一挂号单去重:localStorage 记录上次提醒时间戳 + * 医生开多个标签/窗口时每个连接都会收到推送(Go 服务发给用户所有在线设备), + * 不去重会同时响多次提示音;localStorage 跨标签共享,天然适合做这个锁 + */ +function shouldNotify(registerId: number): boolean { + const key = DEDUPE_KEY_PREFIX + registerId; + try { + const last = Number(localStorage.getItem(key) || 0); + const now = Date.now(); + if (now - last < DEDUPE_WINDOW_MS) return false; + localStorage.setItem(key, String(now)); + return true; + } catch { + // localStorage 不可用(隐私模式)时放行,宁可重复提醒不可漏提醒 + return true; + } +} + +/** + * 弹站内通知卡片 + * key 用挂号单 ID 保证同单只显示一张卡片;点击卡片跳转挂号订单页并收起。 + * 页面在后台标签时不自动关闭(duration=0),医生切回来仍能看到提醒 + */ +function showInPageNotification(payload: RegisterNotifyPayload) { + const key = `register-notify-${payload.register_id}`; + notification.open({ + key, + message: payload.title, + description: payload.body || payload.order_no, + duration: document.hidden ? 0 : 8, + onClick: () => { + router.push(REGISTER_ORDER_ROUTE).catch(() => {}); + notification.close(key); + }, + }); +} + +/** + * 挂号提醒统一入口(useWebSocket 收到 message_type=15 时调用) + * 流程:解析载荷 → 跨标签去重 → 播放提示音 → 按运行环境选择通知形态 + */ +export function handleRegisterNotifyMessage(data: { + message_content?: unknown; +}) { + const payload = parsePayload(data?.message_content); + if (!payload || !shouldNotify(payload.register_id)) return; + + playNotifySound(); + + // 桌面端窗口未聚焦:系统通知 + 任务栏闪烁(页内卡片此时用户看不到), + // 点击通知由主进程唤起窗口并回传路由(basic.vue 的 desktop.onNavigate 执行跳转) + if (isElectron() && !document.hasFocus()) { + desktop.notify({ + title: payload.title, + body: payload.body || payload.order_no || '', + route: REGISTER_ORDER_ROUTE, + }); + desktop.flashFrame(); + return; + } + + // 浏览器端 / 桌面端窗口聚焦中:站内提示 + showInPageNotification(payload); +} diff --git a/apps/web-antd/src/utils/tagColor.ts b/apps/web-antd/src/utils/tagColor.ts new file mode 100644 index 00000000..7418d954 --- /dev/null +++ b/apps/web-antd/src/utils/tagColor.ts @@ -0,0 +1,41 @@ +/** + * antd Tag 预设色名 → css 色值工具 + * + * 为什么需要:Apple 风格卡片用「状态彩点 + tinted 胶囊」替代 antd Tag 色块, + * 但字典常量里沿用 antd 色名(red/green/processing…),这里统一转换成真实色值, + * 供订单三页卡片视图与详情弹窗共用,避免各组件复制色板。 + */ +export const TAG_COLOR_HEX: Record = { + red: '#f5222d', + orange: '#fa8c16', + gold: '#faad14', + yellow: '#d4b106', + green: '#52c41a', + cyan: '#13c2c2', + blue: '#1677ff', + geekblue: '#2f54eb', + purple: '#722ed1', + pink: '#eb2f96', + magenta: '#eb2f96', + processing: '#1677ff', + success: '#52c41a', + warning: '#fa8c16', + error: '#f5222d', + gray: '#8c8c8c', + default: '#8c8c8c', +}; + +/** 未知色名兜底灰(单独常量,规避 Record 索引返回 undefined 的类型收窄问题) */ +const DEFAULT_TAG_HEX = '#8c8c8c'; + +/** Tag 色名转 css 色值;自定义 hex 原样返回,未知色名回退灰 */ +export function tagColorHex(color?: string): string { + if (!color) return DEFAULT_TAG_HEX; + return color.startsWith('#') ? color : (TAG_COLOR_HEX[color] ?? DEFAULT_TAG_HEX); +} + +/** Apple tinted 胶囊内联样式:同色 10% 透明度底 + 同色文字(8 位 hex alpha) */ +export function pillStyle(color?: string) { + const hex = tagColorHex(color); + return { color: hex, background: `${hex}1a` }; +} diff --git a/apps/web-antd/src/views/business/chat/components/SettingsModal.vue b/apps/web-antd/src/views/business/chat/components/SettingsModal.vue index 4deb755a..3366ac88 100644 --- a/apps/web-antd/src/views/business/chat/components/SettingsModal.vue +++ b/apps/web-antd/src/views/business/chat/components/SettingsModal.vue @@ -235,6 +235,7 @@ + + + + diff --git a/apps/web-antd/src/views/business/order/prescription/config/constants.ts b/apps/web-antd/src/views/business/order/prescription/config/constants.ts new file mode 100644 index 00000000..b79764e7 --- /dev/null +++ b/apps/web-antd/src/views/business/order/prescription/config/constants.ts @@ -0,0 +1,54 @@ +/** + * 处方订单模块字典常量 + * 列表 Tag 槽与卡片视图共用同一份映射,避免两处渲染语义漂移 + */ +export interface TagMeta { + color: string; + label: string; +} + +/** 处方类型映射(与列表 #status 槽第一行渲染语义一致) */ +export const PRESCRIPTION_ORDER_TYPE_MAP: Record = { + 1: { label: '中药', color: 'orange' }, + 2: { label: '商品【西药】', color: 'blue' }, + 3: { label: '商品【保健食品】', color: 'purple' }, + 4: { label: '已退款', color: 'green' }, + 5: { label: '产品服务包', color: 'pink' }, + 6: { label: '非药品', color: 'cyan' }, + 7: { label: '医疗器械', color: 'geekblue' }, +}; + +/** 审核状态映射(status:0待审核 1已通过 2已拒绝 3/4无需审核) */ +export const PRESCRIPTION_STATUS_MAP: Record = { + 0: { label: '待审核', color: 'red' }, + 1: { label: '已通过', color: 'green' }, + 2: { label: '已拒绝', color: 'red' }, + 3: { label: '无需审核', color: 'purple' }, + 4: { label: '无需审核', color: 'green' }, +}; + +/** + * 处方来源映射 + * is_online 由挂号 type 经 RegisterTypeEnum::getIsOnlineByType 映射: + * 1=在线问诊 2=诊所在线复诊 3=药店在线复诊 0=线下 + * (老页面漏了 3,药店复诊被误显示为线下,这里补全) + */ +export const PRESCRIPTION_IS_ONLINE_MAP: Record = { + 1: { label: '在线问诊', color: 'blue' }, + 2: { label: '在线复诊(诊所)', color: 'green' }, + 3: { label: '在线复诊(药店)', color: 'green' }, +}; + +/** 来源兜底:线下就诊 */ +export const PRESCRIPTION_IS_ONLINE_FALLBACK: TagMeta = { + label: '线下就诊', + color: 'default', +}; + +/** 搜索下拉:处方来源选项 */ +export const PRESCRIPTION_IS_ONLINE_OPTIONS = [ + { label: '在线问诊', value: 1 }, + { label: '在线复诊(诊所)', value: 2 }, + { label: '在线复诊(药店)', value: 3 }, + { label: '线下就诊', value: 0 }, +]; diff --git a/apps/web-antd/src/views/business/order/prescription/config/search.ts b/apps/web-antd/src/views/business/order/prescription/config/search.ts index 67af6e30..fb571765 100644 --- a/apps/web-antd/src/views/business/order/prescription/config/search.ts +++ b/apps/web-antd/src/views/business/order/prescription/config/search.ts @@ -3,13 +3,42 @@ import type { VbenFormProps } from '#/adapter/form'; import dayjs from 'dayjs'; import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api'; +import { PRESCRIPTION_IS_ONLINE_OPTIONS } from './constants'; + export const formOptions: VbenFormProps = { - // 默认展开 - collapsed: false, + // 默认收起只显示第一行,点「展开」看全部筛选(占位最小化) + collapsed: true, + commonConfig: { + // label 默认 100px,本页筛选名最长 4 字,收窄到 70px 让控件占满格子(否则控件被挤得只剩一半宽) + labelWidth: 70, + // antd Select/RangePicker 默认按内容收缩,统一撑满格子,保证一行里各控件等宽整齐 + componentProps: { + class: 'w-full', + }, + }, + // 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格 + wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5', schema: [ { - component: 'VbenInput', + // 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据 + component: 'RangePicker', componentProps: { + format: 'YYYY-MM-DD', + // 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59 + valueFormat: 'YYYY-MM-DD', + }, + defaultValue: [ + dayjs().startOf('month').format('YYYY-MM-DD'), + dayjs().format('YYYY-MM-DD'), + ], + fieldName: 'search_time', + label: '时间范围', + }, + { + // 统一 antd 系控件:与 ApiSelect 同一套视觉(此前 VbenInput 是 shadcn 风格,高度圆角不一致) + component: 'Input', + componentProps: { + allowClear: true, placeholder: '输入订单号', }, defaultValue: '', @@ -36,18 +65,35 @@ export const formOptions: VbenFormProps = { label: '订单类型', }, { - component: 'RangePicker', + // 就诊人姓名/手机号模糊(后端 PersonKeywordScopeService,whereHas 关联表) + component: 'Input', componentProps: { - format: 'YYYY-MM-DD', - // 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59 - valueFormat: 'YYYY-MM-DD', + allowClear: true, + placeholder: '姓名/手机号', }, - defaultValue: [ - dayjs().startOf('month').format('YYYY-MM-DD'), - dayjs().format('YYYY-MM-DD'), - ], - fieldName: 'search_time', - label: '时间范围', + fieldName: 'patient_keyword', + label: '就诊人', + }, + { + // 医生姓名/手机号模糊(后端 PersonKeywordScopeService) + component: 'Input', + componentProps: { + allowClear: true, + placeholder: '姓名/手机号', + }, + fieldName: 'doctor_keyword', + label: '医生', + }, + { + // 处方来源(后端 queryField 等值匹配;选项与 constants 映射同语义) + component: 'Select', + componentProps: { + allowClear: true, + options: PRESCRIPTION_IS_ONLINE_OPTIONS, + placeholder: '请选择', + }, + fieldName: 'is_online', + label: '处方来源', }, ], // 控制表单是否显示折叠按钮 @@ -55,9 +101,7 @@ export const formOptions: VbenFormProps = { submitButtonOptions: { content: '查询', }, - // 是否在字段值改变时提交表单 - // submitOnChange: true, - // submitOnChange: true, // 按下回车时是否提交表单 - submitOnEnter: false, + // 任意输入框回车即搜索;下拉面板打开时的回车只选中选项(core 层已处理),第二次回车才搜索 + submitOnEnter: true, }; diff --git a/apps/web-antd/src/views/business/order/prescription/config/table.ts b/apps/web-antd/src/views/business/order/prescription/config/table.ts index ee9c4743..a9a2df25 100644 --- a/apps/web-antd/src/views/business/order/prescription/config/table.ts +++ b/apps/web-antd/src/views/business/order/prescription/config/table.ts @@ -2,12 +2,18 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; import { getPrescriptionListApi } from '../api'; +/** + * 处方列表行类型:后端行字段众多(含关联 store/doctor_info/user_patient), + * 用索引签名放宽,避免模板槽访问动态字段时 vue-tsc 报属性缺失 + */ interface RowType { - id: string; - name: string; - logo: string; - introduce: string; + id: number; + prescription_no: string; + prescription_type: number; + status: number; + is_online: number; created_at: string; + [key: string]: any; } export const gridOptions: VxeGridProps = { diff --git a/apps/web-antd/src/views/business/order/prescription/index.vue b/apps/web-antd/src/views/business/order/prescription/index.vue index 23ac1084..b4ba5491 100644 --- a/apps/web-antd/src/views/business/order/prescription/index.vue +++ b/apps/web-antd/src/views/business/order/prescription/index.vue @@ -1,19 +1,36 @@ + + + + + + +
+ +
+ + + +
diff --git a/apps/web-antd/src/views/dashboard/calendar/components/CalendarPanel.vue b/apps/web-antd/src/views/dashboard/calendar/components/CalendarPanel.vue new file mode 100644 index 00000000..2d61e1d5 --- /dev/null +++ b/apps/web-antd/src/views/dashboard/calendar/components/CalendarPanel.vue @@ -0,0 +1,1661 @@ + + + diff --git a/apps/web-antd/src/views/dashboard/calendar/components/day-todos-modal.vue b/apps/web-antd/src/views/dashboard/calendar/components/day-todos-modal.vue deleted file mode 100644 index c90a5483..00000000 --- a/apps/web-antd/src/views/dashboard/calendar/components/day-todos-modal.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - diff --git a/apps/web-antd/src/views/dashboard/calendar/index.vue b/apps/web-antd/src/views/dashboard/calendar/index.vue index 42aeea03..065c8062 100644 --- a/apps/web-antd/src/views/dashboard/calendar/index.vue +++ b/apps/web-antd/src/views/dashboard/calendar/index.vue @@ -1,597 +1,17 @@ - diff --git a/apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts b/apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts index 8bed9b1c..9267016b 100644 --- a/apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts +++ b/apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts @@ -3,31 +3,82 @@ import { HolidayUtil, Solar } from 'lunar-typescript'; /** * 黄历信息计算工具(基于 lunar-typescript) * 设计说明: - * - 中国传统节日(春节/端午/中秋等)由农历推算,国际节日(元旦/圣诞/母亲节等)按公历, - * 正好满足「中国节日算农历、其他节日按公历」的需求 + * - 中国传统节日(春节/端午/中秋等)由农历推算,公历节日(元旦/国庆等)按公历 + * - 农历文案与节日、节气三者并列返回互不覆盖:格子里农历始终显示,节日/节气展示在副行 + * - 公历节日走白名单只保留中国也在过的,纯国外节日暂时注释掉(见 SOLAR_FESTIVAL_WHITELIST) * - 法定节假日调休(休/班)数据由 HolidayUtil 内置,覆盖国务院历年放假安排 * - 按「y-m-d」缓存计算结果:日历一屏 42 格、频繁切月,避免重复实例化 Solar/Lunar */ -/** 单日黄历信息 */ +/** 节日条目:kind 用于差异化配色(传统农历节日红、公历现代节日橙),产品要求节日颜色不能都一样 */ +export interface FestivalItem { + kind: 'modern' | 'traditional'; + name: string; +} + +/** 单日黄历信息(日历格子用) */ export interface DayAlmanac { - /** 格子副标题:优先级 节日 > 节气 > 初一显示月名 > 农历日 */ - displayText: string; - /** 副标题类型,用于差异化配色:festival节日 / jieqi节气 / lunar农历 */ - displayType: 'festival' | 'jieqi' | 'lunar'; + /** 农历文案:初一显示月名(如「七月」),其余显示农历日(如「三十」),始终展示、不被节日覆盖 */ + lunarText: string; + /** 当天节气(无则空串),与节日并列展示互不覆盖 */ + jieQi: string; + /** 过滤后的节日列表:农历传统节日在前、公历现代节日在后,格子展示第一个、悬浮提示展示全部 */ + festivalItems: FestivalItem[]; /** 完整农历日期,如「正月初一」,悬浮提示用 */ lunarDate: string; - /** 当天节气(无则空串) */ - jieQi: string; - /** 当天全部节日(农历节日 + 公历/国际节日),悬浮提示用 */ - festivals: string[]; /** 法定节假日安排:null=正常工作日/周末,否则含名称与是否补班 */ holiday: null | { isWork: boolean; name: string }; } +/** 单日黄历详情(侧栏「选中日」卡片用,信息比格子更全) */ +export interface DayAlmanacDetail { + /** 星期文本,如「星期三」 */ + week: string; + /** 完整农历日期,如「六月廿九」 */ + lunarDate: string; + /** 干支纪年月日,如「丙午年 丙申月 庚辰日」 */ + ganZhi: string; + /** 生肖,如「马」 */ + shengXiao: string; + /** 黄历宜(民俗参考,侧栏氛围信息) */ + yi: string[]; + /** 黄历忌 */ + ji: string[]; +} + +/** + * 公历节日白名单:只保留中国也在过的节日(法定/官方节日 + 已本土化的舶来节日) + * 产品要求暂时屏蔽国内基本不过的国外节日,注释保留在这里,后续要恢复解开对应注释即可 + */ +const SOLAR_FESTIVAL_WHITELIST = new Set([ + '元旦节', + '情人节', + '妇女节', + '植树节', + '消费者权益日', + '劳动节', + '青年节', + '儿童节', + '建党节', + '建军节', + '教师节', + '国庆节', + '母亲节', + '父亲节', + '平安夜', + '圣诞节', + // '愚人节', + // '万圣节前夜', + // '万圣节', + // '感恩节', +]); + /** 计算结果缓存:跨月切换时同一天不重复计算 */ const cache = new Map(); +/** 详情缓存与格子缓存分开:详情仅选中日才算,字段更多没必要提前算 42 格 */ +const detailCache = new Map(); + /** * 获取某个公历日期的黄历信息(农历/节气/节日/法定调休) * @param year 公历年 @@ -42,38 +93,57 @@ export function getDayAlmanac(year: number, month: number, day: number): DayAlma const solar = Solar.fromYmd(year, month, day); const lunar = solar.getLunar(); - // 节日合并:农历节日在前(春节等中国节日优先展示),其后公历节日与国际纪念日 - const festivals = [ - ...lunar.getFestivals(), - ...solar.getFestivals(), - ...lunar.getOtherFestivals(), - ...solar.getOtherFestivals(), + // 节日组装:农历传统节日(含除夕/下元节等民俗,都是中国节日全保留)在前, + // 公历节日过白名单在后;solar.getOtherFestivals() 是「世界XX日」类国际纪念日,国内基本不过,整体不展示 + const festivalItems: FestivalItem[] = [ + ...[...lunar.getFestivals(), ...lunar.getOtherFestivals()].map((name) => ({ + kind: 'traditional' as const, + name, + })), + ...solar + .getFestivals() + .filter((name) => SOLAR_FESTIVAL_WHITELIST.has(name)) + .map((name) => ({ kind: 'modern' as const, name })), ]; - const jieQi = lunar.getJieQi(); - // 初一显示月名(如「二月」),其余显示农历日(如「十五」) - const lunarDay = - lunar.getDay() === 1 ? `${lunar.getMonthInChinese()}月` : lunar.getDayInChinese(); - // 副标题择优:节日 > 节气 > 农历日,格子空间有限只显示一条 - let displayText = lunarDay; - let displayType: DayAlmanac['displayType'] = 'lunar'; - if (festivals.length > 0) { - displayText = festivals[0] as string; - displayType = 'festival'; - } else if (jieQi) { - displayText = jieQi; - displayType = 'jieqi'; - } + // 初一显示月名(如「二月」),其余显示农历日(如「十五」);农历始终展示,不再被节日/节气覆盖 + const lunarText = + lunar.getDay() === 1 ? `${lunar.getMonthInChinese()}月` : lunar.getDayInChinese(); const h = HolidayUtil.getHoliday(year, month, day); const result: DayAlmanac = { - displayText, - displayType, + lunarText, + jieQi: lunar.getJieQi(), + festivalItems, lunarDate: `${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`, - jieQi, - festivals, holiday: h ? { isWork: h.isWork(), name: h.getName() } : null, }; cache.set(key, result); return result; } + +/** + * 获取某个公历日期的黄历详情(星期/完整农历/干支/生肖/宜忌) + * 供侧栏「选中日」卡片展示,走独立缓存避免与格子计算互相拖累 + * @param year 公历年 + * @param month 公历月(1-12) + * @param day 公历日 + */ +export function getDayAlmanacDetail(year: number, month: number, day: number): DayAlmanacDetail { + const key = `${year}-${month}-${day}`; + const cached = detailCache.get(key); + if (cached) return cached; + + const solar = Solar.fromYmd(year, month, day); + const lunar = solar.getLunar(); + const result: DayAlmanacDetail = { + week: `星期${solar.getWeekInChinese()}`, + lunarDate: `${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`, + ganZhi: `${lunar.getYearInGanZhi()}年 ${lunar.getMonthInGanZhi()}月 ${lunar.getDayInGanZhi()}日`, + shengXiao: lunar.getYearShengXiao(), + yi: lunar.getDayYi(), + ji: lunar.getDayJi(), + }; + detailCache.set(key, result); + return result; +} diff --git a/apps/web-antd/src/views/dashboard/workspace/api/index.ts b/apps/web-antd/src/views/dashboard/workspace/api/index.ts index bb1191ef..a43c303c 100644 --- a/apps/web-antd/src/views/dashboard/workspace/api/index.ts +++ b/apps/web-antd/src/views/dashboard/workspace/api/index.ts @@ -61,6 +61,45 @@ export type TodoCenterResult = { items: TodoCenterItem[]; }; +/** 未来7天预约:单日分布 */ +export type AppointmentUpcomingDay = { + date: string; + label: string; + week: string; + count: number; +}; + +/** 未来7天预约统计(卡片) */ +export type AppointmentUpcomingResult = { + total: number; + days: AppointmentUpcomingDay[]; +}; + +/** 未来7天预约明细行 */ +export type AppointmentUpcomingItem = { + register_id: number; + order_no: string; + visit_date: string; + week: string; + slot_name: string; + start_time: string; + end_time: string; + slot_label: string; + patient_name: string; + doctor_name: string; + store_name: string; + is_pay: number; + price: string; + status: number; + status_text: string; +}; + +/** 未来7天预约明细分页结果 */ +export type AppointmentUpcomingListResult = { + items: AppointmentUpcomingItem[]; + total: number; +}; + /** * 获取当前登录角色的工作台布局 */ @@ -115,3 +154,26 @@ export async function getPlatformOverviewApi() { export async function getTodoCenterApi() { return requestClient.get(`${prefix}todo-center`); } + +/** + * 未来7天预约号统计(卡片:总数 + 按天分布,scope 后端按登录身份判定) + */ +export async function getAppointmentUpcomingApi() { + return requestClient.get( + `${prefix}appointment-upcoming`, + ); +} + +/** + * 未来7天预约明细(卡片点击弹窗:分页 + 日期筛选) + */ +export async function getAppointmentUpcomingListApi(data: { + page: number; + page_size: number; + visit_date?: string; +}) { + return requestClient.get( + `${prefix}appointment-upcoming-list`, + { params: data }, + ); +} diff --git a/apps/web-antd/src/views/dashboard/workspace/components/NoticeTicker.vue b/apps/web-antd/src/views/dashboard/workspace/components/NoticeTicker.vue new file mode 100644 index 00000000..fa62a432 --- /dev/null +++ b/apps/web-antd/src/views/dashboard/workspace/components/NoticeTicker.vue @@ -0,0 +1,455 @@ + + + + + diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAdminHub.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAdminHub.vue index 097c4967..7e458c42 100644 --- a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAdminHub.vue +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAdminHub.vue @@ -181,6 +181,9 @@ onMounted(fetchAll); /> + + + diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAppointmentUpcoming.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAppointmentUpcoming.vue new file mode 100644 index 00000000..bf2f5c97 --- /dev/null +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAppointmentUpcoming.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetCalendarTodo.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetCalendarTodo.vue new file mode 100644 index 00000000..a0b6a0f0 --- /dev/null +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetCalendarTodo.vue @@ -0,0 +1,386 @@ + + + diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetNotice.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetNotice.vue index 7181e74c..037f58cb 100644 --- a/apps/web-antd/src/views/dashboard/workspace/components/WidgetNotice.vue +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetNotice.vue @@ -1,5 +1,8 @@ + + diff --git a/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts b/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts index aaad31b0..534a28a1 100644 --- a/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts +++ b/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts @@ -1,22 +1,27 @@ import type { Component } from 'vue'; +import NoticeTicker from '../components/NoticeTicker.vue'; +import WidgetAppointmentUpcoming from '../components/WidgetAppointmentUpcoming.vue'; +import WidgetCalendarTodo from '../components/WidgetCalendarTodo.vue'; import WidgetClinicToday from '../components/WidgetClinicToday.vue'; import WidgetDoctorToday from '../components/WidgetDoctorToday.vue'; -import WidgetNotice from '../components/WidgetNotice.vue'; import WidgetOrderPending from '../components/WidgetOrderPending.vue'; import WidgetPharmacistPending from '../components/WidgetPharmacistPending.vue'; import WidgetPlatformOverview from '../components/WidgetPlatformOverview.vue'; import WidgetQuickEntry from '../components/WidgetQuickEntry.vue'; import WidgetSalespersonSummary from '../components/WidgetSalespersonSummary.vue'; import WidgetTodoCenter from '../components/WidgetTodoCenter.vue'; +import WidgetWorkCalendar from '../components/WidgetWorkCalendar.vue'; /** * widget_code → 组件映射(与后端 WorkbenchWidgetEnum 对齐) * 后端 layout 下发未知 code 时前端直接跳过,保证两端可独立灰度上线 + * notice 已由列表卡(WidgetNotice,弃用保留)换成单行轮播条 NoticeTicker, + * 实际渲染位置由 index.vue 固定插槽控制(驾驶舱卡底内嵌 / 无驾驶舱独立一行) */ export const WIDGET_COMPONENT_MAP: Record = { quick_nav: WidgetQuickEntry, - notice: WidgetNotice, + notice: NoticeTicker, doctor_today: WidgetDoctorToday, pharmacist_pending: WidgetPharmacistPending, salesperson_summary: WidgetSalespersonSummary, @@ -24,6 +29,10 @@ export const WIDGET_COMPONENT_MAP: Record = { order_pending: WidgetOrderPending, platform_overview: WidgetPlatformOverview, todo_center: WidgetTodoCenter, + appointment_upcoming: WidgetAppointmentUpcoming, + // 日程待办(快捷入口行右侧)与日程日历(快捷入口行下方全宽),布局位置固定不进 KPI 区 + calendar_todo: WidgetCalendarTodo, + work_calendar: WidgetWorkCalendar, }; /** KPI 类模块(渲染在中部 KPI 区,其余按固定位置渲染) */ @@ -35,4 +44,5 @@ export const KPI_WIDGET_CODES = new Set([ 'order_pending', 'platform_overview', 'todo_center', + 'appointment_upcoming', ]); diff --git a/apps/web-antd/src/views/dashboard/workspace/index.vue b/apps/web-antd/src/views/dashboard/workspace/index.vue index 6cfe2d7f..8c5321cc 100644 --- a/apps/web-antd/src/views/dashboard/workspace/index.vue +++ b/apps/web-antd/src/views/dashboard/workspace/index.vue @@ -1,9 +1,11 @@ + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/StoreSettingModal.vue b/apps/web-antd/src/views/doctor/doctor/components/StoreSettingModal.vue new file mode 100644 index 00000000..c5d7ca8f --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/StoreSettingModal.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/AddDoctorScheduleModal.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/AddDoctorScheduleModal.vue new file mode 100644 index 00000000..9346ad4b --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/AddDoctorScheduleModal.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue new file mode 100644 index 00000000..849aa0eb --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleContextMenu.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleContextMenu.vue new file mode 100644 index 00000000..6f34bc55 --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleContextMenu.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleMatrixView.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleMatrixView.vue new file mode 100644 index 00000000..e75c9e09 --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ScheduleMatrixView.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SchedulePanel.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SchedulePanel.vue new file mode 100644 index 00000000..0f7b3bc5 --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SchedulePanel.vue @@ -0,0 +1,1760 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ShiftTemplateModal.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ShiftTemplateModal.vue new file mode 100644 index 00000000..af0f994d --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/ShiftTemplateModal.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SlotRowsEditor.vue b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SlotRowsEditor.vue new file mode 100644 index 00000000..d9d2e3f9 --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor/components/schedule-calendar/SlotRowsEditor.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor/index.vue b/apps/web-antd/src/views/doctor/doctor/index.vue index 030c2bfc..b3db9fef 100644 --- a/apps/web-antd/src/views/doctor/doctor/index.vue +++ b/apps/web-antd/src/views/doctor/doctor/index.vue @@ -3,7 +3,7 @@ import type { VxeGridListeners } from '#/adapter/vxe-table'; import { ref } from 'vue'; -import { Page, useVbenModal } from '@vben/common-ui'; +import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui'; import { Button, message, Tag } from 'ant-design-vue'; @@ -19,6 +19,9 @@ import DoctorBasicInfoCell from './components/cells/DoctorBasicInfoCell.vue'; import DoctorProfessionalInfoCell from './components/cells/DoctorProfessionalInfoCell.vue'; import FormModalDemo from './components/modal.vue'; import DoctorCardModal from './components/DoctorCardModal.vue'; +import ScheduleCalendarModal from './components/schedule-calendar/ScheduleCalendarModal.vue'; +import ScheduleDrawerComp from './components/ScheduleDrawer.vue'; +import StoreSettingModalComp from './components/StoreSettingModal.vue'; import { formOptions } from './config/search'; import { gridOptions } from './config/table'; @@ -67,6 +70,16 @@ const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({ connectedComponent: DoctorCardModal, }); +/** 排班设置抽屉(真排班:每天/按星期 × 全天/上下午/自定义时段) */ +const [ScheduleDrawer, ScheduleDrawerApi] = useVbenDrawer({ + connectedComponent: ScheduleDrawerComp, +}); + +/** 门店统一可预约天数设置弹窗 */ +const [StoreSettingModal, StoreSettingModalApi] = useVbenModal({ + connectedComponent: StoreSettingModalComp, +}); + const showModal = (data = {}, isUpdate = false) => { formModalApi.setData({ values: data, @@ -121,6 +134,43 @@ const showDoctorCard = (row: Record) => { DoctorCardModalApi.open(); }; +/** 打开排班设置抽屉 */ +const showScheduleDrawer = (row: Record) => { + ScheduleDrawerApi.setData({ record: row }); + ScheduleDrawerApi.open(); +}; + +/** 排班日历弹窗(按日期排班,锁定该医生) */ +const scheduleCalendarModalRef = ref>(); + +/** + * 打开排班日历(锁定医生模式) + * 店上下文取医生关联门店(列表行已带 stores):单店直接进,多店弹窗顶部可切换; + * 未关联门店时排班无模板归属也无患者入口,直接提示先绑定诊所 + */ +const showScheduleCalendar = (row: Record) => { + const stores = (Array.isArray(row.stores) ? row.stores : []) + .filter((s: any) => s?.store?.id) + .map((s: any) => ({ id: Number(s.store.id), name: String(s.store.name || '') })); + if (stores.length === 0) { + message.warning('该医生未关联任何诊所,请先绑定诊所后再排班'); + return; + } + scheduleCalendarModalRef.value?.ModalApi?.setData({ + storeId: stores[0]!.id, + storeName: stores[0]!.name, + storeOptions: stores, + lockedDoctorId: Number(row.su_id || 0), + lockedDoctorName: String(row.name || ''), + }); + scheduleCalendarModalRef.value?.ModalApi?.open(); +}; + +/** 打开门店统一设置弹窗 */ +const showStoreSettingModal = () => { + StoreSettingModalApi.open(); +}; + const openPcWindows = (id: number) => { openPcWindowsApi(id).then(() => { message.success('开通成功!'); @@ -151,6 +201,9 @@ const refuse = (id: number) => { + + +