feat: 订单双视图、工作台日历组件、医生排班管理与挂号语音提醒

- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典
- 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel
- 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗
- 日志与通知:新增排班变更日志页、排班变更通知视图
- 挂号提醒:新增挂号语音播报资源与 register-notify 工具
- 桌面端:新增 apps/desktop 壳及 desktop 工具方法
- 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
This commit is contained in:
李琦
2026-08-14 17:46:50 +08:00
parent f15bf10553
commit beba5e8feb
105 changed files with 16261 additions and 1253 deletions

View File

@@ -0,0 +1,108 @@
import { existsSync, statSync } from 'node:fs';
import { join, normalize } from 'node:path';
import { pathToFileURL } from 'node:url';
import { app, net, protocol } from 'electron';
import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config';
/**
* app:// 自定义协议:桌面端的「本地 nginx」
* 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback
* (等价 nginx 的 try_files $uri /index.html因此无需把前端路由改成 hash 模式
* 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 ——
* 渲染层看到的是同源请求,天然规避 CORS后端与 nginx 都不需要任何改动
*/
/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */
type ElectronFetchInit = RequestInit & {
/** 转发出去的请求不再进入自定义协议处理器,防止死循环 */
bypassCustomProtocolHandlers?: boolean;
/** 携带流式 body 时 fetch 规范要求显式声明半双工 */
duplex?: 'half';
};
/**
* 注册特权协议,必须在 app ready 之前调用
* standard/secure 让 app:// 成为「安全上下文」navigator.clipboard、getUserMedia、
* IndexedDB、crypto.subtle 等浏览器能力才可用stream 支持流式响应(音频等大文件)
*/
export function registerSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
corsEnabled: true,
},
},
]);
}
/**
* 解析 web dist 根目录
* 打包后位于 resources/webelectron-builder extraResources 拷入,不进 asar
* 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist
*/
export function getWebRoot(): string {
return app.isPackaged
? join(process.resourcesPath, 'web')
: join(__dirname, '../../../web-antd/dist');
}
/**
* 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass
* 保留原请求的方法 / 头 / 流式请求体Bearer token 请求头原样透传
*/
function proxyRequest(target: string, request: Request): Promise<Response> {
const init: ElectronFetchInit = {
method: request.method,
headers: request.headers,
body: request.body,
bypassCustomProtocolHandlers: true,
};
if (request.body) init.duplex = 'half';
return net.fetch(target, init);
}
/**
* 静态文件解析:带路径穿越防护 + SPA fallback
* 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由
*/
function resolveStaticFile(webRoot: string, pathname: string): string {
const indexHtml = join(webRoot, 'index.html');
try {
const filePath = normalize(join(webRoot, decodeURIComponent(pathname)));
// 防路径穿越:解析结果必须仍在 webRoot 目录内
if (!filePath.startsWith(normalize(webRoot))) return indexHtml;
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath;
} catch {
// decode 失败等异常场景直接回 index.html不让协议处理器抛错
}
return indexHtml;
}
/**
* 注册 app:// 协议处理器,在 app ready 之后调用
*/
export function registerAppProtocol(): void {
const webRoot = getWebRoot();
protocol.handle(APP_SCHEME, (request) => {
const url = new URL(request.url);
// 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin路径直接透传
if (url.pathname.startsWith('/api/')) {
return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request);
}
// 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/*
if (url.pathname.startsWith('/im-api/')) {
const stripped = url.pathname.slice('/im-api'.length);
return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request);
}
// 其余全部按静态资源处理net.fetch file:// 会按扩展名自动带上正确的 Content-Type
const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString();
return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit);
});
}