cc 1 週間 前
親
コミット
a576dbf3f1

+ 57 - 3
ai-electron/electron/controller/osCtl.js

@@ -1,9 +1,13 @@
 import { shell } from 'electron';
 import { os } from "../service/os/os";
+import { localSettingsService } from "../service/setting/localSettings";
 import path from 'node:path';
 import fs from 'node:fs';
 import { logger } from "ee-core/log";
 
+/** 本机设置里存离线地图目录的键名 */
+const MAP_LOCAL_KEY = 'mapLocal';
+
 
 /**
  * 窗口控制(用于无边框窗口拖拽)
@@ -65,10 +69,60 @@ class OsCtl {
     return await os.selectDirectory();
   }
   /**
-   * 选择单个文件(返回文件路径;params: { title?, filterName?, extensions?: string[] })
+   * 分块读本机文件(离线地图瓦片用;params: { filePath, offset?, length })。
+   *
+   * 方法名沿用改造前前端已调用的 `readBinaryFileRange`;返回项目统一的 {success,data|message} 信封,
+   * 读取失败(路径非法/文件不存在等)不抛到 Electron 层,调用方看 message 即可。
    */
-  async selectFile(params) {
-    return await os.selectFile(params);
+  async readBinaryFileRange(params) {
+    try {
+      return { success: true, data: await os.readBinaryFileRange(params) };
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      logger.warn('[osCtl] readBinaryFileRange failed:', msg);
+      return { success: false, message: msg };
+    }
+  }
+  /**
+   * 读本机文本文件(离线地图样式用;params: { filePath, encoding? })
+   */
+  async readTextFile(params) {
+    try {
+      return { success: true, data: { text: await os.readTextFile(params) } };
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      logger.warn('[osCtl] readTextFile failed:', msg);
+      return { success: false, message: msg };
+    }
+  }
+  /**
+   * 读取本机离线地图目录(未设置为空串)。
+   *
+   * 存在本机(local-settings.json),不走服务端:地图瓦片在本机时,后端可能部署在别的机器上。
+   */
+  async getMapLocalPath() {
+    return { success: true, data: { path: localSettingsService.get(MAP_LOCAL_KEY) } };
+  }
+  /**
+   * 保存本机离线地图目录;params: { path },path 传空表示清空。
+   *
+   * 校验与 openPath 一致:只接受绝对路径、必须已存在、且必须是目录。
+   */
+  async setMapLocalPath(params) {
+    try {
+      const target = typeof params?.path === 'string' ? params.path.trim() : '';
+      if (target) {
+        if (!path.isAbsolute(target)) throw new Error('只支持绝对路径');
+        if (!fs.existsSync(target)) throw new Error(`目录不存在:${target}`);
+        if (!fs.statSync(target).isDirectory()) throw new Error('请选择目录,而不是文件');
+      }
+      const saved = localSettingsService.set(MAP_LOCAL_KEY, target);
+      return { success: true, data: { path: saved } };
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      logger.warn('[osCtl] setMapLocalPath failed:', msg);
+      return { success: false, message: msg };
+    }
   }
   /**
    * 获取应用信息

+ 42 - 13
ai-electron/electron/service/os/os.js

@@ -49,21 +49,50 @@ class Os {
   }
 
   /**
-   * 选择单个本机文件(如基站包 .bin)。
-   * @param options { title?, filterName?, extensions?: string[] } 扩展名过滤,传 ['bin'] 即只列 .bin
+   * 按偏移分块读本机文件,返回 base64(离线地图瓦片这类大文件只按需取块)。
+   *
+   * <p>渲染进程侧的实现见 core/utils/map/offlineMap.ts 的 ElectronPmtilesSource:
+   * PMTiles 只要求 getBytes(offset, length),所以这里只做「绝对路径 + 真实文件 + 边界收敛」。
+   *
+   * @param params { filePath: string, offset?: number, length: number }
+   * @returns { data: base64, size: 实际读到的字节数, fileSize: 文件总大小 }
    */
-  async selectFile(options = {}) {
-    const win = getMainWindow();
-    const extensions = Array.isArray(options?.extensions) ? options.extensions.filter(Boolean) : [];
-    const result = await dialog.showOpenDialog(win, {
-      title: options?.title || '选择文件',
-      properties: ['openFile'],
-      filters: extensions.length ? [{ name: options?.filterName || '文件', extensions }] : undefined,
-    });
-    if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
-      return '';
+  async readBinaryFileRange(params = {}) {
+    const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : '';
+    if (!filePath) throw new Error('文件路径不能为空');
+    if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径');
+    if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`);
+    if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件');
+
+    const fileSize = fs.statSync(filePath).size;
+    const offset = Math.max(0, Math.floor(Number(params?.offset) || 0));
+    const requested = Math.floor(Number(params?.length) || 0);
+    if (!requested || requested < 0) throw new Error('读取长度不合法');
+    if (offset >= fileSize) return { data: '', size: 0, fileSize };
+
+    const length = Math.min(requested, fileSize - offset);
+    const fd = fs.openSync(filePath, 'r');
+    try {
+      const buffer = Buffer.alloc(length);
+      const read = fs.readSync(fd, buffer, 0, length, offset);
+      return { data: buffer.subarray(0, read).toString('base64'), size: read, fileSize };
+    } finally {
+      fs.closeSync(fd);
     }
-    return result.filePaths[0];
+  }
+
+  /**
+   * 读本机文本文件(离线地图样式 JSON 等)。
+   * @param params { filePath: string, encoding?: string }
+   */
+  async readTextFile(params = {}) {
+    const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : '';
+    if (!filePath) throw new Error('文件路径不能为空');
+    if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径');
+    if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`);
+    if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件');
+    const encoding = typeof params?.encoding === 'string' && params.encoding ? params.encoding : 'utf8';
+    return fs.readFileSync(filePath, encoding);
   }
 
   async getAppInfo() {

+ 53 - 0
ai-electron/electron/service/setting/localSettings.js

@@ -0,0 +1,53 @@
+'use strict';
+
+import { getDataDir } from 'ee-core/ps';
+import { logger } from 'ee-core/log';
+import path from 'node:path';
+import fs from 'node:fs';
+
+/**
+ * 本机(客户端)设置:落盘在应用数据目录下的 local-settings.json。
+ *
+ * <p>与后端 {@code QingJian/conf/c5e503ad.json} 无关:这些是「这台机器上的客户端」的偏好,
+ * 例如离线地图目录 —— 后端可能部署在别的机器上,服务端路径对客户端没有意义。
+ */
+class LocalSettingsService {
+  get filePath() {
+    return path.join(getDataDir(), 'local-settings.json');
+  }
+
+  readAll() {
+    try {
+      const file = this.filePath;
+      if (!fs.existsSync(file)) return {};
+      const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
+      return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
+    } catch (e) {
+      logger.warn('[localSettings] 读取失败:', e instanceof Error ? e.message : String(e));
+      return {};
+    }
+  }
+
+  /** 读一个字符串设置;没有或类型不对返回空串 */
+  get(key) {
+    const value = this.readAll()[key];
+    return typeof value === 'string' ? value : '';
+  }
+
+  /** 写一个设置;传空则删除该键 */
+  set(key, value) {
+    const all = this.readAll();
+    const text = typeof value === 'string' ? value.trim() : '';
+    if (text) {
+      all[key] = text;
+    } else {
+      delete all[key];
+    }
+    const file = this.filePath;
+    fs.mkdirSync(path.dirname(file), { recursive: true });
+    fs.writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, 'utf8');
+    return text;
+  }
+}
+
+export const localSettingsService = new LocalSettingsService();

+ 16 - 18
ai-electron/frontend/src/case/views/dm/components/StationMapModal.vue

@@ -25,11 +25,11 @@
   import { BasicModal } from '@/core/components/Modal';
   import { useDesign } from '@/core/hooks/web/useDesign';
   import {
-    HttpPmtilesSource,
+    getOfflineMapPmtilesPath,
+    readOfflineMapStyleText,
+    resolveOfflinePmtilesSources,
     getOfflineMapDir,
-    getOfflineMapPmtilesUrl,
-    getOfflineMapStyleUrl,
-    resolveOfflinePmtilesUrls,
+    getOfflineMapStylePath,
     OFFLINE_MAP_DIR_CHANGED_EVENT,
     toLocalMapUrl,
   } from '@/core/utils/map/offlineMap';
@@ -151,8 +151,8 @@
   };
   const ensureOfflinePmtilesArchive = () => {
     if (!pmtilesProtocol) return;
-    resolveOfflinePmtilesUrls().forEach((url) => {
-      if (!pmtilesProtocol?.get(url)) pmtilesProtocol?.add(new PMTiles(new HttpPmtilesSource(url) as any));
+    resolveOfflinePmtilesSources().forEach(({ key, source }) => {
+      if (!pmtilesProtocol?.get(key)) pmtilesProtocol?.add(new PMTiles(source as any));
     });
   };
 
@@ -198,15 +198,15 @@
 
   const buildExternalMapStyle = () => {
     const offlineMapDir = getOfflineMapDir();
-    const pmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-    if (!offlineMapDir || !pmtilesUrl) return null;
+    const pmtilesFilePath = getOfflineMapPmtilesPath();
+    if (!offlineMapDir || !pmtilesFilePath) return null;
     return {
       version: 8,
       name: 'portrait-offline-map-external',
       sources: {
         'behavior-basemap': {
           type: 'vector',
-          url: `${PMTILES_PROTOCOL}${pmtilesUrl}`,
+          url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
         },
       },
       layers: [
@@ -292,9 +292,7 @@
   const readOfflineMapStyle = async () => {
     if (!getOfflineMapDir()) return '';
     try {
-      const response = await fetch(getOfflineMapStyleUrl(), { cache: 'no-store' });
-      if (!response.ok) return '';
-      return await response.text();
+      return await readOfflineMapStyleText();
     } catch {
       return '';
     }
@@ -304,15 +302,15 @@
     const custom = (window as PortraitStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
     try {
       const offlineMapDir = getOfflineMapDir();
-      const offlinePmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-      const offlineStyleUrl = getOfflineMapStyleUrl();
-      if (offlineMapDir && offlinePmtilesUrl && offlineStyleUrl) {
+      const offlinePmtilesFilePath = getOfflineMapPmtilesPath();
+      const offlineStylePath = getOfflineMapStylePath();
+      if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
         const styleText = await readOfflineMapStyle();
         if (styleText) {
           const style = JSON.parse(styleText);
           mapMode.value = 'template';
           errorMsg.value = '';
-          return normalizePmtilesSources(style as Record<string, any>, offlineStyleUrl, offlinePmtilesUrl);
+          return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
         }
       }
       const externalStyle = buildExternalMapStyle();
@@ -324,7 +322,7 @@
       if (custom && typeof custom === 'object') {
         mapMode.value = 'template';
         errorMsg.value = '';
-        return normalizePmtilesSources(custom, window.location.href, offlinePmtilesUrl || undefined);
+        return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
       }
       const styleUrl = resolveStyleAssetUrl(String(custom), window.location.href);
       const response = await fetch(styleUrl, { cache: 'no-store' });
@@ -335,7 +333,7 @@
       return normalizePmtilesSources(
         style as Record<string, any>,
         response.url || styleUrl,
-        offlinePmtilesUrl || undefined,
+        offlinePmtilesFilePath || undefined,
       );
     } catch (error) {
       errorMsg.value = error instanceof Error ? error.message : 'style error';

+ 0 - 22
ai-electron/frontend/src/core/api/sys/cellLocal.ts

@@ -1,22 +0,0 @@
-/**
- * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
- * No deletion without permission, or be held responsible to law.
- * @author ThinkGem
- */
-import { defHttp } from '@/core/utils/http/axios';
-import { useGlobSetting } from '@/core/hooks/setting';
-import { Result } from '@/types/axios';
-
-const { adminPath } = useGlobSetting();
-
-export const getCellLocalApi = () =>
-  defHttp.get<Result>(
-    { url: adminPath + '/sys/getCellLocal', timeout: 10 * 1000 },
-    { errorMessageMode: 'none', isTransformResponse: false, skipErrorMessage: true },
-  );
-
-export const setCellLocalApi = (path: string) =>
-  defHttp.get<Result>(
-    { url: adminPath + '/sys/cellLocal', params: { path }, timeout: 10 * 1000 },
-    { errorMessageMode: 'none', isTransformResponse: false },
-  );

+ 0 - 1
ai-electron/frontend/src/core/api/sys/index.ts

@@ -7,7 +7,6 @@ export * from './dictType';
 export * from './empUser';
 export * from './log';
 export * from './login';
-export * from './cellLocal';
 export * from './mapLocal';
 export * from './menu';
 export * from './module';

+ 0 - 1
ai-electron/frontend/src/core/api/sys/login.ts

@@ -180,7 +180,6 @@ export const checkAuthApi = () =>
   );
 
 export { getMapLocalApi, setMapLocalApi } from './mapLocal';
-export { getCellLocalApi, setCellLocalApi } from './cellLocal';
 
 export const menuRouteApi = () => defHttp.get<Menu[]>({ url: adminPath + '/menuRoute' });
 

+ 0 - 174
ai-electron/frontend/src/core/layouts/default/setting/components/CellLocalSettingModal.vue

@@ -1,174 +0,0 @@
-<template>
-  <BasicModal
-    v-bind="$attrs"
-    title="基站包设置"
-    :width="620"
-    :centered="true"
-    :confirmLoading="saving"
-    okText="保存"
-    @register="registerModal"
-    @ok="handleSave"
-  >
-    <div :class="prefixCls">
-      <p class="cell-local__tip">
-        基站数据库文件(服务端路径,形如 <code>cell.bin</code>):文件必须已存在于服务端,保存后写入服务端配置;
-        基站库在本次运行中已经加载过时,需重启服务端才会改用新文件。
-      </p>
-
-      <div class="cell-local__row">
-        <Input
-          v-model:value="path"
-          :disabled="loading || saving"
-          placeholder="例如 E:\\data\\cell\\cell.bin"
-          allowClear
-        />
-        <Button :disabled="loading || saving || !desktopPicker" @click="handlePick">
-          <FolderOpenOutlined class="mr-1" />
-          选择文件
-        </Button>
-      </div>
-
-      <p v-if="!desktopPicker" class="cell-local__hint">当前不在桌面客户端内,只能手动输入路径。</p>
-      <p v-else-if="loading" class="cell-local__hint">正在读取当前配置…</p>
-    </div>
-  </BasicModal>
-</template>
-
-<script lang="ts" setup>
-  /**
-   * 基站包设置:读写服务端基站数据库文件路径(/sys/getCellLocal、/sys/cellLocal)。
-   *
-   * 后端只认 .bin 文件名(TowerUtils.init 会校验后缀),这里保存前先在本地拦一道,
-   * 免得存进去一个用不起来的路径;保存成功后调用 initCellLocalPathFromServer() 同步本地缓存。
-   */
-  import { ref, unref, watch } from 'vue';
-  import { Button, Input } from 'ant-design-vue';
-  import { FolderOpenOutlined } from '@ant-design/icons-vue';
-  import { BasicModal, useModalInner } from '@/core/components/Modal';
-  import { useDesign } from '@/core/hooks/web/useDesign';
-  import { useMessage } from '@/core/hooks/web/useMessage';
-  import { getCellLocalApi, setCellLocalApi } from '@/core/api/sys/cellLocal';
-  import { initCellLocalPathFromServer } from '@/core/utils/cell/cellLocalInit';
-  import { isValidCellLocalPath } from '@/core/utils/cell/cellLocal';
-  import { isDesktopDialogAvailable, pickFile } from '@/core/utils/desktop/pathPicker';
-
-  const { prefixCls } = useDesign('cell-local-setting');
-  const { createMessage } = useMessage();
-
-  const desktopPicker = isDesktopDialogAvailable();
-  const loading = ref(false);
-  const saving = ref(false);
-  const path = ref('');
-
-  /**
-   * 解析后端返回:/sys/getCellLocal 是 Result<String>。
-   *
-   * 该接口在 defHttp 层关掉了错误提示,所以这里必须自己看 code —— 否则「读取失败(如未登录)」
-   * 会和「没配置过」一样显示成空值,用户对着空框保存就把已有配置清掉了。
-   */
-  const readSetting = (payload: unknown): { ok: boolean; path: string; message: string } => {
-    const res = (payload ?? {}) as { code?: number; message?: string; data?: unknown };
-    return {
-      ok: Number(res.code ?? 0) === 200,
-      path: typeof res.data === 'string' ? res.data.trim() : '',
-      message: res.message || '读取基站包配置失败',
-    };
-  };
-
-  /**
-   * 每次打开都重新读一次服务端配置。
-   *
-   * <p>用 `getOpen` 而不是 `useModalInner(callback)`:抽屉每次都传同一个 data(openModal(true)),
-   * 内部 dataTransfer 值没变化就不会再触发回调,第二次往后打开会读到上一次的残值。
-   */
-  const [registerModal, { closeModal, getOpen }] = useModalInner();
-
-  async function loadSetting() {
-    loading.value = true;
-    path.value = '';
-    try {
-      const parsed = readSetting(await getCellLocalApi());
-      if (!parsed.ok) {
-        createMessage.error(parsed.message);
-        return;
-      }
-      path.value = parsed.path;
-    } catch (error) {
-      createMessage.error(error instanceof Error ? error.message : '读取基站包配置失败');
-    } finally {
-      loading.value = false;
-    }
-  }
-
-  watch(
-    () => unref(getOpen),
-    (open) => {
-      if (open) void loadSetting();
-    },
-    { immediate: false },
-  );
-
-  async function handlePick() {
-    const picked = await pickFile({ title: '选择基站数据库文件', extensions: ['bin'], filterName: '基站数据库文件' });
-    if (picked) path.value = picked;
-  }
-
-  async function handleSave() {
-    const next = path.value.trim();
-    // 后端对空路径会走 Path.of("") → 命中「当前不支持文件夹!」这句误报,所以这里先拦住;
-    // 非 .bin / 非绝对路径后端也用不起来(TowerUtils 会拒绝),本地先校验一次。
-    if (!next) {
-      createMessage.warning('请填写基站包路径,或点「选择文件」挑一个 .bin 文件');
-      return;
-    }
-    if (!isValidCellLocalPath(next)) {
-      createMessage.warning('基站包必须是绝对路径的 .bin 文件,或点「选择文件」挑一个');
-      return;
-    }
-    saving.value = true;
-    try {
-      const res = await setCellLocalApi(next);
-      if (Number((res as { code?: number } | null)?.code ?? 0) !== 200) {
-        createMessage.error((res as { message?: string } | null)?.message || '保存基站包配置失败');
-        return;
-      }
-      await initCellLocalPathFromServer();
-      createMessage.success(next ? '基站包已保存' : '已清空基站包配置');
-      closeModal();
-    } catch (error) {
-      createMessage.error(error instanceof Error ? error.message : '保存基站包配置失败');
-    } finally {
-      saving.value = false;
-    }
-  }
-</script>
-
-<style lang="less">
-  @prefix-cls: ~'jeesite-cell-local-setting';
-
-  .@{prefix-cls} {
-    .cell-local__tip {
-      margin-bottom: 12px;
-      color: #4b5563;
-      line-height: 1.7;
-
-      code {
-        padding: 0 4px;
-        background: #f3f4f6;
-        border-radius: 4px;
-      }
-    }
-
-    .cell-local__row {
-      display: flex;
-      gap: 8px;
-      align-items: center;
-    }
-
-    .cell-local__hint {
-      margin: 10px 0 0;
-      font-size: 12px;
-      color: #9ca3af;
-    }
-  }
-</style>

+ 40 - 51
ai-electron/frontend/src/core/layouts/default/setting/components/MapLocalSettingModal.vue

@@ -5,36 +5,44 @@
     :width="620"
     :centered="true"
     :confirmLoading="saving"
+    :okButtonProps="{ disabled: !desktop || loading }"
     okText="保存"
     @register="registerModal"
     @ok="handleSave"
   >
     <div :class="prefixCls">
       <p class="map-local__tip">
-        离线地图瓦片目录(服务端路径):目录下放 <code>china.pmtiles</code>, 可选放
-        <code>behavior-spacetime-style.json</code> 自定义样式;留空表示不启用离线地图。
+        离线地图瓦片目录(本机路径,由客户端保存):目录下放 <code>china.pmtiles</code>, 可选放
+        <code>behavior-spacetime-style.json</code> 自定义样式;留空表示用内置资源。
       </p>
 
       <div class="map-local__row">
-        <Input v-model:value="path" :disabled="loading || saving" placeholder="例如 E:\\data\\offline-map" allowClear />
-        <Button :disabled="loading || saving || !desktopPicker" @click="handlePick">
+        <Input
+          v-model:value="path"
+          :disabled="loading || saving || !desktop"
+          placeholder="例如 E:\\data\\offline-map"
+          allowClear
+        />
+        <Button :disabled="loading || saving || !desktop" @click="handlePick">
           <FolderOpenOutlined class="mr-1" />
           选择目录
         </Button>
       </div>
 
-      <p v-if="!desktopPicker" class="map-local__hint">当前不在桌面客户端内,只能手动输入路径。</p>
-      <p v-else-if="loading" class="map-local__hint">正在读取当前配置…</p>
+      <p v-if="!desktop" class="map-local__hint">当前不在桌面客户端内:地图目录保存在本机,只能到桌面端设置。</p>
+      <p v-else-if="loading" class="map-local__hint">正在读取本机配置…</p>
+      <p v-else-if="savedPath" class="map-local__hint">当前生效:{{ savedPath }}</p>
     </div>
   </BasicModal>
 </template>
 
 <script lang="ts" setup>
   /**
-   * 地图设置:读写服务端离线地图目录(/sys/getMaplocal、/sys/mapLocal)。
+   * 地图设置:离线地图目录**只落在本机**(客户端 local-settings.json),不写服务端配置。
    *
-   * 保存成功后调用 initOfflineMapDirFromServer() 把新路径同步进本地缓存,
-   * 地图组件下一次取瓦片就用新目录,不需要刷新页面。
+   * <p>后端可能部署在别的机器上,服务端目录对客户端没有意义,所以这里只调 Electron 的
+   * osCtl.getMapLocalPath / setMapLocalPath;瓦片的提供方式不变。
+   * <p>保存成功后写入前端缓存并派发 OFFLINE_MAP_DIR_CHANGED_EVENT,已打开的地图会自己重新加载。
    */
   import { ref, unref, watch } from 'vue';
   import { Button, Input } from 'ant-design-vue';
@@ -42,36 +50,25 @@
   import { BasicModal, useModalInner } from '@/core/components/Modal';
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useMessage } from '@/core/hooks/web/useMessage';
-  import { getMapLocalApi, setMapLocalApi } from '@/core/api/sys/mapLocal';
-  import { initOfflineMapDirFromServer } from '@/core/utils/map/offlineMapInit';
-  import { isValidOfflineMapPath } from '@/core/utils/map/offlineMap';
-  import { isDesktopDialogAvailable, pickDirectory } from '@/core/utils/desktop/pathPicker';
+  import {
+    getLocalMapDir,
+    isDesktopDialogAvailable,
+    pickDirectory,
+    saveLocalMapDir,
+  } from '@/core/utils/desktop/pathPicker';
+  import { setOfflineMapDir } from '@/core/utils/map/offlineMap';
 
   const { prefixCls } = useDesign('map-local-setting');
   const { createMessage } = useMessage();
 
-  const desktopPicker = isDesktopDialogAvailable();
+  const desktop = isDesktopDialogAvailable();
   const loading = ref(false);
   const saving = ref(false);
   const path = ref('');
+  const savedPath = ref('');
 
   /**
-   * 解析后端返回:/sys/getMaplocal 是 Result<String>。
-   *
-   * 该接口在 defHttp 层关掉了错误提示,所以这里必须自己看 code —— 否则「读取失败(如未登录)」
-   * 会和「没配置过」一样显示成空值,用户对着空框保存就把已有配置清掉了。
-   */
-  const readSetting = (payload: unknown): { ok: boolean; path: string; message: string } => {
-    const res = (payload ?? {}) as { code?: number; message?: string; data?: unknown };
-    return {
-      ok: Number(res.code ?? 0) === 200,
-      path: typeof res.data === 'string' ? res.data.trim() : '',
-      message: res.message || '读取地图配置失败',
-    };
-  };
-
-  /**
-   * 每次打开都重新读一次服务端配置。
+   * 每次打开都重新读一次本机配置。
    *
    * <p>用 `getOpen` 而不是 `useModalInner(callback)`:抽屉每次都传同一个 data(openModal(true)),
    * 内部 dataTransfer 值没变化就不会再触发回调,第二次往后打开会读到上一次的残值。
@@ -79,17 +76,16 @@
   const [registerModal, { closeModal, getOpen }] = useModalInner();
 
   async function loadSetting() {
-    loading.value = true;
     path.value = '';
+    savedPath.value = '';
+    if (!desktop) return;
+    loading.value = true;
     try {
-      const parsed = readSetting(await getMapLocalApi());
-      if (!parsed.ok) {
-        createMessage.error(parsed.message);
-        return;
-      }
-      path.value = parsed.path;
+      const current = await getLocalMapDir();
+      path.value = current;
+      savedPath.value = current;
     } catch (error) {
-      createMessage.error(error instanceof Error ? error.message : '读取地图配置失败');
+      createMessage.error(error instanceof Error ? error.message : '读取本机地图配置失败');
     } finally {
       loading.value = false;
     }
@@ -110,23 +106,16 @@
 
   async function handleSave() {
     const next = path.value.trim();
-    // 空值代表清空配置(不启用离线地图);非空必须是绝对路径,否则后端存了也用不起来
-    if (next && !isValidOfflineMapPath(next)) {
-      createMessage.warning('请填写绝对路径,或点「选择目录」挑一个目录');
-      return;
-    }
     saving.value = true;
     try {
-      const res = await setMapLocalApi(next);
-      if (Number((res as { code?: number } | null)?.code ?? 0) !== 200) {
-        createMessage.error((res as { message?: string } | null)?.message || '保存地图配置失败');
-        return;
-      }
-      await initOfflineMapDirFromServer();
-      createMessage.success(next ? '地图目录已保存' : '已清空地图目录');
+      const saved = await saveLocalMapDir(next);
+      // 同步到前端缓存:地图组件监听该事件后会用新目录重新登记瓦片源
+      setOfflineMapDir(saved);
+      savedPath.value = saved;
+      createMessage.success(saved ? '地图目录已保存到本机' : '已清空地图目录');
       closeModal();
     } catch (error) {
-      createMessage.error(error instanceof Error ? error.message : '保存地图配置失败');
+      createMessage.error(error instanceof Error ? error.message : '保存失败');
     } finally {
       saving.value = false;
     }

+ 0 - 13
ai-electron/frontend/src/core/layouts/default/setting/components/SettingFooter.vue

@@ -5,11 +5,6 @@
       地图
     </a-button>
 
-    <a-button block @click="openCellLocalSettingModal(true)" class="mt-3">
-      <DatabaseOutlined class="mr-2" />
-      基站包
-    </a-button>
-
     <a-button block @click="openAuthorizationInfoModal(true)" class="mt-3">
       <SafetyCertificateOutlined class="mr-2" />
       授权信息
@@ -36,7 +31,6 @@
     </a-button>
 
     <MapLocalSettingModal @register="registerMapLocalSettingModal" />
-    <CellLocalSettingModal @register="registerCellLocalSettingModal" />
     <ProductInfoModal @register="registerProductInfoModal" />
     <AuthorizationInfoModal @register="registerAuthorizationInfoModal" />
   </div>
@@ -48,7 +42,6 @@
     CopyOutlined,
     RedoOutlined,
     EnvironmentOutlined,
-    DatabaseOutlined,
     InfoCircleOutlined,
     SafetyCertificateOutlined,
   } from '@ant-design/icons-vue';
@@ -72,7 +65,6 @@
   import { router } from '@/core/router';
   import { cleanCache } from '../api';
   import MapLocalSettingModal from './MapLocalSettingModal.vue';
-  import CellLocalSettingModal from './CellLocalSettingModal.vue';
   import ProductInfoModal from './ProductInfoModal.vue';
   import AuthorizationInfoModal from './AuthorizationInfoModal.vue';
 
@@ -82,11 +74,9 @@
       CopyOutlined,
       RedoOutlined,
       EnvironmentOutlined,
-      DatabaseOutlined,
       InfoCircleOutlined,
       SafetyCertificateOutlined,
       MapLocalSettingModal,
-      CellLocalSettingModal,
       ProductInfoModal,
       AuthorizationInfoModal,
     },
@@ -102,7 +92,6 @@
       const [registerProductInfoModal, { openModal: openProductInfoModal }] = useModal();
       const [registerAuthorizationInfoModal, { openModal: openAuthorizationInfoModal }] = useModal();
       const [registerMapLocalSettingModal, { openModal: openMapLocalSettingModal }] = useModal();
-      const [registerCellLocalSettingModal, { openModal: openCellLocalSettingModal }] = useModal();
       function handleCopy() {
         const { isSuccessRef } = useCopyToClipboard(JSON.stringify(unref(appStore.getProjectConfig), null, 2));
         unref(isSuccessRef) &&
@@ -152,8 +141,6 @@
         openAuthorizationInfoModal,
         registerMapLocalSettingModal,
         openMapLocalSettingModal,
-        registerCellLocalSettingModal,
-        openCellLocalSettingModal,
       };
     },
   });

+ 16 - 8
ai-electron/frontend/src/core/logics/caseConfigGuide.ts

@@ -1,21 +1,29 @@
-import { initCellLocalPathFromServer } from '@/core/utils/cell/cellLocalInit';
-import { initOfflineMapDirFromServer } from '@/core/utils/map/offlineMapInit';
+import { getLocalMapDir, isDesktopDialogAvailable } from '@/core/utils/desktop/pathPicker';
+import { setOfflineMapDir } from '@/core/utils/map/offlineMap';
 
 export const OPEN_SETTING_DRAWER_EVENT = 'layout-setting:open-drawer';
 
 let globalCaseConfigInitPromise: Promise<void> | null = null;
 
 /**
- * 初始化案件级全局配置:离线地图目录、基站包路径都从后端配置读取。
+ * 初始化案件级全局配置:把**本机**离线地图目录同步进前端缓存(地图组件从这里读)。
  *
- * <p>Web 化后不再通过 Electron 读写本机配置文件,服务端配置由运维通过
- * {@code /sys/mapLocal}、{@code /sys/cellLocal} 或 {@code QingJian/conf/c5e503ad.json} 设置。
+ * <p>目录由设置抽屉里的「地图设置」通过 Electron 存到客户端 local-settings.json,
+ * 不再读服务端配置;原先的基站包配置已移除。
  */
+async function initOfflineMapDirFromLocal() {
+  if (!isDesktopDialogAvailable()) return;
+  try {
+    const dir = await getLocalMapDir();
+    setOfflineMapDir(dir);
+  } catch {
+    // 读不到就按「未配置」处理,地图退回内置资源
+  }
+}
+
 export function initGlobalCaseConfig() {
   if (!globalCaseConfigInitPromise) {
-    globalCaseConfigInitPromise = Promise.all([initOfflineMapDirFromServer(), initCellLocalPathFromServer()])
-      .then(() => undefined)
-      .catch(() => undefined);
+    globalCaseConfigInitPromise = initOfflineMapDirFromLocal().catch(() => undefined);
   }
   return globalCaseConfigInitPromise;
 }

+ 0 - 80
ai-electron/frontend/src/core/utils/cell/cellLocal.ts

@@ -1,80 +0,0 @@
-const CELL_LOCAL_PATH_KEY = 'CELL_LOCAL_PATH__';
-const CELL_LOCAL_SERVER_PATH_KEY = 'CELL_LOCAL_SERVER_PATH__';
-export const CELL_LOCAL_PATH_CHANGED_EVENT = 'cell-local-path-changed';
-
-const FILE_PROTOCOL = 'file://';
-
-export function isValidCellLocalPath(value: string) {
-  const text = String(value || '')
-    .trim()
-    .replace(/[\\/]+$/, '');
-  if (!text) return false;
-  if (text === '[object Object]') return false;
-  if (text === 'null' || text === 'undefined') return false;
-  if (!/\.bin$/i.test(text)) return false;
-  return /^[a-zA-Z]:[\\/]/.test(text) || /^\\\\/.test(text) || text.startsWith('/') || text.startsWith(FILE_PROTOCOL);
-}
-
-function normalizePath(value: string) {
-  const text = String(value || '')
-    .trim()
-    .replace(/[\\/]+$/, '');
-  if (!isValidCellLocalPath(text)) return '';
-  return text;
-}
-
-export function getCellLocalPath() {
-  try {
-    const serverPath = normalizePath(window.localStorage.getItem(CELL_LOCAL_SERVER_PATH_KEY) || '');
-    if (serverPath) return serverPath;
-    return normalizePath(window.localStorage.getItem(CELL_LOCAL_PATH_KEY) || '');
-  } catch {
-    return '';
-  }
-}
-
-export function getManualCellLocalPath() {
-  try {
-    return normalizePath(window.localStorage.getItem(CELL_LOCAL_PATH_KEY) || '');
-  } catch {
-    return '';
-  }
-}
-
-export function getServerCellLocalPath() {
-  try {
-    return normalizePath(window.localStorage.getItem(CELL_LOCAL_SERVER_PATH_KEY) || '');
-  } catch {
-    return '';
-  }
-}
-
-export function setCellLocalPath(path: string) {
-  const normalized = normalizePath(path);
-  try {
-    if (!normalized) {
-      window.localStorage.removeItem(CELL_LOCAL_PATH_KEY);
-      window.dispatchEvent(new CustomEvent(CELL_LOCAL_PATH_CHANGED_EVENT, { detail: '' }));
-      return;
-    }
-    window.localStorage.setItem(CELL_LOCAL_PATH_KEY, normalized);
-    window.dispatchEvent(new CustomEvent(CELL_LOCAL_PATH_CHANGED_EVENT, { detail: normalized }));
-  } catch {}
-}
-
-export function clearCellLocalPath() {
-  setCellLocalPath('');
-}
-
-export function setServerCellLocalPath(path: string) {
-  const normalized = normalizePath(path);
-  try {
-    if (!normalized) {
-      window.localStorage.removeItem(CELL_LOCAL_SERVER_PATH_KEY);
-      window.dispatchEvent(new CustomEvent(CELL_LOCAL_PATH_CHANGED_EVENT, { detail: getCellLocalPath() }));
-      return;
-    }
-    window.localStorage.setItem(CELL_LOCAL_SERVER_PATH_KEY, normalized);
-    window.dispatchEvent(new CustomEvent(CELL_LOCAL_PATH_CHANGED_EVENT, { detail: normalized }));
-  } catch {}
-}

+ 0 - 53
ai-electron/frontend/src/core/utils/cell/cellLocalInit.ts

@@ -1,53 +0,0 @@
-import { getCellLocalApi } from '@/core/api/sys/cellLocal';
-import { setServerCellLocalPath } from './cellLocal';
-
-const normalizeText = (value: unknown) => String(value ?? '').trim();
-const isPlainObject = (value: unknown): value is Record<string, unknown> =>
-  Object.prototype.toString.call(value) === '[object Object]';
-
-const extractCellLocalPath = (payload: any) => {
-  const data = payload?.data ?? payload?.result ?? payload?.obj ?? payload;
-  if (typeof data === 'string') return normalizeText(data);
-  if (!isPlainObject(data)) return '';
-
-  const directPath = normalizeText(
-    data?.path ??
-      data?.cellPath ??
-      data?.cellLocal ??
-      data?.cellLocalPath ??
-      data?.baseStationPath ??
-      data?.localPath ??
-      data?.filePath,
-  );
-  if (directPath) return directPath;
-
-  const nestedCandidates = [data?.data, data?.result, data?.obj, data?.record, data?.row];
-  for (const candidate of nestedCandidates) {
-    if (!isPlainObject(candidate)) continue;
-    const nestedPath = normalizeText(
-      candidate.path ??
-        candidate.cellPath ??
-        candidate.cellLocal ??
-        candidate.cellLocalPath ??
-        candidate.baseStationPath ??
-        candidate.localPath ??
-        candidate.filePath,
-    );
-    if (nestedPath) return nestedPath;
-  }
-
-  return '';
-};
-
-export async function initCellLocalPathFromServer() {
-  try {
-    const res = await getCellLocalApi();
-    const code = Number((res as any)?.code ?? 0);
-    if (code !== 200) return '';
-    const path = extractCellLocalPath(res);
-    setServerCellLocalPath(path);
-    return path;
-  } catch {
-    return '';
-  }
-}

+ 32 - 0
ai-electron/frontend/src/core/utils/desktop/ipcRenderer.ts

@@ -0,0 +1,32 @@
+type IpcRendererLike = {
+  invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
+};
+
+/** 主进程实现见 ai-electron/electron/controller/osCtl.js(返回值不被 ee-core 包装) */
+const CHANNEL_PREFIX = 'controller/osCtl/';
+
+/**
+ * 两种桥都认:脚手架现状是 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用),
+ * 若后续启用 preload/bridge.ts 则走 window.electron。浏览器里两者都没有,返回 null。
+ */
+export function resolveIpcRenderer(): IpcRendererLike | null {
+  const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined;
+  if (bridge?.invoke) return bridge;
+  const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require;
+  try {
+    const renderer = nodeRequire?.('electron')?.ipcRenderer;
+    return renderer?.invoke ? renderer : null;
+  } catch {
+    return null;
+  }
+}
+
+/** 当前是否在桌面客户端里(浏览器里读不了本机文件、也存不了本机设置) */
+export const isDesktopDialogAvailable = () => !!resolveIpcRenderer();
+
+/** 调主进程 osCtl 方法;非桌面端抛错,调用方自己决定降级方式 */
+export async function invokeIpc<T>(method: string, params?: unknown): Promise<T> {
+  const ipcRenderer = resolveIpcRenderer();
+  if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
+  return (await ipcRenderer.invoke(`${CHANNEL_PREFIX}${method}`, params)) as T;
+}

+ 24 - 40
ai-electron/frontend/src/core/utils/desktop/pathPicker.ts

@@ -1,50 +1,34 @@
-type IpcRendererLike = {
-  invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
-};
+import { invokeIpc, isDesktopDialogAvailable, resolveIpcRenderer } from '@/core/utils/desktop/ipcRenderer';
 
-/** 主进程实现见 ai-electron/electron/controller/osCtl.js(返回值不被 ee-core 包装) */
-const CHANNEL_PREFIX = 'controller/osCtl/';
-
-/**
- * 两种桥都认:脚手架现状是 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用),
- * 若后续启用 preload/bridge.ts 则走 window.electron。浏览器里两者都没有,返回 null。
- */
-function resolveIpcRenderer(): IpcRendererLike | null {
-  const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined;
-  if (bridge?.invoke) return bridge;
-  const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require;
-  try {
-    const renderer = nodeRequire?.('electron')?.ipcRenderer;
-    return renderer?.invoke ? renderer : null;
-  } catch {
-    return null;
-  }
-}
-
-/** 当前是否在桌面客户端里(浏览器里选不了本机路径) */
-export const isDesktopDialogAvailable = () => !!resolveIpcRenderer();
-
-export interface PickFileOptions {
-  /** 对话框标题 */
-  title?: string;
-  /** 扩展名过滤(不带点),如 ['bin'] */
-  extensions?: string[];
-  /** 过滤器显示名,如 '基站数据库文件' */
-  filterName?: string;
-}
+export { isDesktopDialogAvailable };
 
 /** 弹出系统目录选择框;取消或非桌面端返回空串 */
 export async function pickDirectory(): Promise<string> {
   const ipcRenderer = resolveIpcRenderer();
   if (!ipcRenderer) return '';
-  const result = await ipcRenderer.invoke(`${CHANNEL_PREFIX}selectDirectory`);
+  const result = await ipcRenderer.invoke('controller/osCtl/selectDirectory');
   return typeof result === 'string' ? result : '';
 }
 
-/** 弹出系统文件选择框;取消或非桌面端返回空串 */
-export async function pickFile(options: PickFileOptions = {}): Promise<string> {
-  const ipcRenderer = resolveIpcRenderer();
-  if (!ipcRenderer) return '';
-  const result = await ipcRenderer.invoke(`${CHANNEL_PREFIX}selectFile`, options);
-  return typeof result === 'string' ? result : '';
+/** 主进程 getMapLocalPath / setMapLocalPath 的返回(见 electron/controller/osCtl.js) */
+type MapLocalResult = { success: true; data: { path: string } } | { success: false; message: string };
+
+/** 读取本机离线地图目录(存在客户端 local-settings.json,不走服务端);非桌面端返回空串 */
+export async function getLocalMapDir(): Promise<string> {
+  if (!isDesktopDialogAvailable()) return '';
+  try {
+    const result = await invokeIpc<MapLocalResult>('getMapLocalPath');
+    return result?.success ? result.data.path : '';
+  } catch {
+    return '';
+  }
+}
+
+/** 保存本机离线地图目录(空值表示清空);非法路径抛错,调用方负责提示 */
+export async function saveLocalMapDir(path: string): Promise<string> {
+  if (!isDesktopDialogAvailable()) throw new Error('当前不在桌面客户端内,无法保存本机路径');
+  const result = await invokeIpc<MapLocalResult>('setMapLocalPath', { path });
+  if (!result) throw new Error('桌面端无响应:setMapLocalPath');
+  if (!result.success) throw new Error(result.message || '保存失败');
+  return result.data.path;
 }

+ 99 - 46
ai-electron/frontend/src/core/utils/map/offlineMap.ts

@@ -1,38 +1,76 @@
-import { useGlobSetting } from '@/core/hooks/setting';
+import { isDesktopDialogAvailable, invokeIpc } from '@/core/utils/desktop/ipcRenderer';
 
 const OFFLINE_MAP_DIR_KEY = 'OFFLINE_MAP_DIR__';
-const OFFLINE_MAP_SERVER_DIR_KEY = 'OFFLINE_MAP_SERVER_DIR__';
 export const OFFLINE_MAP_DIR_CHANGED_EVENT = 'offline-map-dir-changed';
 const LOCAL_MAP_PROTOCOL = 'localmap://';
 const PMTILES_PROTOCOL = 'pmtiles://';
 
-/** 后端可达前缀(与 ai/utils/constants.ts 的 resolveApiUrl 同一套约定:apiUrl + urlPrefix + adminPath) */
-const buildApiUrl = (path: string) => {
-  const { apiUrl, urlPrefix, adminPath } = useGlobSetting();
-  const prefix = `${apiUrl || ''}${urlPrefix || ''}${adminPath || ''}`.replace(/\/+$/, '');
-  return `${prefix}${path.startsWith('/') ? path : `/${path}`}`;
+/** 前端静态资源目录里的兜底瓦片地址(public/resource/map/china.pmtiles,未配置本机目录时使用) */
+export const getBuiltinPmtilesUrl = () =>
+  new URL('resource/map/china.pmtiles', new URL(import.meta.env.BASE_URL || './', window.location.href)).toString();
+
+/** 本机离线地图文件路径(配置目录 + 文件名);未配置目录返回空串 */
+export function getOfflineMapFilePath(fileName: string) {
+  const dir = getOfflineMapDir();
+  if (!dir || !fileName) return '';
+  return `${dir.replace(/[\/]+$/, '')}/${fileName}`;
+}
+
+/** 瓦片文件(china.pmtiles)的本机绝对路径 */
+export const getOfflineMapPmtilesPath = () => getOfflineMapFilePath('china.pmtiles');
+
+/** 自定义样式文件的本机绝对路径 */
+export const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
+
+const base64ToArrayBuffer = (base64: string) => {
+  const binary = window.atob(base64);
+  const bytes = new Uint8Array(binary.length);
+  for (let index = 0; index < binary.length; index += 1) {
+    bytes[index] = binary.charCodeAt(index);
+  }
+  return bytes.buffer;
 };
 
 /**
- * 离线地图瓦片(china.pmtiles)的 Web 访问地址。
+ * 按本机路径分块读 PMTiles 的 Source 实现(恢复改造前的本机加载方式)。
  *
- * <p>Web 化改造:瓦片放在**服务端**离线地图目录(系统配置 mapLocal)里,
- * 由后端 `GET /sys/map/pmtiles` 支持 HTTP Range 按块返回,替代原 Electron 的 ipc 读取。
+ * <p>PMTiles 只要求实现 {@code getKey()} 与 {@code getBytes(offset, length)};地图样式里的
+ * source url 写成 {@code pmtiles://<本机绝对路径>},数据由主进程 IPC(osCtl.readBinaryFileRange)
+ * 按需从磁盘取块 —— 不经过服务端,也就不受「后端目录/后端 Range 实现」影响。
  */
-export const getOfflineMapPmtilesUrl = () => buildApiUrl('/sys/map/pmtiles');
+export class ElectronPmtilesSource {
+  filePath: string;
 
-/** 离线地图自定义样式(配置目录里的 behavior-spacetime-style.json)的访问地址 */
-export const getOfflineMapStyleUrl = () => buildApiUrl('/sys/map/style');
+  constructor(filePath: string) {
+    this.filePath = String(filePath || '').trim();
+  }
 
-/** 前端静态资源目录里的兜底瓦片地址(public/resource/map/china.pmtiles,未配置服务端目录时使用) */
-export const getBuiltinPmtilesUrl = () =>
-  new URL('resource/map/china.pmtiles', new URL(import.meta.env.BASE_URL || './', window.location.href)).toString();
+  getKey() {
+    return this.filePath;
+  }
+
+  async getBytes(offset: number, length: number) {
+    if (!this.filePath || !length || !isDesktopDialogAvailable()) {
+      return { data: new ArrayBuffer(0) };
+    }
+    try {
+      const result = await invokeIpc<
+        { success: true; data: { data: string; size: number } } | { success: false; message: string }
+      >('readBinaryFileRange', { filePath: this.filePath, offset, length });
+      if (!result?.success) return { data: new ArrayBuffer(0) };
+      const base64 = String(result.data?.data || '');
+      if (!base64 || !Number(result.data?.size || 0)) {
+        return { data: new ArrayBuffer(0) };
+      }
+      return { data: base64ToArrayBuffer(base64) };
+    } catch {
+      return { data: new ArrayBuffer(0) };
+    }
+  }
+}
 
 /**
- * 通过 HTTP Range 读取 PMTiles 的 Source 实现(替代原 ElectronPmtilesSource)。
- *
- * <p>PMTiles 只要求实现 {@code getKey()} 与 {@code getBytes(offset, length)};
- * 地图样式里的 source url 写成 {@code pmtiles://<本类的 url>} 即会按需分块拉取。
+ * 通过 HTTP Range 读取 PMTiles 的 Source 实现(仅用于前端静态资源里的兜底瓦片)。
  */
 export class HttpPmtilesSource {
   url: string;
@@ -63,9 +101,46 @@ export class HttpPmtilesSource {
   }
 }
 
-/** 注册离线地图瓦片源:配置了服务端目录用后端 Range 接口,否则退回前端静态资源 */
-export const resolveOfflinePmtilesUrls = () =>
-  [getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '', getBuiltinPmtilesUrl()].filter(Boolean);
+/** 本机绝对路径(盘符 / UNC / POSIX)才走 Electron 读盘,其余(http(s)、相对 URL)走 fetch */
+export const isLocalFilePath = (target: string) => {
+  const text = String(target || '').trim();
+  return /^[a-zA-Z]:[\\/]/.test(text) || text.startsWith('\\\\') || text.startsWith('/');
+};
+
+/** 本机绝对路径走 Electron 读盘,其余(内置静态资源 URL)走 HTTP Range */
+export const createPmtilesSource = (target: string) =>
+  isLocalFilePath(target) ? new ElectronPmtilesSource(target) : new HttpPmtilesSource(target);
+
+/**
+ * 离线地图瓦片来源列表:配了本机目录就用本机文件(key 为本机绝对路径),否则退回内置静态资源。
+ * key 与样式里 source url 的 {@code pmtiles://<key>} 必须一致,pmtiles Protocol 才认。
+ */
+export const resolveOfflinePmtilesSources = (): Array<{
+  key: string;
+  source: ElectronPmtilesSource | HttpPmtilesSource;
+}> => {
+  const sources: Array<{ key: string; source: ElectronPmtilesSource | HttpPmtilesSource }> = [];
+  const localPath = getOfflineMapPmtilesPath();
+  if (localPath) sources.push({ key: localPath, source: new ElectronPmtilesSource(localPath) });
+  const builtinUrl = getBuiltinPmtilesUrl();
+  sources.push({ key: builtinUrl, source: new HttpPmtilesSource(builtinUrl) });
+  return sources;
+};
+
+/** 读本机离线地图样式 JSON 文本;未配置目录/非桌面端/读失败返回空串(调用方回退内置样式) */
+export const readOfflineMapStyleText = async (): Promise<string> => {
+  const filePath = getOfflineMapStylePath();
+  if (!filePath || !isDesktopDialogAvailable()) return '';
+  try {
+    const result = await invokeIpc<{ success: true; data: { text: string } } | { success: false; message: string }>(
+      'readTextFile',
+      { filePath, encoding: 'utf8' },
+    );
+    return result?.success ? String(result.data?.text || '') : '';
+  } catch {
+    return '';
+  }
+};
 
 /** 本地路径是否可用(绝对路径 / UNC / file URL):设置页保存前用它拦掉明显无效的输入 */
 export function isValidOfflineMapPath(value: string) {
@@ -87,10 +162,9 @@ function normalizePath(value: string) {
   return text.replace(/[\\/]+$/, '');
 }
 
+/** 生效的离线地图目录:本机设置(Electron 保存的那份)同步过来的缓存值 */
 export function getOfflineMapDir() {
   try {
-    const serverDir = normalizePath(window.localStorage.getItem(OFFLINE_MAP_SERVER_DIR_KEY) || '');
-    if (serverDir) return serverDir;
     return normalizePath(window.localStorage.getItem(OFFLINE_MAP_DIR_KEY) || '');
   } catch {
     return '';
@@ -105,14 +179,6 @@ export function getManualOfflineMapDir() {
   }
 }
 
-export function getServerOfflineMapDir() {
-  try {
-    return normalizePath(window.localStorage.getItem(OFFLINE_MAP_SERVER_DIR_KEY) || '');
-  } catch {
-    return '';
-  }
-}
-
 export function setOfflineMapDir(path: string) {
   const normalized = normalizePath(path);
   try {
@@ -130,19 +196,6 @@ export function clearOfflineMapDir() {
   setOfflineMapDir('');
 }
 
-export function setServerOfflineMapDir(path: string) {
-  const normalized = normalizePath(path);
-  try {
-    if (!normalized) {
-      window.localStorage.removeItem(OFFLINE_MAP_SERVER_DIR_KEY);
-      window.dispatchEvent(new CustomEvent(OFFLINE_MAP_DIR_CHANGED_EVENT, { detail: getOfflineMapDir() }));
-      return;
-    }
-    window.localStorage.setItem(OFFLINE_MAP_SERVER_DIR_KEY, normalized);
-    window.dispatchEvent(new CustomEvent(OFFLINE_MAP_DIR_CHANGED_EVENT, { detail: normalized }));
-  } catch {}
-}
-
 export function buildFileUrl(filePath: string) {
   const normalized = normalizePath(filePath).replace(/\\/g, '/');
   if (!normalized) return '';

+ 0 - 46
ai-electron/frontend/src/core/utils/map/offlineMapInit.ts

@@ -1,46 +0,0 @@
-import { getMapLocalApi } from '@/core/api/sys/mapLocal';
-import { setServerOfflineMapDir } from './offlineMap';
-
-const normalizeText = (value: unknown) => String(value ?? '').trim();
-const isPlainObject = (value: unknown): value is Record<string, unknown> =>
-  Object.prototype.toString.call(value) === '[object Object]';
-
-const extractMapPath = (payload: any) => {
-  const data = payload?.data ?? payload?.result ?? payload?.obj ?? payload;
-  if (typeof data === 'string') return normalizeText(data);
-  if (!isPlainObject(data)) return '';
-
-  const directPath = normalizeText(
-    data?.path ?? data?.mapPath ?? data?.maplocal ?? data?.mapLocal ?? data?.localPath ?? data?.filePath,
-  );
-  if (directPath) return directPath;
-
-  const nestedCandidates = [data?.data, data?.result, data?.obj, data?.record, data?.row];
-  for (const candidate of nestedCandidates) {
-    if (!isPlainObject(candidate)) continue;
-    const nestedPath = normalizeText(
-      candidate.path ??
-        candidate.mapPath ??
-        candidate.maplocal ??
-        candidate.mapLocal ??
-        candidate.localPath ??
-        candidate.filePath,
-    );
-    if (nestedPath) return nestedPath;
-  }
-
-  return '';
-};
-
-export async function initOfflineMapDirFromServer() {
-  try {
-    const res = await getMapLocalApi();
-    const code = Number((res as any)?.code ?? 0);
-    if (code !== 200) return '';
-    const path = extractMapPath(res);
-    setServerOfflineMapDir(path);
-    return path;
-  } catch {
-    return '';
-  }
-}

+ 16 - 18
ai-electron/frontend/src/trans/views/aiBaseStation/components/BaseStationQueryMap.vue

@@ -12,11 +12,11 @@
   import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
   import { useDesign } from '@/core/hooks/web/useDesign';
   import {
-    HttpPmtilesSource,
+    getOfflineMapPmtilesPath,
+    readOfflineMapStyleText,
+    resolveOfflinePmtilesSources,
     getOfflineMapDir,
-    getOfflineMapPmtilesUrl,
-    getOfflineMapStyleUrl,
-    resolveOfflinePmtilesUrls,
+    getOfflineMapStylePath,
     OFFLINE_MAP_DIR_CHANGED_EVENT,
     toLocalMapUrl,
   } from '@/core/utils/map/offlineMap';
@@ -104,8 +104,8 @@
 
   const ensureOfflinePmtilesArchive = () => {
     if (!pmtilesProtocol) return;
-    resolveOfflinePmtilesUrls().forEach((url) => {
-      if (!pmtilesProtocol?.get(url)) pmtilesProtocol?.add(new PMTiles(new HttpPmtilesSource(url) as any));
+    resolveOfflinePmtilesSources().forEach(({ key, source }) => {
+      if (!pmtilesProtocol?.get(key)) pmtilesProtocol?.add(new PMTiles(source as any));
     });
   };
 
@@ -151,15 +151,15 @@
 
   const buildExternalMapStyle = () => {
     const offlineMapDir = getOfflineMapDir();
-    const pmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-    if (!offlineMapDir || !pmtilesUrl) return null;
+    const pmtilesFilePath = getOfflineMapPmtilesPath();
+    if (!offlineMapDir || !pmtilesFilePath) return null;
     return {
       version: 8,
       name: 'base-station-query-offline-style',
       sources: {
         'behavior-basemap': {
           type: 'vector',
-          url: `${PMTILES_PROTOCOL}${pmtilesUrl}`,
+          url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
         },
       },
       layers: [
@@ -226,9 +226,7 @@
   const readOfflineMapStyle = async () => {
     if (!getOfflineMapDir()) return '';
     try {
-      const response = await fetch(getOfflineMapStyleUrl(), { cache: 'no-store' });
-      if (!response.ok) return '';
-      return await response.text();
+      return await readOfflineMapStyleText();
     } catch {
       return '';
     }
@@ -237,12 +235,12 @@
   const resolveStyle = async () => {
     const customStyle = (window as BaseStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
     try {
-      const offlinePmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-      const offlineStyleUrl = getOfflineMapStyleUrl();
-      if (getOfflineMapDir() && offlinePmtilesUrl && offlineStyleUrl) {
+      const offlinePmtilesFilePath = getOfflineMapPmtilesPath();
+      const offlineStylePath = getOfflineMapStylePath();
+      if (getOfflineMapDir() && offlinePmtilesFilePath && offlineStylePath) {
         const styleText = await readOfflineMapStyle();
         if (styleText) {
-          return normalizePmtilesSources(JSON.parse(styleText), offlineStyleUrl, offlinePmtilesUrl);
+          return normalizePmtilesSources(JSON.parse(styleText), offlineStylePath, offlinePmtilesFilePath);
         }
       }
 
@@ -252,14 +250,14 @@
       }
 
       if (customStyle && typeof customStyle === 'object') {
-        return normalizePmtilesSources(customStyle, window.location.href, offlinePmtilesUrl || undefined);
+        return normalizePmtilesSources(customStyle, window.location.href, offlinePmtilesFilePath || undefined);
       }
 
       const styleUrl = resolveStyleAssetUrl(String(customStyle), window.location.href);
       const response = await fetch(styleUrl, { cache: 'no-store' });
       if (!response.ok) throw new Error(`style request failed: ${response.status}`);
       const style = await response.json();
-      return normalizePmtilesSources(style, response.url || styleUrl, offlinePmtilesUrl || undefined);
+      return normalizePmtilesSources(style, response.url || styleUrl, offlinePmtilesFilePath || undefined);
     } catch (error) {
       errorMsg.value = error instanceof Error ? error.message : 'map style error';
       return getFallbackStyle();

+ 16 - 18
ai-electron/frontend/src/trans/views/behaviorSpacetime/index.vue

@@ -316,11 +316,11 @@
   } from '@/otg/utils/timeSeriesJump';
   import { getPersons } from '@/person/api/personApi';
   import {
-    HttpPmtilesSource,
+    getOfflineMapPmtilesPath,
+    readOfflineMapStyleText,
+    resolveOfflinePmtilesSources,
     getOfflineMapDir,
-    getOfflineMapPmtilesUrl,
-    getOfflineMapStyleUrl,
-    resolveOfflinePmtilesUrls,
+    getOfflineMapStylePath,
     OFFLINE_MAP_DIR_CHANGED_EVENT,
     toLocalMapUrl,
   } from '@/core/utils/map/offlineMap';
@@ -2422,8 +2422,8 @@
   };
   const ensureOfflinePmtilesArchive = () => {
     if (!pmtilesProtocol) return;
-    resolveOfflinePmtilesUrls().forEach((url) => {
-      if (!pmtilesProtocol?.get(url)) pmtilesProtocol?.add(new PMTiles(new HttpPmtilesSource(url) as any));
+    resolveOfflinePmtilesSources().forEach(({ key, source }) => {
+      if (!pmtilesProtocol?.get(key)) pmtilesProtocol?.add(new PMTiles(source as any));
     });
   };
   const getFallbackStyle = () => ({
@@ -2433,15 +2433,15 @@
   });
   const buildExternalMapStyle = () => {
     const offlineMapDir = getOfflineMapDir();
-    const pmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-    if (!offlineMapDir || !pmtilesUrl) return null;
+    const pmtilesFilePath = getOfflineMapPmtilesPath();
+    if (!offlineMapDir || !pmtilesFilePath) return null;
     return {
       version: 8,
       name: 'behavior-spacetime-external-map',
       sources: {
         'behavior-basemap': {
           type: 'vector',
-          url: `${PMTILES_PROTOCOL}${pmtilesUrl}`,
+          url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
         },
       },
       layers: [
@@ -2523,9 +2523,7 @@
   const readOfflineMapStyle = async () => {
     if (!getOfflineMapDir()) return '';
     try {
-      const response = await fetch(getOfflineMapStyleUrl(), { cache: 'no-store' });
-      if (!response.ok) return '';
-      return await response.text();
+      return await readOfflineMapStyleText();
     } catch {
       return '';
     }
@@ -2565,15 +2563,15 @@
     const custom = (window as BehaviorSpacetimeWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
     try {
       const offlineMapDir = getOfflineMapDir();
-      const offlinePmtilesUrl = getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '';
-      const offlineStyleUrl = getOfflineMapStyleUrl();
-      if (offlineMapDir && offlinePmtilesUrl && offlineStyleUrl) {
+      const offlinePmtilesFilePath = getOfflineMapPmtilesPath();
+      const offlineStylePath = getOfflineMapStylePath();
+      if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
         const styleText = await readOfflineMapStyle();
         if (styleText) {
           const style = JSON.parse(styleText);
           mapMode.value = 'template';
           errorMsg.value = '';
-          return normalizePmtilesSources(style as Record<string, any>, offlineStyleUrl, offlinePmtilesUrl);
+          return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
         }
       }
       const externalStyle = buildExternalMapStyle();
@@ -2585,7 +2583,7 @@
       if (custom && typeof custom === 'object') {
         mapMode.value = 'template';
         errorMsg.value = '';
-        return normalizePmtilesSources(custom, window.location.href, offlinePmtilesUrl || undefined);
+        return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
       }
       const styleUrl = resolveStyleAssetUrl(String(custom), window.location.href);
       const response = await fetch(styleUrl, { cache: 'no-store' });
@@ -2596,7 +2594,7 @@
       return normalizePmtilesSources(
         style as Record<string, any>,
         response.url || styleUrl,
-        offlinePmtilesUrl || undefined,
+        offlinePmtilesFilePath || undefined,
       );
     } catch (error) {
       errorMsg.value = error instanceof Error ? error.message : 'style error';

+ 98 - 0
ai-electron/scripts/codex-ipc-probe.html

@@ -172,6 +172,104 @@
         );
         await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
 
+        // 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
+        const MAP_DIR_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/scripts';
+        const MAP_STYLE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
+        // 先记下运行前的配置,末尾恢复,保证探针不改动使用者机器上的设置
+        const mapOriginal = ((await osCall('getMapLocalPath')) || {}).data?.path || '';
+        await check('osCtl.getMapLocalPath', () => osCall('getMapLocalPath'), expectData((d) => typeof d.path === 'string'));
+        await check(
+          'osCtl.setMapLocalPath 相对路径被拒',
+          () => osCall('setMapLocalPath', { path: 'relative/maps' }),
+          expectFailure(/绝对路径/),
+        );
+        await check(
+          'osCtl.setMapLocalPath 不存在的目录被拒',
+          () => osCall('setMapLocalPath', { path: 'E:/no-such-dir/maps' }),
+          expectFailure(/目录不存在/),
+        );
+        await check(
+          'osCtl.setMapLocalPath 文件被拒',
+          () => osCall('setMapLocalPath', { path: MAP_STYLE_SAMPLE }),
+          expectFailure(/请选择目录/),
+        );
+        await check(
+          'osCtl.setMapLocalPath 保存真实目录',
+          () => osCall('setMapLocalPath', { path: MAP_DIR_SAMPLE }),
+          expectData((d) => d.path === MAP_DIR_SAMPLE),
+        );
+        await check(
+          'osCtl.getMapLocalPath 回读一致',
+          () => osCall('getMapLocalPath'),
+          expectData((d) => d.path === MAP_DIR_SAMPLE),
+        );
+        await check(
+          'osCtl.setMapLocalPath 清空(空值)',
+          async () => {
+            await osCall('setMapLocalPath', { path: '' });
+            const after = ((await osCall('getMapLocalPath')) || {}).data || {};
+            return { success: true, data: { path: after.path } };
+          },
+          expectData((d) => d.path === ''),
+        );
+        await check(
+          'osCtl.setMapLocalPath 恢复运行前配置',
+          async () => {
+            if (mapOriginal) await osCall('setMapLocalPath', { path: mapOriginal });
+            const after = ((await osCall('getMapLocalPath')) || {}).data || {};
+            return { success: true, data: { path: after.path, expected: mapOriginal } };
+          },
+          expectData((d) => d.path === d.expected),
+        );
+
+        // 离线地图的本机读取(恢复改造前方式):瓦片按偏移分块读、样式读文本
+        const MAP_FILE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
+        await check(
+          'osCtl.readBinaryFileRange 读前 16 字节',
+          () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 16 }),
+          expectData((d) => d.size === 16 && Buffer.from(d.data, 'base64').toString('utf8').trimStart().startsWith('{')),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 中段偏移',
+          () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 100, length: 32 }),
+          expectData((d) => d.size === 32 && d.fileSize > 100),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 超长自动收敛到文件末尾',
+          () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 99999999 }),
+          expectData((d) => d.size === d.fileSize),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 偏移越界返回空',
+          () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 99999999, length: 16 }),
+          expectData((d) => d.size === 0 && d.data === ''),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 相对路径被拒',
+          () => osCall('readBinaryFileRange', { filePath: 'relative/a.pmtiles', length: 16 }),
+          expectFailure(/绝对路径/),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 缺 length 被拒',
+          () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE }),
+          expectFailure(/长度不合法/),
+        );
+        await check(
+          'osCtl.readBinaryFileRange 不存在的文件被拒',
+          () => osCall('readBinaryFileRange', { filePath: 'E:/no-such-dir/a.pmtiles', length: 16 }),
+          expectFailure(/文件不存在/),
+        );
+        await check(
+          'osCtl.readTextFile 能读到样式文本',
+          () => osCall('readTextFile', { filePath: MAP_FILE_SAMPLE, encoding: 'utf8' }),
+          expectData((d) => String(d.text || '').includes('version')),
+        );
+        await check(
+          'osCtl.readTextFile 目录被拒',
+          () => osCall('readTextFile', { filePath: 'E:/workspace/zsjz-ai' }),
+          expectFailure(/不是文件/),
+        );
+
         fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
         const failed = results.filter((item) => !item.ok);
         pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;