From 871e9b8fddb60f0bd7b335e8502ae08df6cd5b8b Mon Sep 17 00:00:00 2001 From: "meilin.huang" <954537473@qq.com> Date: Fri, 22 May 2026 20:36:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E4=BC=98=E5=8C=96&?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E6=96=87=E4=BB=B6/=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E4=B8=8A=E4=BC=A0=E5=AE=9E=E6=97=B6=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 2 +- frontend/src/common/Api.ts | 51 ++ frontend/src/common/syssocket.ts | 179 ++++-- .../dynamic-form/{index.js => index.ts} | 0 .../sysmsg/db/db-sql-exec-progress.ts | 126 ++-- .../sysmsg/global-notification-manager.ts | 32 +- .../machine/MachineFolderUploadProgress.vue | 115 ++-- .../machine/machine-file-upload-progress.ts | 119 ++-- .../machine/machine-folder-upload-progress.ts | 217 +++++-- .../src/components/terminal/TerminalBody.vue | 18 +- frontend/src/i18n/en/common.ts | 7 + frontend/src/i18n/en/home.ts | 45 ++ frontend/src/i18n/en/machine.ts | 2 + frontend/src/i18n/zh-cn/common.ts | 7 + frontend/src/i18n/zh-cn/home.ts | 45 ++ frontend/src/i18n/zh-cn/machine.ts | 2 + frontend/src/views/home/Home.vue | 547 ++++-------------- frontend/src/views/home/resources/Base.vue | 146 +++++ .../src/views/home/resources/Database.vue | 32 + frontend/src/views/home/resources/Docker.vue | 31 + frontend/src/views/home/resources/ES.vue | 31 + frontend/src/views/home/resources/Kafka.vue | 32 + frontend/src/views/home/resources/Machine.vue | 152 +++++ frontend/src/views/home/resources/Milvus.vue | 33 ++ frontend/src/views/home/resources/Mongo.vue | 32 + frontend/src/views/home/resources/Redis.vue | 31 + frontend/src/views/home/resources/index.ts | 10 + frontend/src/views/ops/db/api.ts | 28 +- frontend/src/views/ops/machine/api.ts | 185 ++++-- .../views/ops/machine/file/MachineFile.vue | 14 +- frontend/src/views/ops/milvus/MilvusList.vue | 2 +- server/internal/db/api/db.go | 17 +- server/internal/machine/api/machine_file.go | 477 +++------------ .../machine/application/machine_file.go | 89 +-- server/internal/pkg/config/app.go | 2 +- 35 files changed, 1627 insertions(+), 1231 deletions(-) rename frontend/src/components/dynamic-form/{index.js => index.ts} (100%) create mode 100644 frontend/src/i18n/en/home.ts create mode 100644 frontend/src/i18n/zh-cn/home.ts create mode 100644 frontend/src/views/home/resources/Base.vue create mode 100644 frontend/src/views/home/resources/Database.vue create mode 100644 frontend/src/views/home/resources/Docker.vue create mode 100644 frontend/src/views/home/resources/ES.vue create mode 100644 frontend/src/views/home/resources/Kafka.vue create mode 100644 frontend/src/views/home/resources/Machine.vue create mode 100644 frontend/src/views/home/resources/Milvus.vue create mode 100644 frontend/src/views/home/resources/Mongo.vue create mode 100644 frontend/src/views/home/resources/Redis.vue create mode 100644 frontend/src/views/home/resources/index.ts diff --git a/frontend/package.json b/frontend/package.json index 65c8b24a..3af212b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,7 +68,7 @@ "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", - "vite": "^8.0.13", + "vite": "^8.0.14", "vite-plugin-progress": "0.0.7", "vue-eslint-parser": "^10.4.0" }, diff --git a/frontend/src/common/Api.ts b/frontend/src/common/Api.ts index 3c31e32b..5d9abcca 100644 --- a/frontend/src/common/Api.ts +++ b/frontend/src/common/Api.ts @@ -134,6 +134,57 @@ class Api { }; } + /** + * 原始文件流上传请求(直接使用文件流作为 body,参数通过 URL query 传递) + * @param file 文件对象 + * @param queryParams URL 查询参数字符串 + * @param options 上传选项(可包含自定义 headers) + * @returns { abort: () => void } 返回中止方法 + */ + uploadRaw(file: File, queryParams: string, options: UploadOptions & { headers?: Record } = {}): { abort: () => void } { + const { onSuccess, onError, headers = {} } = options; + + const url = `${config.baseApiUrl}${this.url}?${queryParams}&${joinClientParams()}`; + + // 创建 AbortController 用于取消请求 + const abortController = new AbortController(); + + // 构建请求头 + const requestHeaders: Record = { + ...headers, + }; + + // 发起 fetch 请求,直接使用文件流作为 body + fetch(url, { + method: 'POST', + body: file, + signal: abortController.signal, + headers: requestHeaders, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response; + }) + .then(() => { + onSuccess?.(); + }) + .catch((error) => { + if (error.name === 'AbortError') { + return; + } + onError?.(new Error(`upload failed: ${error.message}`)); + }); + + // 返回中止方法 + return { + abort: () => { + abortController.abort(); + }, + }; + } + /** 静态方法 **/ /** diff --git a/frontend/src/common/syssocket.ts b/frontend/src/common/syssocket.ts index 4694f550..6ffbf999 100644 --- a/frontend/src/common/syssocket.ts +++ b/frontend/src/common/syssocket.ts @@ -28,6 +28,31 @@ class SysSocket { */ categoryHandlers: Map = new Map(); + /** + * 重连定时器 + */ + reconnectTimer: number | null = null; + + /** + * 当前重连次数 + */ + reconnectCount: number = 0; + + /** + * 基础重连延迟(毫秒) + */ + baseReconnectDelay: number = 3000; + + /** + * 是否正在重连 + */ + isReconnecting: boolean = false; + + /** + * 是否手动关闭 + */ + isManualClose: boolean = false; + /** * 初始化全局系统消息websocket */ @@ -42,52 +67,126 @@ class SysSocket { } console.log('init system ws'); try { - this.socket = await createWebSocket('/sysmsg'); - this.socket.onmessage = async (event: { data: string }) => { - let message; - try { - message = JSON.parse(event.data); - } catch (e) { - console.error('解析ws消息失败', e); - return; - } - - // 存在消息类别对应的处理器,则进行处理,否则进行默认通知处理 - const handler = this.categoryHandlers.get(message.category); - if (handler) { - handler(message); - return; - } - - const msgSubtype = EnumValue.getEnumByValue(MsgSubtypeEnum, message.subtype); - if (!msgSubtype) { - console.log(`not found msg subtype: ${message.subtype}`); - return; - } - - // 动态导入 i18n 或延迟获取 i18n 实例 - let title = ''; - try { - // 方式1: 动态导入 - const { i18n } = await import('@/i18n'); - title = i18n.global.t(msgSubtype?.label); - } catch (e) { - console.warn('i18n not ready, using default title'); - } - - ElNotification({ - duration: 0, - title, - message: h(MessageRenderer, { content: message.msg }), - type: msgSubtype?.extra.notifyType || 'info', - }); - }; + this.isManualClose = false; + await this.connect(); } catch (e) { console.error('open system ws error', e); } } + /** + * 建立 WebSocket 连接 + */ + private async connect(): Promise { + this.socket = await createWebSocket('/sysmsg'); + + this.socket.onopen = () => { + console.log('WebSocket connected'); + this.resetReconnect(); + }; + + this.socket.onmessage = async (event: { data: string }) => { + let message; + try { + message = JSON.parse(event.data); + } catch (e) { + console.error('解析ws消息失败', e); + return; + } + + // 存在消息类别对应的处理器,则进行处理,否则进行默认通知处理 + const handler = this.categoryHandlers.get(message.category); + if (handler) { + handler(message); + return; + } + + const msgSubtype = EnumValue.getEnumByValue(MsgSubtypeEnum, message.subtype); + if (!msgSubtype) { + console.log(`not found msg subtype: ${message.subtype}`); + return; + } + + // 动态导入 i18n 或延迟获取 i18n 实例 + let title = ''; + try { + // 方式1: 动态导入 + const { i18n } = await import('@/i18n'); + title = i18n.global.t(msgSubtype?.label); + } catch (e) { + console.warn('i18n not ready, using default title'); + } + + ElNotification({ + duration: 0, + title, + message: h(MessageRenderer, { content: message.msg }), + type: msgSubtype?.extra.notifyType || 'info', + }); + }; + + this.socket.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + this.socket.onclose = (event) => { + console.log('WebSocket closed:', event.code, event.reason); + this.socket = null; + + // 如果不是手动关闭,则尝试重连 + if (!this.isManualClose) { + this.handleReconnect(); + } + }; + } + + /** + * 处理重连逻辑 + */ + private handleReconnect() { + if (this.isReconnecting) { + return; + } + + this.isReconnecting = true; + this.reconnectCount++; + + // 固定延迟重连策略:每 3 秒重试一次 + const delay = this.baseReconnectDelay; + console.log(`WebSocket 将在 ${delay}ms 后尝试第 ${this.reconnectCount} 次重连`); + + this.reconnectTimer = window.setTimeout(async () => { + this.isReconnecting = false; + try { + const token = getToken(); + if (!token) { + console.warn('Token 不存在,停止重连'); + return; + } + console.log(`尝试第 ${this.reconnectCount} 次重连 WebSocket`); + await this.connect(); + } catch (e) { + console.error(`第 ${this.reconnectCount} 次重连失败:`, e); + this.handleReconnect(); + } + }, delay); + } + + /** + * 重置重连状态 + */ + private resetReconnect() { + this.isReconnecting = false; + this.reconnectCount = 0; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + destory() { + this.isManualClose = true; + this.resetReconnect(); this.socket?.close(); this.socket = null; this.categoryHandlers?.clear(); diff --git a/frontend/src/components/dynamic-form/index.js b/frontend/src/components/dynamic-form/index.ts similarity index 100% rename from frontend/src/components/dynamic-form/index.js rename to frontend/src/components/dynamic-form/index.ts diff --git a/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts b/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts index 8d63cb1a..42badc46 100644 --- a/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts +++ b/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts @@ -1,7 +1,7 @@ -import DbSqlExecProgress from './DbSqlExecProgress.vue'; -import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager'; import syssocket from '@/common/syssocket'; -import { reactive, nextTick } from 'vue'; +import { nextTick, reactive } from 'vue'; +import { activeNotifications, completeNotification, createOrUpdateNotification } from '../global-notification-manager'; +import DbSqlExecProgress from './DbSqlExecProgress.vue'; // 存储SQL执行任务的取消方法 const sqlExecAborters = new Map void; progress?: any }>(); @@ -9,11 +9,32 @@ const sqlExecAborters = new Map void; progress?: any }>() // 存储待注册的 abort 方法(等待 WebSocket 消息到达) const pendingSqlExecAborters = new Map void>(); +export interface SqlExecProgress { + id: string; + title: string; + dbCode: string; + dbName: string; + executedStatements: number; + terminated: boolean; + status: string; + clientId: string; +} + +const sqlExecStates = reactive>(new Map()); + +/** + * 注册数据库SQL执行进度消息处理 + */ export async function registerDbSqlExecProgress() { await syssocket.registerMsgHandler('sqlScriptRunProgress', function (message: any) { const content = message.params; const id = content.id; + const progress = sqlExecStates.get(id); + if (!progress) { + return; + } + // SQL执行完成 if (content.terminated) { completeNotification(id, 1000); @@ -21,55 +42,62 @@ export async function registerDbSqlExecProgress() { return; } - // 构建组件props - const props = { - progress: reactive({ - title: content.title || '', - executedStatements: content.executedStatements || 0, - terminated: content.terminated || false, - status: content.status || '', - dbCode: content.dbCode || '', - dbName: content.dbName || '', - }), - onCancel: () => { - const aborter = sqlExecAborters.get(id); - if (aborter) { - aborter.abort(); - - // 更新通知状态为取消 - if (aborter.progress) { - nextTick(() => { - aborter.progress.status = 'cancelled'; - aborter.progress.terminated = true; - }); - - // 延迟后关闭通知 - setTimeout(() => { - completeNotification(id, 1000); - sqlExecAborters.delete(id); - }, 1500); - } else { - sqlExecAborters.delete(id); - } - } - }, - }; - - // 创建或更新通知 - createOrUpdateNotification(id, 'sqlScriptRun', content, DbSqlExecProgress, props, { - title: message.title || 'db.sqlExecute', - onCancel: props.onCancel, - }); - - // 如果有待注册的 abort 方法,现在注册 - const pendingAbort = pendingSqlExecAborters.get(id); - if (pendingAbort) { - sqlExecAborters.set(id, { abort: pendingAbort, progress: props.progress }); - pendingSqlExecAborters.delete(id); - } + progress.executedStatements = content.executedStatements || 0; + progress.terminated = content.terminated || false; + progress.status = content.status || ''; + return; }); } +/** + * 创建SQL执行进度通知 + * @param id 执行ID + * @param data 进度数据 + */ +export function createSqlExecNotification(id: string, data: SqlExecProgress) { + // 构建组件props + const props = { + progress: data, + onCancel: () => { + const aborter = sqlExecAborters.get(id); + if (aborter) { + aborter.abort(); + + // 更新通知状态为取消 + if (aborter.progress) { + nextTick(() => { + aborter.progress.status = 'cancelled'; + aborter.progress.terminated = true; + }); + + // 延迟后关闭通知 + setTimeout(() => { + completeNotification(id, 1000); + sqlExecAborters.delete(id); + }, 1500); + } else { + sqlExecAborters.delete(id); + } + } + }, + }; + + // 创建或更新通知 + createOrUpdateNotification(id, 'sqlScriptRun', data, DbSqlExecProgress, props, { + title: data.title || 'db.sqlExecute', + onCancel: props.onCancel, + }); + + sqlExecStates.set(id, data); + + // 如果有待注册的 abort 方法,现在注册 + const pendingAbort = pendingSqlExecAborters.get(id); + if (pendingAbort) { + sqlExecAborters.set(id, { abort: pendingAbort, progress: props.progress }); + pendingSqlExecAborters.delete(id); + } +} + /** * 注册SQL执行任务的取消方法 * @param execId 执行ID diff --git a/frontend/src/components/sysmsg/global-notification-manager.ts b/frontend/src/components/sysmsg/global-notification-manager.ts index 789e3667..49828ec3 100644 --- a/frontend/src/components/sysmsg/global-notification-manager.ts +++ b/frontend/src/components/sysmsg/global-notification-manager.ts @@ -1,7 +1,21 @@ -import { reactive } from 'vue'; +import { reactive, type Component } from 'vue'; + +// 通知任务接口定义 +export interface NotificationTask { + id: string; + category: string; + content: unknown; + component: Component; + componentProps: Record; + options: { + title: string; + onCancel?: () => void; + }; + timestamp: number; +} // 活跃通知任务映射表 -export const activeNotifications = reactive>(new Map()); +export const activeNotifications = reactive>(new Map()); // 悬浮通知状态 export const globalNotificationState = reactive({ @@ -15,6 +29,13 @@ const updateNotificationState = () => { globalNotificationState.activeCount = activeNotifications.size; }; +/** + * 获取通知 + */ +export function getNotification(id: string) { + return activeNotifications.get(id); +} + /** * 创建或更新通知 * @param id 通知唯一ID @@ -27,11 +48,12 @@ const updateNotificationState = () => { export const createOrUpdateNotification = ( id: string, category: string, - content: any, - component: any, - componentProps: any, + content: unknown, + component: Component, + componentProps: Record, options: { title: string; + onCancel?: () => void; } ) => { // 添加到活跃任务 diff --git a/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue b/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue index 46141968..76c4471d 100644 --- a/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue +++ b/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue @@ -1,54 +1,95 @@ - + diff --git a/frontend/src/views/home/resources/Base.vue b/frontend/src/views/home/resources/Base.vue new file mode 100644 index 00000000..b160da37 --- /dev/null +++ b/frontend/src/views/home/resources/Base.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/views/home/resources/Database.vue b/frontend/src/views/home/resources/Database.vue new file mode 100644 index 00000000..67b92839 --- /dev/null +++ b/frontend/src/views/home/resources/Database.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/views/home/resources/Docker.vue b/frontend/src/views/home/resources/Docker.vue new file mode 100644 index 00000000..978073ae --- /dev/null +++ b/frontend/src/views/home/resources/Docker.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/views/home/resources/ES.vue b/frontend/src/views/home/resources/ES.vue new file mode 100644 index 00000000..5c8c4bfb --- /dev/null +++ b/frontend/src/views/home/resources/ES.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/views/home/resources/Kafka.vue b/frontend/src/views/home/resources/Kafka.vue new file mode 100644 index 00000000..67150588 --- /dev/null +++ b/frontend/src/views/home/resources/Kafka.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/views/home/resources/Machine.vue b/frontend/src/views/home/resources/Machine.vue new file mode 100644 index 00000000..516b6dfc --- /dev/null +++ b/frontend/src/views/home/resources/Machine.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/src/views/home/resources/Milvus.vue b/frontend/src/views/home/resources/Milvus.vue new file mode 100644 index 00000000..9079c41a --- /dev/null +++ b/frontend/src/views/home/resources/Milvus.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/views/home/resources/Mongo.vue b/frontend/src/views/home/resources/Mongo.vue new file mode 100644 index 00000000..9571c08f --- /dev/null +++ b/frontend/src/views/home/resources/Mongo.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/views/home/resources/Redis.vue b/frontend/src/views/home/resources/Redis.vue new file mode 100644 index 00000000..fbf8dddf --- /dev/null +++ b/frontend/src/views/home/resources/Redis.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/views/home/resources/index.ts b/frontend/src/views/home/resources/index.ts new file mode 100644 index 00000000..417d8ef2 --- /dev/null +++ b/frontend/src/views/home/resources/index.ts @@ -0,0 +1,10 @@ +import Database from './Database.vue'; +import Docker from './Docker.vue'; +import ES from './ES.vue'; +import Kafka from './Kafka.vue'; +import Machine from './Machine.vue'; +import Milvus from './Milvus.vue'; +import Mongo from './Mongo.vue'; +import Redis from './Redis.vue'; + +export const resourceComponents = [Machine, Database, Redis, Mongo, ES, Milvus, Docker, Kafka]; diff --git a/frontend/src/views/ops/db/api.ts b/frontend/src/views/ops/db/api.ts index 9a9cd914..e7c7dd89 100644 --- a/frontend/src/views/ops/db/api.ts +++ b/frontend/src/views/ops/db/api.ts @@ -1,7 +1,7 @@ import Api from '@/common/Api'; +import { registerSqlExecAborter, createSqlExecNotification } from '@/components/sysmsg/db/db-sql-exec-progress'; import { AesEncrypt } from '@/common/crypto'; import { joinClientParams } from '@/common/request'; -import { registerSqlExecAborter } from '@/components/sysmsg/db/db-sql-exec-progress'; export const dbApi = { // 获取权限列表 @@ -103,16 +103,18 @@ export function uploadSqlFile( // 生成 uploadId const uploadId = `sql_exec_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - const formData = new FormData(); - formData.append('file', file); - formData.append('db', params.dbName); - formData.append('uploadId', uploadId); + // 使用 URLSearchParams 构建查询参数 + const queryParams = new URLSearchParams({ + db: params.dbName, + uploadId: uploadId, + filename: file.name, + }).toString(); // 创建 Api 实例 const api = Api.newPost(`/dbs/${params.dbId}/exec-sql-file`); - // 使用 Api.upload 发起请求 - const { abort } = api.upload(formData, { + // 使用 uploadRaw 直接传递文件流 + const { abort } = api.uploadRaw(file, queryParams, { onSuccess: () => { options.onSuccess?.(); }, @@ -121,6 +123,18 @@ export function uploadSqlFile( }, }); + // 创建SQL执行进度通知 + createSqlExecNotification(uploadId, { + id: uploadId, + title: file.name, + dbCode: '', + dbName: params.dbName, + executedStatements: 0, + terminated: false, + status: 'uploading', + clientId: '', + }); + // 注册取消器(在获取到abort方法后) registerSqlExecAborter(uploadId, abort); diff --git a/frontend/src/views/ops/machine/api.ts b/frontend/src/views/ops/machine/api.ts index c5f96396..94acde0b 100644 --- a/frontend/src/views/ops/machine/api.ts +++ b/frontend/src/views/ops/machine/api.ts @@ -1,4 +1,6 @@ import Api, { UploadOptions } from '@/common/Api'; +import { createUploadFileNotification, registerUploadFileAborter } from '@/components/sysmsg/machine/machine-file-upload-progress'; +import { createUploadFolderNotification, registerUploadFolderAborter } from '@/components/sysmsg/machine/machine-folder-upload-progress'; export const machineApi = { // 获取权限列表 @@ -97,6 +99,11 @@ export interface UploadParams { path: string; /** 文件名 */ filename: string; + + /** 是否创建进度通知 */ + createProgressNotify?: boolean; + /** 是否是文件夹上传的一部分 */ + isFolderUpload?: boolean; } /** @@ -110,16 +117,37 @@ export function uploadFile(file: File, params: UploadParams, options: UploadOpti // 业务层生成 uploadId const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - const formData = new FormData(); - formData.append('file', file); - formData.append('uploadId', uploadId); - formData.append('machineId', String(params.machineId)); - formData.append('authCertName', params.authCertName); - formData.append('protocol', String(params.protocol)); - formData.append('fileId', String(params.fileId)); - formData.append('path', params.path); + // 构建查询参数 + const queryParams = new URLSearchParams({ + machineId: String(params.machineId), + authCertName: params.authCertName, + protocol: String(params.protocol), + fileId: String(params.fileId), + path: params.path, + uploadId: uploadId, + filename: file.name, + }); - const { abort } = machineApi.uploadFile.upload(formData, options); + // 如果是文件夹上传,添加标识参数 + if (params.isFolderUpload) { + queryParams.set('isFolderUpload', 'true'); + } + + // 直接使用文件流作为 body,不包装为 FormData + const { abort } = machineApi.uploadFile.uploadRaw(file, queryParams.toString(), { + ...options, + }); + + if (params.createProgressNotify !== false) { + createUploadFileNotification(uploadId, { + authCertName: params.authCertName, + path: params.path, + filename: file.name, + }); + + // 注册取消方法 + registerUploadFileAborter(uploadId, abort); + } return { uploadId, abort }; } @@ -143,39 +171,124 @@ export interface FolderUploadParams { } /** - * 上传文件夹(使用 /upload-folder 接口) - * @param files 文件列表 - * @param params 上传参数 - * @param options 上传选项 - * @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象 + * 上传文件夹(逐个文件流式上传,保持目录结构) */ export function uploadFolder(files: FileList | File[], params: FolderUploadParams, options: UploadOptions = {}): { uploadId: string; abort: () => void } { - // 业务层生成 uploadId - const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + const uploadId = params.uploadId || `folder_${params.fileId}_${Date.now()}`; + const fileArray = Array.from(files); + const totalFiles = fileArray.length; + const totalSize = fileArray.reduce((sum, file) => sum + file.size, 0); + let isAborted = false; + const abortControllers: (() => void)[] = []; // 存储所有正在进行的上传的取消方法 - const formData = new FormData(); - formData.append('uploadId', uploadId); - formData.append('basePath', params.path); - formData.append('machineId', String(params.machineId)); - formData.append('authCertName', params.authCertName); - formData.append('protocol', String(params.protocol)); - - // 添加所有文件 - const paths: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; + const filepaths: string[] = []; + // 创建上传任务 + const uploadTasks = fileArray.map((file, index) => { const relativePath = (file as any).webkitRelativePath || file.name; - formData.append('files', file); - paths.push(relativePath); - } + const fullPath = `${params.path}/${relativePath}`; + const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/')); - // 添加路径数组 - paths.forEach((path) => { - formData.append('paths', path); + filepaths.push(fullPath); + + return () => + new Promise((resolve, reject) => { + console.log(`[FolderUpload] 开始上传 ${index + 1}/${totalFiles}:`, fullPath); + + const { abort } = uploadFile( + file, + { + uploadId, + machineId: params.machineId, + authCertName: params.authCertName, + protocol: params.protocol, + fileId: params.fileId, + path: dirPath, + filename: file.name, + isFolderUpload: true, + createProgressNotify: false, + }, + { + onSuccess: () => { + console.log(`[FolderUpload] 上传成功 ${index + 1}/${totalFiles}:`, fullPath); + resolve(); + }, + onError: (error) => { + console.log(`[FolderUpload] 上传失败 ${index + 1}/${totalFiles}:`, fullPath, error.message); + if (error.name === 'AbortError' || isAborted) { + reject(error); + } else { + options.onError?.(error); + resolve(); + } + }, + } + ); + + // 将当前文件的取消方法添加到列表中 + abortControllers.push(abort); + }); }); - // 使用 Api.upload 发起请求 - const { abort } = machineApi.uploadFolder.upload(formData, options); + // 初始化进度通知 + const folderName = fileArray[0]?.webkitRelativePath?.split('/')[0] || 'folder'; + createUploadFolderNotification(uploadId, { + authCertName: params.authCertName, + path: params.path, + folderName, + totalFiles, + totalSize, + uploadedSize: 0, + uploadedFiles: 0, + finishedFiles: 0, + status: 'uploading', + files: new Map(filepaths.map((filepath) => [filepath, { path: filepath, status: 'waiting', progress: 0, currentSize: 0, totalSize: 0, timestamp: 0 }])), + }); - return { uploadId, abort }; + // 并发执行 + const executeUploads = async () => { + const maxConcurrent = 3; + const running = new Set>(); + let index = 0; + + const launchNext = () => { + if (index >= uploadTasks.length || isAborted) return; + + const task = uploadTasks[index++](); + running.add(task); + + task.finally(() => { + running.delete(task); + if (!isAborted) launchNext(); + }); + }; + + // 启动初始并发 + for (let i = 0; i < maxConcurrent && i < uploadTasks.length; i++) { + launchNext(); + } + + // 等待所有完成 + while (running.size > 0) { + await Promise.race(running); + } + + if (!isAborted) { + console.log('[FolderUpload] 全部完成'); + options.onSuccess?.(); + } + }; + + executeUploads(); + + const res = { + uploadId, + abort: () => { + isAborted = true; + // 取消所有正在进行的上传请求 + abortControllers.forEach((abort) => abort()); + }, + }; + + registerUploadFolderAborter(uploadId, res.abort); + return res; } diff --git a/frontend/src/views/ops/machine/file/MachineFile.vue b/frontend/src/views/ops/machine/file/MachineFile.vue index 1de14898..bfea760f 100755 --- a/frontend/src/views/ops/machine/file/MachineFile.vue +++ b/frontend/src/views/ops/machine/file/MachineFile.vue @@ -306,8 +306,6 @@