cc 2 weeks ago
parent
commit
40f4ed44e2

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

@@ -133,32 +133,6 @@ 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);
   }

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

@@ -285,25 +285,6 @@ export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> {
   return codexInvoke<{ path: string }>('skillOpenFolder', { path });
 }
 
-/** 复用 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`);
 }

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

@@ -114,46 +114,6 @@ 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 },
-  );
-
 /**
  * 无外网授权激活(离线授权)。
  *

+ 4 - 0
ai-electron/frontend/src/core/components/Application/index.ts

@@ -9,6 +9,7 @@ import appLocalePicker from './src/AppLocalePicker.vue';
 import appDarkModeToggle from './src/AppDarkModeToggle.vue';
 import appWorkbenchToggle from './src/AppWorkbenchToggle.vue';
 import appTopBar from './src/AppTopBar.vue';
+import appDownloadManager from './src/AppDownloadManager.vue';
 
 export { useAppProviderContext } from './src/useAppContext';
 
@@ -20,6 +21,9 @@ export const AppDarkModeToggle = withInstall(appDarkModeToggle);
 export const AppWorkbenchToggle = withInstall(appWorkbenchToggle);
 export const AppTopBar = withInstall(appTopBar);
 
+// 下载管理器(顶栏设置图标前):下载记录 + 下载目录设置
+export const AppDownloadManager = withInstall(appDownloadManager);
+
 // 无边框窗口的标题栏:与主界面 LayoutHeader 同款外观(logo + 操作区 + 窗口控制)
 export const AppHeader = withInstall(appHeader);
 

+ 5 - 1
ai-electron/frontend/src/core/layouts/default/header/index.vue

@@ -46,6 +46,9 @@
 
       <!-- <FullScreen v-if="getShowFullScreen" class="fullscreen-item" /> -->
 
+      <!-- 下载管理器:放在设置图标前面 -->
+      <AppDownloadManager />
+
       <SettingDrawer v-if="getShowSetting" />
     </template>
   </AppTopBar>
@@ -59,7 +62,7 @@
   import { useRoute } from 'vue-router';
   import { propTypes } from '@/core/utils/propTypes';
   import { Button, Tooltip } from 'ant-design-vue';
-  import { AppTopBar, AppWorkbenchToggle } from '@/core/components/Application';
+  import { AppDownloadManager, AppTopBar, AppWorkbenchToggle } from '@/core/components/Application';
   import { AppSearch } from '@/core/components/Application';
   import { Icon } from '@/core/components/Icon';
   import { PageEnum } from '@/core/enums/pageEnum';
@@ -100,6 +103,7 @@
       FullScreen,
       AppSearch,
       ErrorAction,
+      AppDownloadManager,
       SettingDrawer,
       Tooltip,
       Icon,

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

@@ -30,3 +30,19 @@ export async function invokeIpc<T>(method: string, params?: unknown): Promise<T>
   if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
   return (await ipcRenderer.invoke(`${CHANNEL_PREFIX}${method}`, params)) as T;
 }
+
+type IpcListener = (event: unknown, payload: unknown) => void;
+
+/** 订阅主进程推送,返回取消订阅函数;非桌面端返回空函数 */
+export function onIpc<T>(channel: string, handler: (payload: T) => void): () => void {
+  const ipcRenderer = resolveIpcRenderer() as
+    | (IpcRendererLike & {
+        on?: (c: string, l: IpcListener) => void;
+        removeListener?: (c: string, l: IpcListener) => void;
+      })
+    | null;
+  if (!ipcRenderer?.on) return () => undefined;
+  const listener: IpcListener = (_event, payload) => handler(payload as T);
+  ipcRenderer.on(channel, listener);
+  return () => ipcRenderer.removeListener?.(channel, listener);
+}

+ 23 - 0
ai-electron/frontend/src/core/utils/file/download.ts

@@ -2,6 +2,8 @@
 import type { AxiosResponse } from 'axios';
 import { dataURLtoBlob, urlToBase64 } from './base64Conver';
 import { defHttp } from '@/core/utils/http/axios';
+import { isDesktopDownloadAvailable, saveDownload } from '@/core/utils/desktop/downloader';
+import { useMessage } from '@/core/hooks/web/useMessage';
 
 /**
  * Download online pictures
@@ -38,6 +40,11 @@ export function downloadByBase64(buf: string, filename: string, mime?: string, b
 export function downloadByData(data: BlobPart, filename: string, mime?: string, bom?: BlobPart) {
   const blobData = typeof bom !== 'undefined' ? [bom, data] : [data];
   const blob = new Blob(blobData, { type: mime || 'application/octet-stream' });
+  // 桌面端:交给主进程写到「下载目录」(可配置、有下载记录、能直接打开),不再用浏览器另存
+  if (isDesktopDownloadAvailable()) {
+    void saveBlobToManager(blob, filename);
+    return;
+  }
   const nav = window.navigator as any;
   if (typeof nav.msSaveBlob !== 'undefined') {
     nav.msSaveBlob(blob, filename);
@@ -227,3 +234,19 @@ export async function resolveDownloadErrorMessage(error: any, fallback = '文件
   if (/timeout/i.test(raw)) return '请求超时,请稍后重试';
   return raw || fallback;
 }
+
+/** 桌面端下载:把 blob 交给主进程落盘,并提示结果(下载记录在「下载管理器」里) */
+async function saveBlobToManager(blob: Blob, fileName: string) {
+  const { createMessage } = useMessage();
+  try {
+    const item = await saveDownload({
+      fileName,
+      mime: blob.type || 'application/octet-stream',
+      data: await blob.arrayBuffer(),
+      source: window.location.hash || window.location.pathname,
+    });
+    createMessage.success(`已下载:${item.fileName}`);
+  } catch (error) {
+    createMessage.error(error instanceof Error ? error.message : '下载失败');
+  }
+}

+ 30 - 0
ai-electron/frontend/src/core/utils/openOfficeFile.ts

@@ -1,5 +1,9 @@
 import { downloadFileApi } from '@/core/api/sys/login';
 import { resolveDownloadErrorMessage, saveBlobResponse } from '@/core/utils/file/download';
+import { isDesktopDownloadAvailable, startDownload } from '@/core/utils/desktop/downloader';
+import { useGlobSetting } from '@/core/hooks/setting';
+import { useMessage } from '@/core/hooks/web/useMessage';
+import { getToken } from '@/core/utils/auth';
 
 const normalizeOfficeFileKey = (value: string) =>
   value
@@ -58,7 +62,33 @@ export const resolveOfficeFileId = (source: unknown): string | undefined => {
  * @param fileId       文件/工作表ID(file_info.id)
  * @param fallbackName 响应头里拿不到文件名时的兜底名
  */
+/** 拼出后端下载接口的绝对地址(后台下载在主进程里跑,需要完整 URL 与鉴权头) */
+const buildDownloadUrl = (fileId: string | number) => {
+  const { apiUrl, urlPrefix, adminPath } = useGlobSetting();
+  const prefix = `${apiUrl || ''}${urlPrefix || ''}${adminPath || ''}`.replace(/\/+$/, '');
+  return `${prefix}/sys/downloadFile?fid=${encodeURIComponent(String(fileId))}`;
+};
+
 export const downloadOfficeFileById = async (fileId: string | number, fallbackName = '文件') => {
+  // 桌面端:交给主进程后台流式下载(能显示进度、不占渲染进程内存,下载记录里可打开)
+  if (isDesktopDownloadAvailable()) {
+    const { createMessage } = useMessage();
+    try {
+      const token = getToken();
+      const item = await startDownload({
+        url: buildDownloadUrl(fileId),
+        fileName: fallbackName,
+        headers: token ? { 'x-token': String(token) } : undefined,
+        source: '案件数据',
+      });
+      createMessage.success(`已开始下载:${item.fileName}`);
+      return;
+    } catch (error) {
+      throw new Error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
+    }
+  }
+
+  // 浏览器:沿用原来的「取 blob 再另存」
   try {
     const response = await downloadFileApi(fileId);
     await saveBlobResponse(response, fallbackName);

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

@@ -1,176 +0,0 @@
-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;
-  }
-}

+ 210 - 8
ai-electron/scripts/codex-ipc-probe.html

@@ -32,6 +32,8 @@
       const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
       /** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
       const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
+      /** 下载管理器 controller(controller/downloadCtl/*) */
+      const dlCall = (method, params) => ipcRenderer.invoke(`controller/downloadCtl/${method}`, params);
 
       function log(name, ok, detail) {
         results.push({ name, ok, detail });
@@ -162,14 +164,6 @@
         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'));
 
         // 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
@@ -270,6 +264,214 @@
           expectFailure(/不是文件/),
         );
 
+        // 下载管理器:落盘到可配置目录 + 清单 + 打开/移除(都在临时目录里做,不动使用者的文件)
+        const dlTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-dl-'));
+        const dirBefore = ((await dlCall('getDownloadDir')) || {}).data || {};
+        await check(
+          'downloadCtl.getDownloadDir',
+          () => dlCall('getDownloadDir'),
+          expectData((d) => typeof d.dir === 'string' && d.dir.length > 0),
+        );
+        await check(
+          'downloadCtl.setDownloadDir 文件被拒',
+          () => dlCall('setDownloadDir', { dir: MAP_FILE_SAMPLE }),
+          expectFailure(/请选择目录/),
+        );
+        await check(
+          'downloadCtl.setDownloadDir 不存在被拒',
+          () => dlCall('setDownloadDir', { dir: 'E:/no-such-dir/downloads' }),
+          expectFailure(/目录不存在/),
+        );
+        await check(
+          'downloadCtl.setDownloadDir 切到临时目录',
+          () => dlCall('setDownloadDir', { dir: dlTempDir }),
+          expectData((d) => d.dir === dlTempDir),
+        );
+        await check(
+          'downloadCtl.saveDownload 落盘',
+          async () => {
+            const saved = await dlCall('saveDownload', {
+              fileName: 'probe-download.txt',
+              mime: 'text/plain',
+              data: Buffer.from('hello-download'),
+              source: 'probe',
+            });
+            const path = saved?.data?.savedPath || '';
+            const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
+            return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
+          },
+          expectData((d) => d.fileName === 'probe-download.txt' && d.bytes === 14 && d.content === 'hello-download'),
+        );
+        await check(
+          'downloadCtl.listDownloads 含新记录且目录正确',
+          () => dlCall('listDownloads'),
+          expectData((d) => d.dir === dlTempDir && Array.isArray(d.items) && d.items[0]?.fileName === 'probe-download.txt'),
+        );
+        await check(
+          'downloadCtl.saveDownload 重名自动加序号',
+          () =>
+            dlCall('saveDownload', {
+              fileName: 'probe-download.txt',
+              mime: 'text/plain',
+              data: Buffer.from('second'),
+              source: 'probe',
+            }),
+          expectData((d) => d.fileName === 'probe-download (1).txt'),
+        );
+        await check(
+          'downloadCtl.saveDownload 支持直接传 ArrayBuffer',
+          async () => {
+            const bytes = new TextEncoder().encode('array-buffer-payload');
+            const saved = await dlCall('saveDownload', {
+              fileName: 'probe-arraybuffer.bin',
+              mime: 'application/octet-stream',
+              data: bytes.buffer,
+              source: 'probe',
+            });
+            const path = saved?.data?.savedPath || '';
+            const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
+            return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
+          },
+          expectData((d) => d.bytes === 20 && d.content === 'array-buffer-payload'),
+        );
+        await check(
+          'downloadCtl.openDownloadFile 记录不存在被拒',
+          () => dlCall('openDownloadFile', { id: 'no-such-id' }),
+          expectFailure(/记录不存在/),
+        );
+        await check(
+          'downloadCtl.removeDownload 移除一条',
+          async () => {
+            const list = ((await dlCall('listDownloads')) || {}).data || {};
+            const first = (list.items || [])[0];
+            await dlCall('removeDownload', { id: first?.id });
+            const after = ((await dlCall('listDownloads')) || {}).data || {};
+            return { success: true, data: { before: (list.items || []).length, after: (after.items || []).length } };
+          },
+          expectData((d) => d.before > 0 && d.after === d.before - 1),
+        );
+        await check(
+          'downloadCtl.clearDownloads 清空清单',
+          async () => {
+            await dlCall('clearDownloads');
+            const after = ((await dlCall('listDownloads')) || {}).data || {};
+            return { success: true, data: { count: (after.items || []).length } };
+          },
+          expectData((d) => d.count === 0),
+        );
+        await check(
+          'downloadCtl.setDownloadDir 恢复运行前配置',
+          async () => {
+            const expected = dirBefore.dir || '';
+            await dlCall('setDownloadDir', { dir: expected });
+            const after = ((await dlCall('getDownloadDir')) || {}).data || {};
+            return { success: true, data: { dir: after.dir, expected } };
+          },
+          expectData((d) => d.dir === d.expected),
+        );
+
+        // 后台下载 + 进度推送:探针页自己起一个"细水长流"的本地服务,验证进度事件与落盘
+        await check(
+          'downloadCtl.startDownload 后台下载并推送进度',
+          async () => {
+            const http = require('node:http');
+            const payload = Buffer.alloc(256 * 1024, 0x41);
+            const server = http.createServer((req, res) => {
+              res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(payload.length) });
+              // 分 16 片下发,每片 30ms:足够观察到进度
+              let offset = 0;
+              const timer = setInterval(() => {
+                if (offset >= payload.length) {
+                  clearInterval(timer);
+                  res.end();
+                  return;
+                }
+                res.write(payload.subarray(offset, offset + 16 * 1024));
+                offset += 16 * 1024;
+              }, 30);
+              req.on('close', () => clearInterval(timer));
+            });
+            await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+            const port = server.address().port;
+
+            const progresses = [];
+            const onProgress = (_event, item) => progresses.push(item);
+            ipcRenderer.on('downloadCtl:progress', onProgress);
+            const started = await dlCall('startDownload', {
+              url: `http://127.0.0.1:${port}/probe-background.bin`,
+              fileName: 'probe-background.bin',
+              source: 'probe',
+            });
+            const id = started?.data?.id;
+
+            // 等它下完(最多 6 秒)
+            let finalItem = null;
+            for (let i = 0; i < 60; i += 1) {
+              await new Promise((resolve) => setTimeout(resolve, 100));
+              const list = ((await dlCall('listDownloads')) || {}).data || {};
+              const found = (list.items || []).find((item) => item.id === id);
+              if (found && found.status !== 'downloading') {
+                finalItem = found;
+                break;
+              }
+            }
+            ipcRenderer.removeListener('downloadCtl:progress', onProgress);
+            server.close();
+
+            const sameId = progresses.filter((item) => item.id === id);
+            const increased = sameId.some((item, index) => index > 0 && item.received > (sameId[index - 1].received || 0));
+            const size = finalItem?.savedPath && fs.existsSync(finalItem.savedPath) ? fs.statSync(finalItem.savedPath).size : 0;
+            return {
+              status: 'ok',
+              success: true,
+              data: {
+                eventCount: sameId.length,
+                receivedIncreased: increased,
+                finalStatus: finalItem?.status || null,
+                size,
+                total: finalItem?.total || 0,
+              },
+            };
+          },
+          expectData((d) => d.eventCount >= 2 && d.receivedIncreased === true && d.finalStatus === 'done' && d.size === 256 * 1024),
+        );
+        await check(
+          'downloadCtl.startDownload 后端报错记为 failed',
+          async () => {
+            const http = require('node:http');
+            const server = http.createServer((req, res) => {
+              res.writeHead(404, { 'content-type': 'application/json' });
+              res.end(JSON.stringify({ code: 404, message: '文件不存在(探针)' }));
+            });
+            await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+            const port = server.address().port;
+            const started = await dlCall('startDownload', {
+              url: `http://127.0.0.1:${port}/missing.bin`,
+              fileName: 'probe-missing.bin',
+              source: 'probe',
+            });
+            const id = started?.data?.id;
+            let finalItem = null;
+            for (let i = 0; i < 30; i += 1) {
+              await new Promise((resolve) => setTimeout(resolve, 100));
+              const list = ((await dlCall('listDownloads')) || {}).data || {};
+              const found = (list.items || []).find((item) => item.id === id);
+              if (found && found.status !== 'downloading') {
+                finalItem = found;
+                break;
+              }
+            }
+            server.close();
+            return { success: true, data: { status: finalItem?.status || null, message: finalItem?.message || '' } };
+          },
+          expectData((d) => d.status === 'failed' && /文件不存在/.test(d.message)),
+        );
+        await check(
+          'downloadCtl.startDownload 空地址被拒',
+          () => dlCall('startDownload', { fileName: 'x.bin' }),
+          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}`;