cc 1 тиждень тому
батько
коміт
0534ddc9a4

+ 126 - 0
ai-electron/electron/controller/downloadCtl.js

@@ -0,0 +1,126 @@
+'use strict';
+
+import { getMainWindow } from 'ee-core/electron';
+import { downloadService } from '../service/download/downloadService';
+import { logger } from 'ee-core/log';
+
+/** 进度推送通道:界面订阅它刷新"下载中"的进度与状态 */
+export const DOWNLOAD_PROGRESS_CHANNEL = 'downloadCtl:progress';
+
+/** 把 in-flight 状态推给渲染进程(窗口还没建好时忽略) */
+function broadcast(item) {
+  try {
+    const win = getMainWindow();
+    if (win && !win.isDestroyed?.()) win.webContents.send(DOWNLOAD_PROGRESS_CHANNEL, item);
+  } catch {
+    // 窗口已关闭:忽略
+  }
+}
+
+/**
+ * 下载管理器 IPC:通道名 controller/downloadCtl/<方法>(ee-core 按文件名自动注册)。
+ *
+ * 统一返回项目约定 {success, data|message}:失败(路径非法/记录不存在/文件被移走)不抛到 Electron 层,
+ * 前端看 message 即可提示。
+ */
+class DownloadCtl {
+  /** 保存一次下载:params { fileName, mime?, data(ArrayBuffer|base64), source? } */
+  async saveDownload(params) {
+    try {
+      return { success: true, data: downloadService.save(params) };
+    } catch (e) {
+      return { success: false, message: this.message(e, '下载失败') };
+    }
+  }
+
+  /**
+   * 后台下载:主进程流式拉取 URL 落盘并推送进度。
+   * params { url, fileName?, headers?, source?, mime? }
+   */
+  async startDownload(params) {
+    try {
+      downloadService.setNotifier(broadcast);
+      return { success: true, data: downloadService.start(params) };
+    } catch (e) {
+      return { success: false, message: this.message(e, '下载失败') };
+    }
+  }
+
+  /** 下载清单 + 当前生效目录 */
+  async listDownloads() {
+    try {
+      return { success: true, data: { dir: downloadService.getDownloadDir(), items: downloadService.list() } };
+    } catch (e) {
+      return { success: false, message: this.message(e, '读取下载列表失败') };
+    }
+  }
+
+  async getDownloadDir() {
+    try {
+      return { success: true, data: { dir: downloadService.getDownloadDir() } };
+    } catch (e) {
+      return { success: false, message: this.message(e, '读取下载目录失败') };
+    }
+  }
+
+  /** 设置下载目录;params { dir } 传空恢复系统默认 */
+  async setDownloadDir(params) {
+    try {
+      return { success: true, data: { dir: downloadService.setDownloadDir(params?.dir) } };
+    } catch (e) {
+      return { success: false, message: this.message(e, '设置下载目录失败') };
+    }
+  }
+
+  /** 用系统默认程序打开已下载的文件 */
+  async openDownloadFile(params) {
+    try {
+      return { success: true, data: await downloadService.openFile(params?.id) };
+    } catch (e) {
+      return { success: false, message: this.message(e, '打开文件失败') };
+    }
+  }
+
+  /** 在文件管理器里定位已下载的文件 */
+  async revealDownload(params) {
+    try {
+      return { success: true, data: downloadService.revealFile(params?.id) };
+    } catch (e) {
+      return { success: false, message: this.message(e, '定位文件失败') };
+    }
+  }
+
+  /** 打开下载目录 */
+  async openDownloadDir() {
+    try {
+      return { success: true, data: await downloadService.openDir() };
+    } catch (e) {
+      return { success: false, message: this.message(e, '打开下载目录失败') };
+    }
+  }
+
+  /** 只从清单移除记录,不删磁盘上的文件 */
+  async removeDownload(params) {
+    try {
+      return { success: true, data: downloadService.remove(params?.id) };
+    } catch (e) {
+      return { success: false, message: this.message(e, '移除记录失败') };
+    }
+  }
+
+  async clearDownloads() {
+    try {
+      return { success: true, data: downloadService.clear() };
+    } catch (e) {
+      return { success: false, message: this.message(e, '清空记录失败') };
+    }
+  }
+
+  message(error, fallback) {
+    const msg = error instanceof Error ? error.message : String(error);
+    logger.warn('[downloadCtl]', fallback, msg);
+    return msg || fallback;
+  }
+}
+
+export default DownloadCtl;

+ 336 - 0
ai-electron/electron/service/download/downloadService.js

@@ -0,0 +1,336 @@
+'use strict';
+
+import { app, shell } from 'electron';
+import { getDataDir } from 'ee-core/ps';
+import { logger } from 'ee-core/log';
+import path from 'node:path';
+import fs from 'node:fs';
+import { randomUUID } from 'node:crypto';
+import { pipeline } from 'node:stream/promises';
+import { Readable, Transform } from 'node:stream';
+import { localSettingsService } from '../setting/localSettings';
+
+/** 本机设置里存下载目录的键名 */
+const DOWNLOAD_DIR_KEY = 'downloadDir';
+/** 下载清单:放在数据目录,不放进下载目录本身,免得混进用户的文件列表 */
+const MANIFEST_NAME = 'downloads.json';
+/** 清单最多保留多少条(文件仍在磁盘上,只是不再列出) */
+const MAX_ITEMS = 300;
+
+/**
+ * 下载服务:把渲染进程拿到的文件流写进**本机下载目录**,并维护一份下载清单。
+ *
+ * <p>为什么放在主进程:浏览器另存(<a download>)在桌面端会落到系统默认下载目录、也留不下记录;
+ * 这里统一落盘到可配置的目录,清单供「下载管理器」展示,并且能直接打开文件/所在目录。
+ */
+class DownloadService {
+  get manifestPath() {
+    return path.join(getDataDir(), MANIFEST_NAME);
+  }
+
+  /** 生效的下载目录:本机设置优先,未配置用系统「下载」目录 */
+  getDownloadDir() {
+    const configured = localSettingsService.get(DOWNLOAD_DIR_KEY);
+    if (configured) return configured;
+    try {
+      return app.getPath('downloads');
+    } catch {
+      return path.join(getDataDir(), 'downloads');
+    }
+  }
+
+  /** 设置下载目录(必须是已存在的绝对目录;传空表示恢复系统默认) */
+  setDownloadDir(dir) {
+    const target = typeof dir === 'string' ? dir.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('请选择目录,而不是文件');
+    }
+    localSettingsService.set(DOWNLOAD_DIR_KEY, target);
+    return this.getDownloadDir();
+  }
+
+  /**
+   * 启动时清理「僵死」的下载中记录:进程重启后没有下载线程了,
+   * 这类记录若继续显示"下载中"会一直转不完,统一标成失败说明原因。
+   */
+  reconcileOnce() {
+    if (this.reconciled) return;
+    this.reconciled = true;
+    const items = this.readManifest();
+    const stale = items.filter((item) => item.status === 'downloading');
+    if (!stale.length) return;
+    this.writeManifest(
+      items.map((item) =>
+        item.status === 'downloading' ? { ...item, status: 'failed', message: '应用重启,下载已中断' } : item,
+      ),
+    );
+  }
+
+  readManifest() {
+    try {
+      const file = this.manifestPath;
+      if (!fs.existsSync(file)) return [];
+      const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
+      return Array.isArray(parsed?.items) ? parsed.items.filter((item) => item && typeof item.id === 'string') : [];
+    } catch (e) {
+      logger.warn('[download] 读取清单失败:', e instanceof Error ? e.message : String(e));
+      return [];
+    }
+  }
+
+  list() {
+    this.reconcileOnce();
+    return this.readManifest().map((item) => ({
+      ...item,
+      exists: !!item.savedPath && fs.existsSync(item.savedPath),
+    }));
+  }
+
+  writeManifest(items) {
+    const file = this.manifestPath;
+    fs.mkdirSync(path.dirname(file), { recursive: true });
+    fs.writeFileSync(file, `${JSON.stringify({ items: items.slice(0, MAX_ITEMS) }, null, 2)}\n`, 'utf8');
+  }
+
+  /** 文件名只保留纯名字,挡掉路径分隔符与相对路径,避免写出目录之外 */
+  sanitizeFileName(fileName) {
+    const base = String(fileName || '').split(/[\\/]/).pop() || '';
+    const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, '_').trim();
+    return cleaned || `download-${Date.now()}`;
+  }
+
+  /** 重名时自动加 (1)(2)…,不覆盖用户已有文件 */
+  resolveUniquePath(dir, fileName) {
+    const ext = path.extname(fileName);
+    const stem = fileName.slice(0, fileName.length - ext.length);
+    let candidate = path.join(dir, fileName);
+    let index = 0;
+    while (fs.existsSync(candidate)) {
+      index += 1;
+      candidate = path.join(dir, `${stem} (${index})${ext}`);
+    }
+    return candidate;
+  }
+
+  /**
+   * 保存一次下载。
+   * @param params { fileName: string, mime?: string, data: ArrayBuffer|Uint8Array|string(base64), source?: string }
+   */
+  save(params = {}) {
+    const fileName = this.sanitizeFileName(params?.fileName);
+    const raw = params?.data;
+    let buffer;
+    if (typeof raw === 'string') {
+      buffer = Buffer.from(raw, 'base64');
+    } else if (raw instanceof Uint8Array) {
+      buffer = Buffer.from(raw);
+    } else if (raw instanceof ArrayBuffer) {
+      buffer = Buffer.from(new Uint8Array(raw));
+    } else if (ArrayBuffer.isView(raw)) {
+      buffer = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
+    } else {
+      throw new Error('下载内容为空或格式不支持');
+    }
+    if (!buffer.length) throw new Error('下载内容为空');
+
+    const dir = this.getDownloadDir();
+    fs.mkdirSync(dir, { recursive: true });
+    const target = this.resolveUniquePath(dir, fileName);
+    fs.writeFileSync(target, buffer);
+
+    const item = {
+      id: randomUUID(),
+      fileName: path.basename(target),
+      savedPath: target,
+      dir,
+      bytes: buffer.length,
+      status: 'done',
+      received: buffer.length,
+      total: buffer.length,
+      mime: typeof params?.mime === 'string' ? params.mime : '',
+      source: typeof params?.source === 'string' ? params.source : '',
+      at: new Date().toISOString(),
+    };
+    this.writeManifest([item, ...this.list()]);
+    logger.info('[download] 已保存:', target);
+    return item;
+  }
+
+  /** 进度回调:由 controller 接到 webContents.send 上,把 in-flight 状态推给界面 */
+  setNotifier(notifier) {
+    this.notifier = typeof notifier === 'function' ? notifier : null;
+  }
+
+  notify(item) {
+    try {
+      this.notifier?.(item);
+    } catch (e) {
+      logger.warn('[download] 进度推送失败:', e instanceof Error ? e.message : String(e));
+    }
+  }
+
+  /** 更新清单里的一条记录(不动其它条目),并把最新状态推给界面 */
+  patchItem(id, patch) {
+    const items = this.list();
+    const index = items.findIndex((item) => item.id === id);
+    if (index < 0) return null;
+    const next = { ...items[index], ...patch };
+    items[index] = next;
+    this.writeManifest(items);
+    this.notify(next);
+    return next;
+  }
+
+  /**
+   * 后台下载:主进程直接拉取 URL 落盘,边下边报进度(渲染进程不承担大文件内存与阻塞)。
+   *
+   * <p>先写 {@code <名字>.part},下完再改名,避免下载中就被当成完整文件用;
+   * 同名文件自动加 (1)(2),不覆盖已有文件。
+   *
+   * @param params { url, fileName?, headers?, source?, mime? }
+   */
+  start(params = {}) {
+    const url = String(params?.url || '').trim();
+    if (!url) throw new Error('下载地址为空');
+    if (!this.notifier) logger.warn('[download] 未设置进度回调,界面拿不到进度');
+
+    const fileName = this.sanitizeFileName(params?.fileName || '');
+    const dir = this.getDownloadDir();
+    fs.mkdirSync(dir, { recursive: true });
+
+    const item = {
+      id: randomUUID(),
+      fileName,
+      savedPath: '',
+      dir,
+      bytes: 0,
+      status: 'downloading',
+      received: 0,
+      total: 0,
+      mime: typeof params?.mime === 'string' ? params.mime : '',
+      source: typeof params?.source === 'string' ? params.source : '',
+      at: new Date().toISOString(),
+    };
+    this.writeManifest([item, ...this.list()]);
+    this.notify(item);
+
+    // 后台跑,不阻塞调用方;失败也只是把这条记录标成 failed
+    void this.run(item, url, params);
+    return item;
+  }
+
+  async run(item, url, params) {
+    const partPath = path.join(item.dir, `${item.fileName}.part`);
+    let lastNotify = 0;
+    try {
+      const response = await fetch(url, { headers: (params?.headers || {}) });
+      if (!response.ok) {
+        // 后端报错时响应体是 JSON({code,message}),尽量把原因带出来
+        let message = `下载失败:HTTP ${response.status}`;
+        try {
+          const text = await response.text();
+          const parsed = JSON.parse(text);
+          if (parsed?.message) message = parsed.message;
+        } catch {
+          // 不是 JSON 就沿用状态码
+        }
+        throw new Error(message);
+      }
+
+      const total = Number(response.headers.get('content-length') || 0);
+      this.patchItem(item.id, { total, mime: response.headers.get('content-type') || item.mime });
+
+      if (response.body) {
+        const counter = new Transform({
+          transform: (chunk, _encoding, callback) => {
+            item.received += chunk.length;
+            const now = Date.now();
+            if (now - lastNotify > 150) {
+              lastNotify = now;
+              this.patchItem(item.id, { received: item.received });
+            }
+            callback(null, chunk);
+          },
+        });
+        await pipeline(Readable.fromWeb(response.body), counter, fs.createWriteStream(partPath));
+      } else {
+        const buffer = Buffer.from(await response.arrayBuffer());
+        await fs.promises.writeFile(partPath, buffer);
+        item.received = buffer.length;
+      }
+
+      const target = this.resolveUniquePath(item.dir, item.fileName);
+      await fs.promises.rename(partPath, target);
+      const size = fs.statSync(target).size;
+      const finished = this.patchItem(item.id, {
+        status: 'done',
+        savedPath: target,
+        fileName: path.basename(target),
+        bytes: size,
+        received: size,
+        total: size,
+      });
+      logger.info('[download] 已保存:', target);
+      return finished;
+    } catch (e) {
+      const message = e instanceof Error ? e.message : String(e);
+      await fs.promises.rm(partPath, { force: true }).catch(() => undefined);
+      logger.warn('[download] 下载失败:', message);
+      return this.patchItem(item.id, { status: 'failed', message });
+    }
+  }
+
+  find(id) {
+    const item = this.list().find((entry) => entry.id === id);
+    if (!item) throw new Error('下载记录不存在');
+    return item;
+  }
+
+  remove(id) {
+    const items = this.list();
+    const next = items.filter((item) => item.id !== id);
+    if (next.length === items.length) throw new Error('下载记录不存在');
+    this.writeManifest(next);
+    return { removed: true };
+  }
+
+  clear() {
+    const count = this.list().length;
+    this.writeManifest([]);
+    return { cleared: count };
+  }
+
+  /** 用系统默认程序打开已下载的文件 */
+  async openFile(id) {
+    const item = this.find(id);
+    if (!fs.existsSync(item.savedPath)) {
+      throw new Error(`文件已被移动或删除:${item.savedPath}`);
+    }
+    const message = await shell.openPath(item.savedPath);
+    if (message) throw new Error(message);
+    return { path: item.savedPath };
+  }
+
+  /** 在文件管理器里定位到已下载的文件 */
+  revealFile(id) {
+    const item = this.find(id);
+    if (!fs.existsSync(item.savedPath)) {
+      throw new Error(`文件已被移动或删除:${item.savedPath}`);
+    }
+    shell.showItemInFolder(item.savedPath);
+    return { path: item.savedPath };
+  }
+
+  /** 打开下载目录本身 */
+  async openDir() {
+    const dir = this.getDownloadDir();
+    fs.mkdirSync(dir, { recursive: true });
+    const message = await shell.openPath(dir);
+    if (message) throw new Error(message);
+    return { path: dir };
+  }
+}
+
+export const downloadService = new DownloadService();

+ 710 - 0
ai-electron/frontend/src/core/components/Application/src/AppDownloadManager.vue

@@ -0,0 +1,710 @@
+<template>
+  <div
+    :class="['jeesite-header-action-button', `${prefixCls}-trigger`, { 'is-busy': activeCount > 0 }]"
+    :title="activeCount > 0 ? `正在下载 ${activeCount} 个文件` : '下载管理'"
+    @click="openModal(true)"
+  >
+    <span class="dl-trigger__icon"><Icon icon="i-mdi:download-outline" /></span>
+    <span v-if="activeCount > 0" class="dl-trigger__badge">{{ activeCount }}</span>
+    <span v-else-if="items.length" class="dl-trigger__dot"></span>
+  </div>
+
+  <BasicModal v-bind="$attrs" title="下载管理" :width="720" :centered="true" :footer="null" @register="registerModal">
+    <div :class="prefixCls">
+      <!-- 保存位置:一行紧凑展示 -->
+      <div class="dl-path">
+        <span class="dl-path__icon"><FolderOutlined /></span>
+        <span class="dl-path__label">保存到</span>
+        <span class="dl-path__value" :title="downloadDir">{{ downloadDir || '未获取到下载目录' }}</span>
+        <span class="dl-path__actions">
+          <button class="dl-text-btn" type="button" :disabled="!desktop || busy" @click="handleChangeDir">更改</button>
+          <button class="dl-text-btn" type="button" :disabled="!desktop || busy" @click="handleOpenDir">打开</button>
+        </span>
+      </div>
+
+      <!-- 下载记录 -->
+      <header class="dl-head">
+        <span class="dl-head__title">下载记录</span>
+        <span class="dl-head__count">{{ items.length }}</span>
+        <span class="dl-head__spacer"></span>
+        <Tooltip title="刷新列表">
+          <button class="dl-icon-btn" type="button" :disabled="!desktop || busy" @click="handleRefresh">
+            <ReloadOutlined />
+          </button>
+        </Tooltip>
+        <Tooltip title="清空记录(不删文件)">
+          <button
+            class="dl-icon-btn is-danger"
+            type="button"
+            :disabled="!desktop || !items.length || busy"
+            @click="handleClear"
+          >
+            <DeleteOutlined />
+          </button>
+        </Tooltip>
+      </header>
+
+      <div v-if="!desktop" class="dl-blank">
+        <DesktopOutlined class="dl-blank__icon" />
+        <div class="dl-blank__title">当前不在桌面客户端内</div>
+        <div class="dl-blank__desc">下载记录保存在本机,请在桌面客户端里查看与管理。</div>
+      </div>
+
+      <div v-else-if="!items.length" class="dl-blank">
+        <InboxOutlined class="dl-blank__icon" />
+        <div class="dl-blank__title">还没有下载记录</div>
+        <div class="dl-blank__desc">下载文件、导出报告后,会按上面的保存位置存到本机并出现在这里。</div>
+      </div>
+
+      <ul v-else class="dl-list">
+        <li v-for="item in items" :key="item.id" class="dl-item">
+          <div class="dl-item__icon" :class="`is-${fileKind(item.fileName)}`">
+            <SyncOutlined v-if="item.status === 'downloading'" spin />
+            <component :is="fileIcon(item.fileName)" v-else />
+          </div>
+
+          <div class="dl-item__main">
+            <div class="dl-item__name" :title="item.savedPath || item.fileName">{{ item.fileName }}</div>
+
+            <!-- 下载中:进度条 + 百分比 -->
+            <div v-if="item.status === 'downloading'" class="dl-item__progress">
+              <span class="dl-item__bar">
+                <span class="dl-item__bar-fill" :style="{ width: `${progressPercent(item)}%` }"></span>
+              </span>
+              <span class="dl-item__percent">{{ progressText(item) }}</span>
+            </div>
+
+            <div v-else-if="item.status === 'failed'" class="dl-item__meta is-failed">
+              <span>{{ item.message || '下载失败' }}</span>
+            </div>
+
+            <div v-else class="dl-item__meta">
+              <span>{{ formatSize(item.bytes) }}</span>
+              <span class="dl-item__dot">·</span>
+              <span>{{ formatTime(item.at) }}</span>
+              <span v-if="item.exists === false" class="dl-item__missing">
+                <ExclamationCircleOutlined />
+                文件已不在原位置
+              </span>
+            </div>
+          </div>
+
+          <!-- 行操作:文字按钮(下载中的记录不给「打开/所在目录」) -->
+          <div class="dl-item__actions">
+            <button
+              class="dl-text-btn"
+              type="button"
+              :disabled="busy || item.status === 'downloading'"
+              @click="handleOpenFile(item)"
+            >
+              打开
+            </button>
+            <button
+              class="dl-text-btn"
+              type="button"
+              :disabled="busy || item.status === 'downloading'"
+              @click="handleReveal(item)"
+            >
+              所在目录
+            </button>
+            <button class="dl-text-btn is-danger" type="button" :disabled="busy" @click="handleRemove(item)">
+              移除
+            </button>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="AppDownloadManager">
+  /**
+   * 下载管理器:弹框形式列出本机下载记录,支持打开文件 / 定位文件 / 移除记录与更改保存位置。
+   *
+   * <p>记录由主进程维护(见 electron/service/download/downloadService.js):页面里的下载都经
+   * core/utils/file/download.ts 落到「保存位置」这个目录,这里只是它的界面。入口在顶栏设置图标前。
+   */
+  import { computed, onBeforeUnmount, ref, unref, watch } from 'vue';
+  import { Tooltip } from 'ant-design-vue';
+  import {
+    DeleteOutlined,
+    DesktopOutlined,
+    ExclamationCircleOutlined,
+    FileExcelOutlined,
+    FileImageOutlined,
+    FilePdfOutlined,
+    FileTextOutlined,
+    FileWordOutlined,
+    FileZipOutlined,
+    FolderOpenOutlined,
+    FolderOutlined,
+    InboxOutlined,
+    ReloadOutlined,
+    SyncOutlined,
+  } from '@ant-design/icons-vue';
+  import { BasicModal, useModal } from '@/core/components/Modal';
+  import { Icon } from '@/core/components/Icon';
+  import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { pickDirectory } from '@/core/utils/desktop/pathPicker';
+  import {
+    clearDownloads,
+    isDesktopDownloadAvailable,
+    listDownloads,
+    openDownloadDir,
+    openDownloadFile,
+    onDownloadProgress,
+    removeDownload,
+    revealDownload,
+    setDownloadDir,
+    type DownloadItem,
+  } from '@/core/utils/desktop/downloader';
+
+  const { prefixCls } = useDesign('app-download-manager');
+  const { createMessage, createConfirm } = useMessage();
+  const [registerModal, { openModal, closeModal, getOpen }] = useModal();
+
+  const desktop = isDesktopDownloadAvailable();
+  const busy = ref(false);
+  const downloadDir = ref('');
+  const items = ref<DownloadItem[]>([]);
+
+  /** 正在下载的条数:>0 时顶栏图标做动画 */
+  const activeCount = computed(() => items.value.filter((item) => item.status === 'downloading').length);
+
+  /** 进度百分比;服务端没给总大小时按"已下载多少"显示,不编造百分比 */
+  const progressPercent = (item: DownloadItem) => {
+    const total = Number(item.total) || 0;
+    const received = Number(item.received) || 0;
+    if (!total) return received > 0 ? 100 : 0;
+    return Math.min(100, Math.round((received / total) * 100));
+  };
+
+  const progressText = (item: DownloadItem) => {
+    const total = Number(item.total) || 0;
+    if (!total) return `已接收 ${formatSize(Number(item.received) || 0)}`;
+    return `${progressPercent(item)}%(${formatSize(Number(item.received) || 0)} / ${formatSize(total)})`;
+  };
+
+  /**
+   * 订阅主进程的进度推送:下载中/完成/失败都会来。
+   * 新记录(弹框没打开时开始的下载)也会在这里进列表,所以顶栏小圆点/动画一直准。
+   */
+  const stopProgress = onDownloadProgress((item) => {
+    if (!item?.id) return;
+    const index = items.value.findIndex((entry) => entry.id === item.id);
+    if (index >= 0) {
+      items.value[index] = { ...items.value[index], ...item };
+      return;
+    }
+    items.value = [item, ...items.value];
+  });
+
+  onBeforeUnmount(() => stopProgress());
+
+  /** 打开弹框时刷一次;关闭时也顺带取一次,用来决定顶栏入口要不要亮小圆点 */
+  watch(
+    () => unref(getOpen),
+    (open) => {
+      void refresh();
+    },
+    { immediate: false },
+  );
+  void refresh();
+
+  async function refresh() {
+    if (!desktop) return;
+    if (unref(getOpen)) busy.value = true;
+    try {
+      const result = await listDownloads();
+      downloadDir.value = result.dir;
+      items.value = result.items;
+    } catch (error) {
+      // 顶栏入口上的静默拉取失败不打扰用户;弹框打开时的失败要提示
+      if (unref(getOpen)) createMessage.error(error instanceof Error ? error.message : '读取下载列表失败');
+    } finally {
+      busy.value = false;
+    }
+  }
+
+  const handleRefresh = () => void refresh();
+
+  const formatSize = (bytes: number) => {
+    const size = Number(bytes) || 0;
+    if (size < 1024) return `${size} B`;
+    if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+    if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
+    return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
+  };
+
+  const formatTime = (at: string) => {
+    const date = new Date(at);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (value: number) => String(value).padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  };
+
+  /** 按扩展名归类,图标与底色跟着变(用 antd 图标组件,避免 UnoCSS 扫不到运行时拼的类名) */
+  const fileKind = (fileName: string) => {
+    const ext =
+      String(fileName || '')
+        .split('.')
+        .pop()
+        ?.toLowerCase() || '';
+    if (['xls', 'xlsx', 'csv'].includes(ext)) return 'excel';
+    if (['doc', 'docx'].includes(ext)) return 'word';
+    if (ext === 'pdf') return 'pdf';
+    if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) return 'image';
+    if (['zip', 'rar', '7z', 'gz', 'tar'].includes(ext)) return 'zip';
+    return 'file';
+  };
+
+  const fileIcon = (fileName: string) => {
+    const kind = fileKind(fileName);
+    if (kind === 'excel') return FileExcelOutlined;
+    if (kind === 'word') return FileWordOutlined;
+    if (kind === 'pdf') return FilePdfOutlined;
+    if (kind === 'image') return FileImageOutlined;
+    if (kind === 'zip') return FileZipOutlined;
+    return FileTextOutlined;
+  };
+
+  async function handleChangeDir() {
+    const picked = await pickDirectory();
+    if (!picked) return;
+    busy.value = true;
+    try {
+      downloadDir.value = await setDownloadDir(picked);
+      createMessage.success('保存位置已更新');
+    } catch (error) {
+      createMessage.error(error instanceof Error ? error.message : '设置下载目录失败');
+    } finally {
+      busy.value = false;
+    }
+  }
+
+  async function handleOpenDir() {
+    try {
+      await openDownloadDir();
+    } catch (error) {
+      createMessage.error(error instanceof Error ? error.message : '打开下载目录失败');
+    }
+  }
+
+  async function handleOpenFile(item: DownloadItem) {
+    try {
+      await openDownloadFile(item.id);
+    } catch (error) {
+      // 文件被移动/删除时主进程会给出具体路径,顺便刷新一下列表状态
+      createMessage.error(error instanceof Error ? error.message : '打开文件失败');
+      void refresh();
+    }
+  }
+
+  async function handleReveal(item: DownloadItem) {
+    try {
+      await revealDownload(item.id);
+    } catch (error) {
+      createMessage.error(error instanceof Error ? error.message : '定位文件失败');
+    }
+  }
+
+  async function handleRemove(item: DownloadItem) {
+    try {
+      await removeDownload(item.id);
+      items.value = items.value.filter((entry) => entry.id !== item.id);
+    } catch (error) {
+      createMessage.error(error instanceof Error ? error.message : '移除记录失败');
+    }
+  }
+
+  function handleClear() {
+    createConfirm({
+      iconType: 'warning',
+      title: '清空下载记录',
+      content: '只清空列表,磁盘上的文件不会被删除。',
+      okText: '清空',
+      okType: 'primary',
+      okButtonProps: { danger: true },
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await clearDownloads();
+          items.value = [];
+          createMessage.success('已清空下载记录');
+        } catch (error) {
+          createMessage.error(error instanceof Error ? error.message : '清空记录失败');
+        }
+      },
+    });
+  }
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-app-download-manager';
+
+  .@{prefix-cls}-trigger {
+    position: relative;
+
+    /* 下载中:箭头上下轻跳,提示还有任务在跑 */
+    &.is-busy .dl-trigger__icon {
+      animation: dl-trigger-bob 1.1s ease-in-out infinite;
+    }
+
+    &__badge {
+      position: absolute;
+      top: 2px;
+      right: 3px;
+      min-width: 15px;
+      height: 15px;
+      padding: 0 4px;
+      font-size: 10px;
+      line-height: 15px;
+      color: #fff;
+      text-align: center;
+      background: #2f54eb;
+      border: 1px solid #fff;
+      border-radius: 8px;
+    }
+
+    &__dot {
+      position: absolute;
+      top: 9px;
+      right: 11px;
+      width: 6px;
+      height: 6px;
+      background: #2f54eb;
+      border: 1px solid #fff;
+      border-radius: 50%;
+    }
+  }
+
+  .@{prefix-cls} {
+    display: flex;
+    flex-direction: column;
+
+    /* 保存位置:一行,不占高度 */
+    .dl-path {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      height: 34px;
+      padding: 0 10px;
+      font-size: 12.5px;
+      background: #f6f8fc;
+      border: 1px solid #e8ecf4;
+      border-radius: 8px;
+
+      &__icon {
+        display: inline-flex;
+        font-size: 14px;
+        color: #2f54eb;
+        flex: 0 0 auto;
+      }
+
+      &__label {
+        color: #6b7280;
+        flex: 0 0 auto;
+      }
+
+      &__value {
+        min-width: 0;
+        overflow: hidden;
+        font-family: Consolas, Monaco, 'Courier New', monospace;
+        color: #374151;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        flex: 1 1 auto;
+      }
+
+      &__actions {
+        display: flex;
+        gap: 2px;
+        flex: 0 0 auto;
+      }
+    }
+
+    /* 纯文字小按钮(保存位置、行操作) */
+    .dl-text-btn {
+      padding: 0 8px;
+      font-size: 12px;
+      line-height: 22px;
+      color: #2f54eb;
+      background: transparent;
+      border: 0;
+      border-radius: 6px;
+      white-space: nowrap;
+      cursor: pointer;
+      transition: background 0.15s ease-out;
+
+      &:hover:not(:disabled) {
+        background: #e8eeff;
+      }
+
+      &.is-danger {
+        color: #d4380d;
+
+        &:hover:not(:disabled) {
+          background: #fdeceb;
+        }
+      }
+
+      &:disabled {
+        color: #b6bec9;
+        cursor: not-allowed;
+      }
+    }
+
+    /* 图标元素按钮(刷新 / 清空 / 行操作),与顶栏动作按钮同一观感 */
+    .dl-icon-btn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      font-size: 14px;
+      color: #5b6472;
+      background: transparent;
+      border: 0;
+      border-radius: 7px;
+      cursor: pointer;
+      transition:
+        background 0.15s ease-out,
+        color 0.15s ease-out;
+
+      &:hover:not(:disabled) {
+        color: #2f54eb;
+        background: #e8eeff;
+      }
+
+      &.is-danger:hover:not(:disabled) {
+        color: #d4380d;
+        background: #fdeceb;
+      }
+
+      &:disabled {
+        color: #c9cfd8;
+        cursor: not-allowed;
+      }
+    }
+
+    .dl-head {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      margin-top: 12px;
+      padding-bottom: 8px;
+      border-bottom: 1px solid #eef1f6;
+
+      &__title {
+        font-size: 13.5px;
+        font-weight: 600;
+        color: #111827;
+      }
+
+      &__count {
+        padding: 0 7px;
+        font-size: 11px;
+        line-height: 17px;
+        color: #4b5563;
+        background: #eef2f7;
+        border-radius: 9px;
+      }
+
+      &__spacer {
+        flex: 1 1 auto;
+      }
+    }
+
+    .dl-list {
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+      margin: 10px 0 0;
+      padding: 0 4px 0 0;
+      max-height: 46vh;
+      overflow: auto;
+      list-style: none;
+    }
+
+    .dl-item {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px 10px;
+      border: 1px solid transparent;
+      border-radius: 9px;
+      transition:
+        border-color 0.15s ease-out,
+        background 0.15s ease-out;
+
+      &:hover {
+        background: #f8fafd;
+        border-color: #e8eefb;
+      }
+
+      &__icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 34px;
+        height: 34px;
+        font-size: 16px;
+        border-radius: 9px;
+        flex: 0 0 auto;
+
+        &.is-excel {
+          color: #15803d;
+          background: #e8f7ee;
+        }
+
+        &.is-word {
+          color: #1d4ed8;
+          background: #e8efff;
+        }
+
+        &.is-pdf {
+          color: #b91c1c;
+          background: #fdecec;
+        }
+
+        &.is-image {
+          color: #7c3aed;
+          background: #f1ebfe;
+        }
+
+        &.is-zip {
+          color: #b45309;
+          background: #fdf1e3;
+        }
+
+        &.is-file {
+          color: #4b5563;
+          background: #eef1f6;
+        }
+      }
+
+      &__main {
+        min-width: 0;
+        flex: 1 1 auto;
+      }
+
+      &__name {
+        overflow: hidden;
+        font-size: 13px;
+        font-weight: 500;
+        color: #111827;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &__meta {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-top: 2px;
+        font-size: 11.5px;
+        color: #9ca3af;
+
+        &.is-failed {
+          color: #d4380d;
+        }
+      }
+
+      /* 下载进度 */
+      &__progress {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin-top: 4px;
+      }
+
+      &__bar {
+        position: relative;
+        height: 4px;
+        overflow: hidden;
+        background: #eef2f7;
+        border-radius: 3px;
+        flex: 1 1 auto;
+      }
+
+      &__bar-fill {
+        display: block;
+        height: 100%;
+        background: linear-gradient(90deg, #4f7cf7 0%, #2f54eb 100%);
+        border-radius: 3px;
+        transition: width 0.2s ease-out;
+      }
+
+      &__percent {
+        flex: 0 0 auto;
+        font-size: 11px;
+        color: #2f54eb;
+      }
+
+      &__dot {
+        color: #d1d5db;
+      }
+
+      &__missing {
+        display: inline-flex;
+        align-items: center;
+        gap: 3px;
+        padding: 0 6px;
+        font-size: 11px;
+        line-height: 16px;
+        color: #b45309;
+        background: #fdf1e3;
+        border-radius: 8px;
+      }
+
+      &__actions {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        flex: 0 0 auto;
+      }
+    }
+
+    @keyframes dl-trigger-bob {
+      0%,
+      100% {
+        transform: translateY(0);
+      }
+
+      50% {
+        transform: translateY(2px);
+      }
+    }
+
+    .dl-blank {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 6px;
+      padding: 44px 0 34px;
+
+      &__icon {
+        margin-bottom: 4px;
+        font-size: 38px;
+        color: #cbd5e1;
+      }
+
+      &__title {
+        font-size: 13.5px;
+        font-weight: 600;
+        color: #4b5563;
+      }
+
+      &__desc {
+        max-width: 320px;
+        font-size: 12px;
+        line-height: 1.7;
+        color: #9ca3af;
+        text-align: center;
+      }
+    }
+  }
+</style>

+ 112 - 0
ai-electron/frontend/src/core/utils/desktop/downloader.ts

@@ -0,0 +1,112 @@
+import { invokeIpc, isDesktopDialogAvailable, onIpc } from '@/core/utils/desktop/ipcRenderer';
+
+/** 主进程 downloadCtl 的统一信封(见 electron/controller/downloadCtl.js) */
+type DownloadRpc<T> = { success: true; data: T } | { success: false; message: string };
+
+/** 一条下载记录 */
+export interface DownloadItem {
+  id: string;
+  /** 落盘后的文件名(重名时会加 (1)) */
+  fileName: string;
+  /** 本机绝对路径 */
+  savedPath: string;
+  /** 当时生效的下载目录 */
+  dir: string;
+  bytes: number;
+  mime: string;
+  /** 触发来源(页面/功能名,便于回查) */
+  source: string;
+  /** ISO 时间 */
+  at: string;
+  /** 下载状态:下载中 / 已完成 / 失败 */
+  status?: 'downloading' | 'done' | 'failed';
+  /** 已接收字节(下载中会不断更新) */
+  received?: number;
+  /** 总字节;服务端没给 content-length 时为 0 */
+  total?: number;
+  /** 失败原因(status=failed 时) */
+  message?: string;
+  /** 清单返回时带上的状态:文件是否还在原位置 */
+  exists?: boolean;
+}
+
+/** 进度推送通道,与主进程 downloadCtl 里的常量保持一致 */
+const DOWNLOAD_PROGRESS_CHANNEL = 'downloadCtl:progress';
+
+/** 订阅下载进度(下载中/完成/失败都会推),返回取消订阅函数 */
+export function onDownloadProgress(handler: (item: DownloadItem) => void): () => void {
+  return onIpc<DownloadItem>(DOWNLOAD_PROGRESS_CHANNEL, handler);
+}
+
+/**
+ * 后台下载:让主进程直接拉 URL 落盘,返回初始记录(状态 downloading),
+ * 进度通过 onDownloadProgress 推送。
+ */
+export function startDownload(params: {
+  url: string;
+  fileName: string;
+  headers?: Record<string, string>;
+  mime?: string;
+  source?: string;
+}): Promise<DownloadItem> {
+  return callDownload<DownloadItem>('startDownload', params);
+}
+
+export interface DownloadListResult {
+  dir: string;
+  items: DownloadItem[];
+}
+
+/** 桌面端才有下载管理器;浏览器里退回浏览器另存 */
+export const isDesktopDownloadAvailable = () => isDesktopDialogAvailable();
+
+/** 解包信封:失败抛错,调用方负责提示 */
+async function callDownload<T>(method: string, params?: unknown): Promise<T> {
+  const result = await invokeIpc<DownloadRpc<T>>(method, params);
+  if (!result) throw new Error(`桌面端无响应:${method}`);
+  if (!result.success) throw new Error(result.message || '操作失败');
+  return result.data;
+}
+
+/** 保存一次下载(data 直接传 ArrayBuffer,结构化克隆,不再转 base64) */
+export function saveDownload(params: {
+  fileName: string;
+  mime?: string;
+  data: ArrayBuffer | Uint8Array;
+  source?: string;
+}): Promise<DownloadItem> {
+  return callDownload<DownloadItem>('saveDownload', params);
+}
+
+export function listDownloads(): Promise<DownloadListResult> {
+  return callDownload<DownloadListResult>('listDownloads');
+}
+
+export function getDownloadDir(): Promise<string> {
+  return callDownload<{ dir: string }>('getDownloadDir').then((data) => data.dir);
+}
+
+/** 设置下载目录(传空恢复系统默认) */
+export function setDownloadDir(dir: string): Promise<string> {
+  return callDownload<{ dir: string }>('setDownloadDir', { dir }).then((data) => data.dir);
+}
+
+export function openDownloadFile(id: string): Promise<{ path: string }> {
+  return callDownload<{ path: string }>('openDownloadFile', { id });
+}
+
+export function revealDownload(id: string): Promise<{ path: string }> {
+  return callDownload<{ path: string }>('revealDownload', { id });
+}
+
+export function openDownloadDir(): Promise<{ path: string }> {
+  return callDownload<{ path: string }>('openDownloadDir');
+}
+
+export function removeDownload(id: string): Promise<{ removed: boolean }> {
+  return callDownload<{ removed: boolean }>('removeDownload', { id });
+}
+
+export function clearDownloads(): Promise<{ cleared: number }> {
+  return callDownload<{ cleared: number }>('clearDownloads');
+}