|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|