electron-migration-plan.md 13 KB

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 ✅ 已完成:<input type=file accept=".xlts"> + POST /sys/noNet(multipart),后端落盘到 LICENSE_PATH 并校验激活 已实现 —
8 升级包选择 ipc controller/effect/selectFile → /sys/upgrade?path= ✅ 已完成:<input type=file accept=".zip"> + 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 配置),前端 <img :src> 直显;预览用 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。

// 方案 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(瓦片在用户本机):<input type=file accept=".pmtiles"> 选一次,之后纯前端切片
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. 授权文件(离线授权)

  • 前端:<input type="file" accept=".xlts"> → 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 声明已删
  • 地图:瓦片走后端 Range 接口能出图(Range 分支逻辑已单测通过;浏览器实测待做)
  • 地图目录/基站包:不再有 UI 选择入口,路径由服务端配置提供
  • 离线授权:用真实 .xlts 文件走通一次授权成功
  • 图谱历史:保存后刷新页面能重新读取到同样的图
  • 头像:上传后立刻显示;库里历史 file:// 数据不报错(显示占位图)
  • eslint(改动文件)0 错误;vue-tsc 不新增错误