cc 1 settimana fa
parent
commit
dc79fedd2c

+ 1 - 1
QingJian/conf/c5e503ad.json

@@ -1 +1 @@
-{"init":"Y","forceData":"N","mapLocal":"","version":"2"}
+{"init":"Y","forceData":"N","mapLocal":null,"version":"2"}

+ 6 - 0
ai-electron/electron/controller/osCtl.js

@@ -64,6 +64,12 @@ class OsCtl {
   async selectDirectory() {
     return await os.selectDirectory();
   }
+  /**
+   * 选择单个文件(返回文件路径;params: { title?, filterName?, extensions?: string[] })
+   */
+  async selectFile(params) {
+    return await os.selectFile(params);
+  }
   /**
    * 获取应用信息
    */

+ 18 - 0
ai-electron/electron/service/os/os.js

@@ -48,6 +48,24 @@ class Os {
     return result.filePaths[0];
   }
 
+  /**
+   * 选择单个本机文件(如基站包 .bin)。
+   * @param options { title?, filterName?, extensions?: string[] } 扩展名过滤,传 ['bin'] 即只列 .bin
+   */
+  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 '';
+    }
+    return result.filePaths[0];
+  }
+
   async getAppInfo() {
     return {
       version: app.getVersion(),

+ 12 - 3
ai-electron/frontend/src/core/api/sys/mapLocal.ts

@@ -15,8 +15,17 @@ export const getMapLocalApi = () =>
     { errorMessageMode: 'none', isTransformResponse: false, skipErrorMessage: true },
   );
 
-export const setMapLocalApi = (path: string) =>
-  defHttp.get<Result>(
-    { url: adminPath + '/sys/mapLocal', params: { path }, timeout: 10 * 1000 },
+/**
+ * 保存地图目录;传空值表示清空配置。
+ *
+ * <p>注意:本项目把空值统一序列化成字面量 {@code null} 发出去(见 http/axios/helper 的
+ * {@code normalizeEmptyRequestValue}),而后端 {@code SystemConfManager.set} 会把这个字面量
+ * 原样存成字符串 "null"。所以「清空」必须让参数整个不参与序列化,后端才会收到真正的 null。
+ */
+export const setMapLocalApi = (path?: string | null) => {
+  const target = String(path ?? '').trim();
+  return defHttp.get<Result>(
+    { url: adminPath + '/sys/mapLocal', params: target ? { path: target } : {}, timeout: 10 * 1000 },
     { errorMessageMode: 'none', isTransformResponse: false },
   );
+};

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

@@ -0,0 +1,174 @@
+<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>

+ 164 - 0
ai-electron/frontend/src/core/layouts/default/setting/components/MapLocalSettingModal.vue

@@ -0,0 +1,164 @@
+<template>
+  <BasicModal
+    v-bind="$attrs"
+    title="地图设置"
+    :width="620"
+    :centered="true"
+    :confirmLoading="saving"
+    okText="保存"
+    @register="registerModal"
+    @ok="handleSave"
+  >
+    <div :class="prefixCls">
+      <p class="map-local__tip">
+        离线地图瓦片目录(服务端路径):目录下放 <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">
+          <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>
+    </div>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup>
+  /**
+   * 地图设置:读写服务端离线地图目录(/sys/getMaplocal、/sys/mapLocal)。
+   *
+   * 保存成功后调用 initOfflineMapDirFromServer() 把新路径同步进本地缓存,
+   * 地图组件下一次取瓦片就用新目录,不需要刷新页面。
+   */
+  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 { 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';
+
+  const { prefixCls } = useDesign('map-local-setting');
+  const { createMessage } = useMessage();
+
+  const desktopPicker = isDesktopDialogAvailable();
+  const loading = ref(false);
+  const saving = ref(false);
+  const path = 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 值没变化就不会再触发回调,第二次往后打开会读到上一次的残值。
+   */
+  const [registerModal, { closeModal, getOpen }] = useModalInner();
+
+  async function loadSetting() {
+    loading.value = true;
+    path.value = '';
+    try {
+      const parsed = readSetting(await getMapLocalApi());
+      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 pickDirectory();
+    if (picked) path.value = picked;
+  }
+
+  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 ? '地图目录已保存' : '已清空地图目录');
+      closeModal();
+    } catch (error) {
+      createMessage.error(error instanceof Error ? error.message : '保存地图配置失败');
+    } finally {
+      saving.value = false;
+    }
+  }
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-map-local-setting';
+
+  .@{prefix-cls} {
+    .map-local__tip {
+      margin-bottom: 12px;
+      color: #4b5563;
+      line-height: 1.7;
+
+      code {
+        padding: 0 4px;
+        background: #f3f4f6;
+        border-radius: 4px;
+      }
+    }
+
+    .map-local__row {
+      display: flex;
+      gap: 8px;
+      align-items: center;
+    }
+
+    .map-local__hint {
+      margin: 10px 0 0;
+      font-size: 12px;
+      color: #9ca3af;
+    }
+  }
+</style>

+ 27 - 14
ai-electron/frontend/src/core/layouts/default/setting/components/SettingFooter.vue

@@ -1,8 +1,13 @@
 <template>
   <div :class="prefixCls">
-    <a-button block @click="openProductInfoModal(true)">
-      <InfoCircleOutlined class="mr-2" />
-      关于产品
+    <a-button block @click="openMapLocalSettingModal(true)">
+      <EnvironmentOutlined class="mr-2" />
+      地图
+    </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">
@@ -10,9 +15,9 @@
       授权信息
     </a-button>
 
-    <a-button block @click="openChangeCasePasswordModal(true)" class="mt-3">
-      <LockOutlined class="mr-2" />
-      修改密码
+    <a-button block @click="openProductInfoModal(true)" class="mt-3">
+      <InfoCircleOutlined class="mr-2" />
+      关于产品
     </a-button>
 
     <!-- <a-button type="primary" block @click="handleCopy">
@@ -30,7 +35,8 @@
       {{ t('layout.setting.clearBtn') }}
     </a-button>
 
-    <ChangeCasePasswordModal @register="registerChangeCasePasswordModal" />
+    <MapLocalSettingModal @register="registerMapLocalSettingModal" />
+    <CellLocalSettingModal @register="registerCellLocalSettingModal" />
     <ProductInfoModal @register="registerProductInfoModal" />
     <AuthorizationInfoModal @register="registerAuthorizationInfoModal" />
   </div>
@@ -41,7 +47,8 @@
   import {
     CopyOutlined,
     RedoOutlined,
-    LockOutlined,
+    EnvironmentOutlined,
+    DatabaseOutlined,
     InfoCircleOutlined,
     SafetyCertificateOutlined,
   } from '@ant-design/icons-vue';
@@ -64,7 +71,8 @@
   import defaultSetting from '@/core/settings/projectSetting';
   import { router } from '@/core/router';
   import { cleanCache } from '../api';
-  import ChangeCasePasswordModal from './ChangeCasePasswordModal.vue';
+  import MapLocalSettingModal from './MapLocalSettingModal.vue';
+  import CellLocalSettingModal from './CellLocalSettingModal.vue';
   import ProductInfoModal from './ProductInfoModal.vue';
   import AuthorizationInfoModal from './AuthorizationInfoModal.vue';
 
@@ -73,10 +81,12 @@
     components: {
       CopyOutlined,
       RedoOutlined,
-      LockOutlined,
+      EnvironmentOutlined,
+      DatabaseOutlined,
       InfoCircleOutlined,
       SafetyCertificateOutlined,
-      ChangeCasePasswordModal,
+      MapLocalSettingModal,
+      CellLocalSettingModal,
       ProductInfoModal,
       AuthorizationInfoModal,
     },
@@ -91,7 +101,8 @@
       const tabStore = useMultipleTabStore();
       const [registerProductInfoModal, { openModal: openProductInfoModal }] = useModal();
       const [registerAuthorizationInfoModal, { openModal: openAuthorizationInfoModal }] = useModal();
-      const [registerChangeCasePasswordModal, { openModal: openChangeCasePasswordModal }] = 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) &&
@@ -139,8 +150,10 @@
         openProductInfoModal,
         registerAuthorizationInfoModal,
         openAuthorizationInfoModal,
-        registerChangeCasePasswordModal,
-        openChangeCasePasswordModal,
+        registerMapLocalSettingModal,
+        openMapLocalSettingModal,
+        registerCellLocalSettingModal,
+        openCellLocalSettingModal,
       };
     },
   });

+ 50 - 0
ai-electron/frontend/src/core/utils/desktop/pathPicker.ts

@@ -0,0 +1,50 @@
+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。
+ */
+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 async function pickDirectory(): Promise<string> {
+  const ipcRenderer = resolveIpcRenderer();
+  if (!ipcRenderer) return '';
+  const result = await ipcRenderer.invoke(`${CHANNEL_PREFIX}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 : '';
+}

+ 2 - 1
ai-electron/frontend/src/core/utils/map/offlineMap.ts

@@ -67,7 +67,8 @@ export class HttpPmtilesSource {
 export const resolveOfflinePmtilesUrls = () =>
   [getOfflineMapDir() ? getOfflineMapPmtilesUrl() : '', getBuiltinPmtilesUrl()].filter(Boolean);
 
-function isValidOfflineMapPath(value: string) {
+/** 本地路径是否可用(绝对路径 / UNC / file URL):设置页保存前用它拦掉明显无效的输入 */
+export function isValidOfflineMapPath(value: string) {
   const text = String(value || '').trim();
   if (!text) return false;
   if (text === '[object Object]') return false;