cc 2 周之前
父节点
当前提交
6b5e9a90c9

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

@@ -1,4 +1,7 @@
+import { shell } from 'electron';
 import { os } from "../service/os/os";
+import path from 'node:path';
+import fs from 'node:fs';
 import { logger } from "ee-core/log";
 
 
@@ -70,6 +73,32 @@ class OsCtl {
   /**
    * 使用系统默认浏览器打开外部链接
    */
+  /**
+   * 打开本机文件(默认关联程序)。
+   *
+   * 只接受绝对路径,且必须真实存在 —— 调用方是渲染进程里「打开记录关联文件」这类
+   * 用户显式动作;路径可能来自后端 file_info.file_path。
+   *
+   * @param params { path } 本机绝对路径
+   * @returns { success, data?: { path }, message? }
+   */
+  async openPath(params) {
+    try {
+      const target = typeof params?.path === 'string' ? params.path.trim() : '';
+      if (!target) throw new Error('文件路径不能为空');
+      if (!path.isAbsolute(target)) throw new Error('只支持绝对路径');
+      if (!fs.existsSync(target)) throw new Error(`文件不存在:${target}`);
+      if (fs.statSync(target).isDirectory()) throw new Error('目标是目录,不是文件');
+      const message = await shell.openPath(target);
+      if (message) throw new Error(message);
+      return { success: true, data: { path: target } };
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      logger.warn('[osCtl] openPath failed:', msg);
+      return { success: false, message: msg };
+    }
+  }
+
   async openExternal(url) {
     return await os.openExternal(url);
   }

+ 18 - 0
ai-electron/frontend/src/codex/api/codexApi.ts

@@ -286,6 +286,24 @@ export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> {
 }
 
 /** 复用 ee-core 既有的目录选择对话框,取消时主进程返回空字符串 */
+/**
+ * 主进程 {@code osCtl.openPath} 的返回:与 electron/controller/osCtl.js 的两个返回分支一一对应。
+ */
+export type OsOpenPathResult = { success: true; data: { path: string } } | { success: false; message: string };
+
+/**
+ * 用系统默认程序打开本机文件(主进程 osCtl.openPath 会校验绝对路径与存在性)。
+ * 供「打开记录关联文件」这类用户显式动作使用。
+ */
+export async function openLocalPath(target: string): Promise<string> {
+  const result = await invokeRawChannel<OsOpenPathResult | undefined>(`${OS_CHANNEL_PREFIX}openPath`, {
+    path: target,
+  });
+  if (!result) throw new Error('桌面端无响应:openPath');
+  if (!result.success) throw new Error(result.message || '打开文件失败');
+  return result.data.path;
+}
+
 export function selectDirectory(): Promise<string> {
   return invokeRawChannel<string>(`${OS_CHANNEL_PREFIX}selectDirectory`);
 }

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

@@ -114,6 +114,46 @@ export const downloadFileApi = (fileId: string | number): Promise<AxiosResponse<
     { errorMessageMode: 'none', skipErrorMessage: true, isReturnNativeResponse: true },
   );
 
+/**
+ * /sys/getFilePath 成功响应:后端 {@code SystemService.DownloadFile} 记录。
+ *
+ * <p>字段与后端 record 一一对应({@code (Path path, String fileName)}):{@code path} 是
+ * {@code java.nio.file.Path} 的序列化结果 —— {@code file:///E:/xx.xlsx} 形式的 file URI,
+ * 需要转成本地路径再交给 Electron。
+ */
+export interface SysDownloadFile {
+  /** 服务端文件路径(file URI 形式),空表示后端没给出可用路径 */
+  path?: ApiText | null;
+  /** 原始文件名,仅用于展示 */
+  fileName?: ApiText | null;
+}
+
+/**
+ * /sys/getFilePath 失败响应:后端 {@code Result} 包装。
+ *
+ * <p>该接口成功时返回裸的 {@code DownloadFile} 记录,失败时才包 {@code Result}
+ * (如「文件不存在!」),所以用有无 {@code code} 字段区分两种形态。
+ */
+export interface SysResultFailure {
+  /** 后端状态码,出现该字段即视为失败分支 */
+  code: number;
+  message?: ApiText | null;
+}
+
+/** /sys/getFilePath 的两种响应形态(与后端 controller 的两个返回分支一致) */
+export type GetFilePathResult = SysDownloadFile | SysResultFailure;
+
+/**
+ * 按文件ID取服务端文件路径(记录上没有 filePath 时的兜底查法)。
+ *
+ * <p>关闭响应转换:两种形态都由调用方按类型判断,这里不抛错也不提示。
+ */
+export const getFilePathApi = (fileId: ApiId | number): Promise<GetFilePathResult | null> =>
+  defHttp.get<GetFilePathResult | null>(
+    { url: adminPath + '/sys/getFilePath', params: { fileId }, timeout: 30 * 1000 },
+    { errorMessageMode: 'none', skipErrorMessage: true, isTransformResponse: false },
+  );
+
 /**
  * 无外网授权激活(离线授权)。
  *

+ 176 - 0
ai-electron/frontend/src/core/utils/openRecordFile.ts

@@ -0,0 +1,176 @@
+import type { GetFilePathResult } from '@/core/api/sys/login';
+import { getFilePathApi } from '@/core/api/sys/login';
+import { resolveOfficeFileId } from '@/core/utils/openOfficeFile';
+
+/**
+ * 打开记录关联的源文件(Electron 本地打开,不做浏览器下载)。
+ *
+ * 解析顺序:
+ *  1. 记录上直接带文件路径(filePath / sourceFilePath / path)→ 直接交给 Electron 打开;
+ *  2. 没有路径 → 用 fileId 调后端 /sys/getFilePath 换取服务端路径;
+ *  3. 后端没返回路径 / 返回空 → 提示「文件不存在」并终止,不做任何后续动作。
+ *
+ * 说明:后端该接口返回的是 `java.nio.file.Path` 序列化结果(`file:///E:/xx.xlsx` 形式的
+ * file URI),这里统一转回本地绝对路径再交给 Electron。
+ */
+
+/** 记录上可能的文件路径字段(后端不同表命名不一致,按序取第一个非空) */
+const PATH_FIELDS = ['filePath', 'sourceFilePath', 'localPath', 'path'] as const;
+
+/** 行数据可能的嵌套字段:表格渲染回调与接口返回两种形态都会包一层 */
+const NESTED_FIELDS = ['raw', 'record', 'source', 'data'] as const;
+
+/**
+ * 「打开文件」用到的记录形状。
+ *
+ * <p>案件数据行的列由后端表头决定(同一行既可能是话单字段,也可能是勘察字段),
+ * 无法按某一张表穷举,所以这里只声明本功能会读的字段(对应 file_info 的
+ * {@code id/filePath/fileName} 与后端行数据里常见的路径别名),其余列留作动态字段。
+ */
+export interface OpenableFileRecord {
+  /** file_info.id:记录上没有路径时用它回查后端(字段名大小写/下划线各异,由 resolveOfficeFileId 识别)。
+   *  后端 id 是 19 位 Long,超过 Number.MAX_SAFE_INTEGER,行数据里必须保持字符串形态。 */
+  fileId?: ApiId | number | null;
+  /** 服务端文件路径(后端返回 file URI,也可能直接是本地绝对路径) */
+  filePath?: ApiText | null;
+  sourceFilePath?: ApiText | null;
+  localPath?: ApiText | null;
+  path?: ApiText | null;
+  /** 原始文件名,仅用于展示 */
+  fileName?: ApiText | null;
+  /** 嵌套形态:raw / record / source / data 里可能再放一份行数据 */
+  raw?: OpenableFileRecord | null;
+  record?: OpenableFileRecord | null;
+  source?: OpenableFileRecord | null;
+  data?: OpenableFileRecord | null;
+  /** 其余列由后端决定,不参与本功能判断 */
+  [column: string]: unknown;
+}
+
+/** 提示级别:失败(后端报错/IPC 抛错)用 error,可预期的缺失用 warning */
+export type OpenRecordFileNotifyLevel = 'error' | 'warning';
+
+export interface OpenRecordFileHooks {
+  /** 提示(失败/不存在都走这里,调用方决定用哪种消息组件) */
+  notify: (message: string, level: OpenRecordFileNotifyLevel) => void;
+  /** 真正打开本地路径(Electron IPC),失败时抛错 */
+  openLocalPath: (path: string) => Promise<void>;
+}
+
+/** 只有普通对象才可能是承载行数据的记录(数组 / 原始值都不看) */
+function isOpenableFileRecord(value: unknown): value is OpenableFileRecord {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** 读记录上的字符串字段(非字符串一律当空处理) */
+function readText(source: OpenableFileRecord, key: string): string {
+  const value = source[key];
+  return typeof value === 'string' ? value.trim() : '';
+}
+
+/** 读记录上嵌套的行数据 */
+function readNested(source: OpenableFileRecord, key: string): OpenableFileRecord | null {
+  const value = source[key];
+  return isOpenableFileRecord(value) ? value : null;
+}
+
+/** 后端失败分支(带 code 的 Result 包装)与成功记录(裸 DownloadFile)靠 code 字段区分 */
+function isResultFailure(payload: GetFilePathResult | null): payload is Extract<GetFilePathResult, { code: number }> {
+  return payload !== null && 'code' in payload;
+}
+
+/** catch 到的东西是 unknown,统一收敛成可展示的文案 */
+function toErrorMessage(error: unknown, fallback: string): string {
+  return error instanceof Error && error.message ? error.message : fallback;
+}
+
+/** 从记录(含 raw/record/source/data 一层嵌套)里取文件路径 */
+export function resolveRecordFilePath(record: OpenableFileRecord | null | undefined): string {
+  const queue: OpenableFileRecord[] = record ? [record] : [];
+  const visited = new Set<OpenableFileRecord>();
+  while (queue.length) {
+    const current = queue.shift();
+    if (!current || visited.has(current)) continue;
+    visited.add(current);
+    for (const field of PATH_FIELDS) {
+      const value = readText(current, field);
+      if (value) return value;
+    }
+    for (const field of NESTED_FIELDS) {
+      const nested = readNested(current, field);
+      if (nested) queue.push(nested);
+    }
+  }
+  return '';
+}
+
+/**
+ * file URI / 普通路径 → 本地绝对路径。
+ * 后端返回 `file:///E:/a/b.xlsx`(还会做百分号编码),Electron 的 shell.openPath 只认本地路径。
+ */
+export function toLocalPath(raw: ApiText | null | undefined): string {
+  const value = (raw ?? '').trim();
+  if (!value) return '';
+  if (!/^file:\/\//iu.test(value)) return value;
+  try {
+    const url = new URL(value);
+    let pathname = decodeURIComponent(url.pathname);
+    // Windows: /E:/dir/a.xlsx → E:/dir/a.xlsx;UNC 保持 //
+    if (/^\/[A-Za-z]:/u.test(pathname)) pathname = pathname.slice(1);
+    return pathname;
+  } catch {
+    return '';
+  }
+}
+
+/**
+ * 打开记录关联文件;返回是否真的发起了打开动作(便于调用方做 UI 反馈)。
+ */
+export async function openRecordFile(
+  record: OpenableFileRecord | null | undefined,
+  hooks: OpenRecordFileHooks,
+): Promise<boolean> {
+  // 记录自带路径同样要归一化:后端把 Path 序列化成 file URI,而 Electron 只认本地路径
+  const directPath = toLocalPath(resolveRecordFilePath(record));
+  if (directPath) {
+    return openWithFeedback(directPath, hooks);
+  }
+
+  const fileId = resolveOfficeFileId(record);
+  if (!fileId) {
+    hooks.notify('当前记录没有关联的源文件,无法打开', 'warning');
+    return false;
+  }
+
+  let payload: GetFilePathResult | null = null;
+  try {
+    payload = await getFilePathApi(fileId);
+  } catch (error) {
+    hooks.notify(toErrorMessage(error, '获取文件路径失败'), 'error');
+    return false;
+  }
+
+  if (isResultFailure(payload)) {
+    // 后端明确说了原因(如「文件不存在!」):直接用它的原话
+    hooks.notify(payload.message?.trim() || '文件不存在', 'warning');
+    return false;
+  }
+
+  const path = toLocalPath(payload?.path);
+  if (!path) {
+    // 后端查到了记录但没有可用路径:如实提示,不做后续动作
+    hooks.notify('文件不存在', 'warning');
+    return false;
+  }
+  return openWithFeedback(path, hooks);
+}
+
+async function openWithFeedback(path: string, hooks: OpenRecordFileHooks): Promise<boolean> {
+  try {
+    await hooks.openLocalPath(path);
+    return true;
+  } catch (error) {
+    hooks.notify(toErrorMessage(error, '打开文件失败'), 'error');
+    return false;
+  }
+}

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

@@ -30,6 +30,8 @@
       const pre = document.getElementById('out');
 
       const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
+      /** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
+      const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
 
       function log(name, ok, detail) {
         results.push({ name, ok, detail });
@@ -160,6 +162,14 @@
         await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
         await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
         await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
+        // 「打开记录关联文件」用的 IPC:只验参数校验分支(成功分支会真的弹起本机应用)
+        await check('osCtl.openPath 空路径被拒', () => osCall('openPath', {}), expectFailure(/不能为空/));
+        await check('osCtl.openPath 相对路径被拒', () => osCall('openPath', { path: 'relative/a.xlsx' }), expectFailure(/绝对路径/));
+        await check(
+          'osCtl.openPath 不存在的文件被拒',
+          () => osCall('openPath', { path: 'E:/no-such-dir/no-such-file.xlsx' }),
+          expectFailure(/文件不存在/),
+        );
         await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
 
         fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');

+ 21 - 0
ai-server/src/main/java/com/zsjz/ai/module/plat/controller/SystemController.java

@@ -132,6 +132,27 @@ public class SystemController {
         return systemService.downloadFile(fid);
     }
 
+    /**
+     * 打开本地Office文件
+     * 支持CSV、XLS、XLSX格式文件
+     *
+     * @param filePath 文件路径
+     */
+    @GetMapping("/openLocalFile")
+    public void openLocalFile(String filePath) {
+        systemService.openLocalOfficeFile(filePath);
+    }
+
+    @GetMapping("/openOfficeFile")
+    public void openOfficeFile(Long fid) {
+        systemService.openOfficeFile(fid);
+    }
+
+    @GetMapping("/getFilePath")
+    public Result<SystemService.DownloadFile> getFilePath(String fileId) {
+        return Result.succeed(systemService.getFilePath(fileId));
+    }
+
     /**
      * 离线地图瓦片(PMTiles):从系统配置的离线地图目录按 HTTP Range 返回,供浏览器地图组件按块取瓦片。
      *

+ 82 - 3
ai-server/src/main/java/com/zsjz/ai/module/plat/service/SystemService.java

@@ -303,7 +303,9 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
         FileUtil.del(PathConst.ORI_UPGRADE_PATH);
     }
 
-    /** 可下载文件扩展名 → Content-Type */
+    /**
+     * 可下载文件扩展名 → Content-Type
+     */
     private static final Map<String, MediaType> DOWNLOAD_MEDIA_TYPES = Map.of(
             "csv", new MediaType("text", "csv", StandardCharsets.UTF_8),
             "xls", MediaType.parseMediaType("application/vnd.ms-excel"),
@@ -395,6 +397,79 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
     public record DownloadFile(Path path, String fileName) {
     }
 
+
+    public DownloadFile getFilePath(String fileId) {
+        FileInfo fileInfo = fileInfoMapper.selectById(fileId);
+        if (fileInfo == null) {
+            throw ServerException.spe("文件不存在!");
+        }
+        String filePath = fileInfo.getFilePath();
+        return new DownloadFile(Path.of(filePath), fileInfo.getFileName());
+    }
+
+
+    /**
+     * 打开本地Office文件(CSV、Excel等)
+     * 调用系统命令启动默认应用程序打开文件
+     *
+     * @param filePath 文件路径
+     */
+    public void openLocalOfficeFile(String filePath) {
+        if (StrUtil.isBlank(filePath)) {
+            throw ServerException.spe("文件路径不能为空!");
+        }
+        Path path = Path.of(filePath);
+        if (Files.notExists(path) || Files.isDirectory(path)) {
+            throw ServerException.spe("文件不存在!");
+        }
+        String suffix = FileUtil.getSuffix(path.toFile());
+        if (StrUtil.isBlank(suffix) || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
+            throw ServerException.spe("仅支持打开 csv、xls、xlsx 文件!");
+        }
+        try {
+            int exitCode = getExitCode(path);
+
+            if (exitCode != 0) {
+                log.error("打开文件失败,退出码: {}", exitCode);
+                throw ServerException.spe("打开失败,检查是否有可用的应用程序!");
+            }
+        } catch (Exception e) {
+            log.error("打开本地文件失败, filePath={}", filePath, e);
+            throw ServerException.spe("当前系统不支持打开该文件类型!");
+        }
+    }
+
+    private static int getExitCode(Path path) throws IOException, InterruptedException {
+        ProcessBuilder pb;
+
+        if (LicenseUtil.isWin()) {
+            // Windows
+            pb = new ProcessBuilder("cmd", "/c", "start", path.toAbsolutePath().toString());
+        } else {
+            // Linux 和其他 Unix-like 系统
+            pb = new ProcessBuilder("xdg-open", path.toAbsolutePath().toString());
+        }
+
+        // 合并错误流和输出流
+        pb.redirectErrorStream(true);
+
+        // 可选:直接输出到控制台
+        //pb.inheritIO();
+
+        Process process = pb.start();
+        return process.waitFor();
+    }
+
+    public void openOfficeFile(Long fid) {
+        FileInfo fileInfo = fileInfoMapper.selectById(fid);
+        if (fileInfo == null) {
+            throw ServerException.spe("文件不存在!");
+        }
+        String filePath = fileInfo.getFilePath();
+        openLocalOfficeFile(filePath);
+    }
+
+
     /**
      * 检查系统授权状态
      * 如果未授权则抛出异常
@@ -435,9 +510,13 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
         SystemConfManager.INSTANCE.set("mapLocal", path);
     }
 
-    /** 离线地图瓦片文件名(固定放在 mapLocal 配置的目录下) */
+    /**
+     * 离线地图瓦片文件名(固定放在 mapLocal 配置的目录下)
+     */
     private static final String MAP_PMTILES_FILE = "china.pmtiles";
-    /** 离线地图样式文件名(可选,放在 mapLocal 配置的目录下;没有则前端用内置样式) */
+    /**
+     * 离线地图样式文件名(可选,放在 mapLocal 配置的目录下;没有则前端用内置样式)
+     */
     private static final String MAP_STYLE_FILE = "behavior-spacetime-style.json";
 
     /**