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

109 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
}