# Electron 残留 → 纯 Web 迁移方案 > 盘点时间:2026-09-16 · 范围:`ai-frontend/src`(后端 `ai-server` 仅列需要配合的改动) > 盘点方式:全仓 `electron|ipcRenderer|isElectron` 命中点逐个读源码确认用途 > 结论:**共 14 项**,其中 3 项「直接删」、6 项「前端改造即可」、5 项「需后端配合」;4 项存在 Web 天然能力缺失,给了降级方案 > > **进度(2026-09-16 晚)**:第 **3、4、5** 项已完成(详见文末「已实施记录」)。 --- ## 已实施记录(2026-09-16) ### 4/5. 两个「选择」功能已删除 ✅ | 删除对象 | 说明 | | --- | --- | | `core/layouts/default/setting/components/OfflineMapModal.vue` | 离线地图目录选择弹窗(ipc `selectFolder`) | | `core/layouts/default/setting/components/CellLocalModal.vue` | 基站包路径选择弹窗(ipc `selectBinFile` + 读前 16 字节) | | `core/utils/caseGlobalConfig.ts` | 仅为把这两条路径通过 Electron ipc 写到本机配置文件,随选择功能一起删除 | | `SettingFooter.vue` 两个入口按钮/弹窗/引导事件监听、`components/index.ts` 两个导出 | 一并清理 | | `caseConfigGuide.ts` 的 `hydratePersistedCaseGlobalConfig()` 调用与两个弹窗事件常量 | 保留 `initGlobalCaseConfig()`(现在只从后端读配置)与 `OPEN_SETTING_DRAWER_EVENT` | **删除后路径怎么配(重要)**:两个路径本来就存在**服务端**配置里(`SystemConfManager` → `QingJian/conf/c5e503ad.json` 的 `mapLocal` / `cellLocal` 字段), 前端只是通过 `/sys/getMaplocal`、`/sys/getCellLocal` 读回来缓存。所以 UI 删掉后有两种设置方式: 1. 调用接口:`GET /js/a/sys/mapLocal?path=...`、`GET /js/a/sys/cellLocal?path=...`(`/sys/cellLocal` 会校验文件存在且非目录) 2. 直接编辑 `QingJian/conf/c5e503ad.json`:`{"mapLocal":"E:/map","cellLocal":"E:/cell/xxx.bin"}`(**改完需重启后端**,配置是启动时读入内存的) ### 3. 离线地图瓦片:方案 A 已落地 ✅ **后端**(`SystemController` + `SystemService`) - 新增 `GET /sys/map/pmtiles`:从 `mapLocal` 配置目录读 `china.pmtiles`,**支持 HTTP Range**(无 Range → 200 全量 + `Accept-Ranges: bytes`;单区间 → 206 + `ResourceRegion`;多区间/非法/越界 → 416) - 新增 `GET /sys/map/style`:返回配置目录里的自定义样式 `behavior-spacetime-style.json`(没有则 404,前端自动回退内置样式) - 两个接口**刻意不返回 `Result`**(响应体是文件内容),文件名是服务端固定白名单常量、不接受调用方传参 - 逻辑全在 service(`mapPmtiles` / `mapStyle` / `resolveMapFile` / `serveLocalFile`),controller 仅转发 **前端** - `core/utils/map/offlineMap.ts`:删除 `ElectronPmtilesSource`(ipc 读本机文件),新增 `HttpPmtilesSource`(`fetch` + `Range` 头取块)、`getOfflineMapPmtilesUrl()`、`getOfflineMapStyleUrl()`、`getBuiltinPmtilesUrl()`、`resolveOfflinePmtilesUrls()` - 三个地图组件(`BaseStationQueryMap.vue`、`StationMapModal.vue`、`behaviorSpacetime/index.vue`): `new ElectronPmtilesSource(本地路径)` → `new HttpPmtilesSource(后端 URL)`;自定义样式从 ipc 读文件改为 `fetch /sys/map/style`;未配置服务端目录时自动回退到前端静态资源 `public/resource/map/china.pmtiles`(内置样式本来就指向 `pmtiles://resource/map/china.pmtiles`,现在会把该地址也注册成瓦片源) - 因此**两个部署形态都可用**:①瓦片放服务端 `mapLocal` 目录(走后端 Range);②瓦片放前端构建产物 `public/resource/map/`(走静态服务,需该静态服务支持 Range) **验证** - 后端 `mvn -pl ai-server -B compile` → BUILD SUCCESS - Range 分支逻辑:用真实 spring-web 6.2.19 跑了 9 个用例(`0-99`/`100-`/`-100`/`999-999` → 206;`0-1,5-6`/`abc`/`2000-3000`/`-0` → 416;无 Range → 200)**全通过** - 前端:esbuild 打包真实 `offlineMap.ts` + 起本地 Range 服务,跑 10 项断言(URL 拼装、Range 头、返回字节、空参数不发请求、404 返回空)**全通过** - `eslint --max-warnings 0` 0 错误;`vue-tsc` 全仓仍 103(无新增) --- ## 一、总览 | # | 功能 | 现状(Electron 依赖) | 迁移方案 | 后端改动 | 难度 | |---|---|---|---|---|---| | 1 | 窗口最小化/最大化/关闭 | `window.electron.windowMinimize/Maximize/Close` + `window:maximize-change` 事件 | ✅ **已完成**:4 个组件 + `useElectronWindow` 已删除(本就没有页面挂载它们,纯死代码) | 无 | — | | 2 | 版本号展示 | `useElectronWindow().getVersion()` | ✅ **已完成**:AppLogo/ProductInfoModal 改用构建期 `__APP_INFO__.pkg.version`(vite define 已注入) | 无 | — | | 3 | 离线地图瓦片读取 | `ElectronPmtilesSource.getBytes()` 走 ipc `readBinaryFileRange` | ✅ **已完成**:后端 `GET /sys/map/pmtiles`(支持 Range)+ 前端 `HttpPmtilesSource`;未配置目录时回退前端静态 `resource/map/china.pmtiles` | 已实现 | — | | 4 | 离线地图目录选择 | ipc `controller/os/selectFolder` | ✅ **已删除**功能;路径改由服务端配置(`/sys/mapLocal` 或 `QingJian/conf/c5e503ad.json`) | 已实现 | — | | 5 | 基站库(.bin)选择 | ipc `selectBinFile` + 读前 16 字节校验 | ✅ **已删除**功能;路径改由服务端配置(`/sys/cellLocal` 或同一配置文件) | 已实现 | — | | 6 | 案件全局配置持久化 | ipc `readCaseGlobalConfig` / `writeCaseGlobalConfig` | 改存后端(按 caseId)——`/sys/getMaplocal`、`/sys/cellLocal` 接口已存在,直接复用;纯本机偏好可退化为 `localStorage` | 复用现有接口 | ★ | | 7 | 授权文件选择 | ipc `controller/effect/selectFile` → 传**服务端路径**给 `/sys/noNet` | ✅ **已完成**:`` + `POST /sys/noNet`(multipart),后端落盘到 LICENSE_PATH 并校验激活 | 已实现 | — | | 8 | 升级包选择 | ipc `controller/effect/selectFile` → `/sys/upgrade?path=` | ✅ **已完成**:`` + `POST /sys/uploadUpgrade`(multipart,带进度),后端落盘临时 zip 后复用原 upgrade 流程并清理 | 已实现 | — | | 9 | 图谱历史文件读写 | ipc `readTextFile` / `writeTextFile`(路径来自后端 `graph_his`,本质是**服务端文件**) | ✅ **已完成**:新增 `GET/POST /graph/history/{id}/content`(服务端校验路径必须在 GRAPH_HIS_PATH 内),前端按历史ID读写 | 已实现 | — | | 10 | 基站 xls 选择 + 打开目录 | ipc `selectXlsFile` / `openDirectory` | ✅ **已完成**:`POST /tower/covFile`(multipart 上传,结果以附件流返回浏览器下载);模板改为 `GET /tower/template` 附件下载;"打开文件夹"交互删除 | 已实现 | — | | 11 | 头像本地路径 | `window.electron.getPathForFile(file)` 存本机路径;`PersonAvatar` 再用 ipc 读图 | 改为**上传图片**→后端存盘并返回 URL(可复用 `LocalFileStorageUtil` + `base-url` 配置),前端 `` 直显;预览用 `URL.createObjectURL(file)` | 需要(新增图片上传接口) | ★★★ | | 12 | `file:` 协议兜底 baseURL | `governApi.ts` 的 `getPackagedFallbackOrigin()`(`location.protocol === 'file:'` → `http://localhost:8980`) | ✅ **已完成**:常量与函数已删,host 兜底链只剩同源 | 无 | — | | 13 | `window.electron` 类型声明 | `types/global.d.ts` | 第 1~12 项做完后删除 | 无 | ★ | | 14 | `preFile`(本地路径数组)注释 | `governApi.ts` 注释「Electron 本地路径方式,保留兼容」 | 代码实际只被 importList 用**服务端路径**调用(重新清洗),逻辑本身没问题,仅更新注释 | 无 | ★ | --- ## 二、重点项的落地细节 ### 3. 离线地图瓦片(最关键,决定地图能不能用) 现状:`ElectronPmtilesSource.getBytes(offset, length)` 用 ipc 读本机文件片段,PMTiles 库靠它按需取瓦片。 三个地图组件构造 Source:`BaseStationQueryMap.vue:107`、`behaviorSpacetime/index.vue:2425`、`StationMapModal.vue:154`。 ```ts // 方案 A(推荐,瓦片放服务器):后端加一个支持 Range 的接口 // GET /sys/map/pmtiles → 从 mapLocal 配置的目录流式返回 china.pmtiles // Spring 的 ResourceHttpRequestHandler 原生支持 Range(206),无需手写 export class HttpPmtilesSource { constructor(private url: string) {} getKey() { return this.url; } async getBytes(offset: number, length: number) { const res = await fetch(this.url, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } }); return { data: await res.arrayBuffer() }; } } // 方案 B(瓦片在用户本机): 选一次,之后纯前端切片 export class FilePmtilesSource { constructor(private file: File) {} getKey() { return `${this.file.name}:${this.file.size}`; } async getBytes(offset: number, length: number) { return { data: await this.file.slice(offset, offset + length).arrayBuffer() }; } } ``` 两个类都满足 PMTiles 的 `Source` 接口,替换 `new ElectronPmtilesSource(path)` 即可;**方案 B 完全不需要后端**。 ### 4/5/8. 「选择服务端文件/目录」的统一解法 这三处的路径最终都存到**服务端**(`/sys/mapLocal`、`/sys/cellLocal`、`/sys/upgrade`),Electron 里只是用对话框拿到一个字符串。Web 里最贴近的做法是**后端提供受限的目录浏览**: - `GET /sys/browse?path=&root=` → 返回该目录下的子目录/文件(**必须白名单根目录 + normalize 防 `../` 穿越**,参考 `AgentPythonFileController.assertSafeSegment` 的写法) - 前端把「选择文件夹」弹窗换成「服务端目录浏览弹窗」,点选结果仍是路径字符串 → 后续链路一行不用改 - 若不想做浏览弹窗:退化为「文本框输入路径 + 保存时后端校验(存在/后缀/可读)」,弹一条明确的错误提示 ### 7. 授权文件(离线授权) - 前端:`` → `const text = await file.text()` - 后端:`/sys/noNet` 从「收 license 路径」改为「收文本内容或 multipart」,服务端自行写入 `PathConst.LICENSE_PATH` 再走原有校验 - 注意:授权与机器码绑定,**服务端写文件的路径必须固定**,不要让前端指定 ### 11. 头像(唯一涉及存量数据的一项) - 新数据:上传接口(`POST /case/avatar` 之类)→ 后端用 `LocalFileStorageUtil` 存盘 → 返回可访问 URL(`file-url.base-url` 已配置为 `http://localhost:8980/js/a`)→ 库表里存 URL - 历史数据:库里存的是 `file:///C:/...png` 这种本机绝对路径,**浏览器读不到** → 需要一次性迁移脚本(把这些行标记为无头像,显示占位图),或让用户重新上传 - `PersonAvatar.loadLocalImage()` 里的 ipc 读图整段删除,改为直接绑定 URL --- ## 三、建议实施顺序 | 批次 | 内容 | 说明 | |---|---|---| | P0(无依赖,随时可做) | 1、2、6、12、14 | 纯删除/替换,风险最低,先把「Electron 存在感」降下来 | | P1(前端改造即可) | 3-方案B、7、9、10 | 3B/9/10 体验与 Electron 版基本等价;7 需后端小改(收内容) | | P2(需后端补接口) | **3-方案A ✅、4 ✅、5 ✅**、8 | 3/4/5 已完成(见文末已实施记录);8 仍在待办 | | P3(涉及存量数据) | 11 | 需要迁移策略与用户沟通 | | 收尾 | 13(删类型声明)+ 全仓 grep 归零 | 验收:`grep -ri "electron\|ipcRenderer" ai-frontend/src` 为 0 | --- ## 四、Web 天然做不到、必须换交互的 4 件事 1. **打开/定位本机目录**(基站模块"打开文件夹"、图谱目录)→ 改为页内文件列表 + 下载,或提示路径让用户自行打开 2. **读取本机任意路径的文件**(地图瓦片、基站包、头像)→ 上传,或服务端侧选择 3. **窗口控制**(最小化/最大化/关闭)→ 删除;可保留"全屏"按钮 4. **静默读写本机文件**(案件全局配置、图谱历史)→ 挪到服务端接口 --- ## 五、验收清单(每项做完怎么算过) - [ ] 全仓代码里不再有 Electron 依赖:`grep -rn "electron\|ipcRenderer\|isElectron" ai-frontend/src` **只剩注释里的历史说明**(无任何 `window.electron` / ipc 调用) - [ ] `types/global.d.ts` 里 `window.electron` 声明已删 - [x] 地图:瓦片走后端 Range 接口能出图(Range 分支逻辑已单测通过;浏览器实测待做) - [x] 地图目录/基站包:不再有 UI 选择入口,路径由服务端配置提供 - [ ] 离线授权:用真实 `.xlts` 文件走通一次授权成功 - [ ] 图谱历史:保存后刷新页面能重新读取到同样的图 - [ ] 头像:上传后立刻显示;库里历史 `file://` 数据不报错(显示占位图) - [x] `eslint`(改动文件)0 错误;`vue-tsc` 不新增错误