diff --git a/Dockerfile b/Dockerfile index c8655590..9cffed7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,34 +7,26 @@ ARG MAYFLY_GO_VERSION ARG MAYFLY_GO_DIR_NAME=mayfly-go-linux-${TARGETARCH} ARG MAYFLY_GO_URL=https://gitee.com/dromara/mayfly-go/releases/download/${MAYFLY_GO_VERSION}/${MAYFLY_GO_DIR_NAME}.zip -RUN apk add --no-cache wget unzip && \ - wget -cO mayfly-go.zip ${MAYFLY_GO_URL} && \ +RUN wget -cO mayfly-go.zip ${MAYFLY_GO_URL} && \ unzip mayfly-go.zip && \ - cp -r ${MAYFLY_GO_DIR_NAME}/. /opt/ && \ - rm -rf mayfly-go.zip ${MAYFLY_GO_DIR_NAME} - + mv ${MAYFLY_GO_DIR_NAME} /opt/mayfly-go && \ + rm -rf mayfly-go.zip FROM ${BASEIMAGES} ARG TZ=Asia/Shanghai +ENV TZ=${TZ} +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 安装必要的运行时依赖并创建非root用户 -RUN apk add --no-cache ca-certificates tzdata && \ - ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ - echo $TZ > /etc/timezone && \ - addgroup -g 1000 mayfly && \ - adduser -u 1000 -G mayfly -s /bin/sh -D mayfly -# 复制构建产物 -COPY --from=builder /opt/ /mayfly-go/ +# 从 builder 阶段复制完整目录 +COPY --from=builder /opt/mayfly-go/bin/mayfly-go /usr/local/bin/mayfly-go + +# 设置执行权限 +RUN chmod +x /usr/local/bin/mayfly-go -# 设置工作目录和权限 WORKDIR /mayfly-go -RUN chown -R mayfly:mayfly /mayfly-go - -# 切换到非root用户 -USER mayfly EXPOSE 18888 -CMD ["./mayfly-go"] \ No newline at end of file +CMD ["mayfly-go"] diff --git a/frontend/package.json b/frontend/package.json index b6112cb1..e922060b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,8 +41,8 @@ "sql-formatter": "^15.7.3", "uuid": "^13.0.2", "vue": "3.6.0-beta.11", - "vue-element-plus-x": "^2.0.2", - "vue-i18n": "^11.4.2", + "vue-element-plus-x": "^2.0.3", + "vue-i18n": "^11.4.4", "vue-router": "^5.0.7", "vuedraggable": "^4.1.0", "x-markdown-vue": "0.0.200", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0fdd9dbb..900c6395 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,6 +18,9 @@ + + + @@ -31,6 +34,7 @@ import { useI18n } from 'vue-i18n'; import EnumValue from './common/Enum'; import { I18nEnum } from './common/commonEnum'; import { saveThemeConfig } from './common/utils/storage'; +import GlobalNotificationFab from '@/components/sysmsg/GlobalNotificationFab.vue'; const Setings = defineAsyncComponent(() => import('@/layout/navBars/breadcrumb/setings.vue')); diff --git a/frontend/src/common/Api.ts b/frontend/src/common/Api.ts index 47542c45..3c31e32b 100644 --- a/frontend/src/common/Api.ts +++ b/frontend/src/common/Api.ts @@ -1,5 +1,17 @@ -import request from './request'; import { RequestOptions, useApiFetch } from '@/hooks/useRequest'; +import config from './config'; +import request, { joinClientParams } from './request'; +import { getToken } from './utils/storage'; + +/** + * 文件上传选项 + */ +export interface UploadOptions { + /** 成功回调 */ + onSuccess?: () => void; + /** 错误回调 */ + onError?: (error: Error) => void; +} /** * 可用于各模块定义各自api请求 @@ -77,6 +89,51 @@ class Api { return request.xhrReq(this.method, this.url, param, options); } + /** + * 文件上传请求 + * @param formData FormData 对象(调用方自行构建,包含文件和其他参数) + * @param options 上传选项 + * @returns { abort: () => void } 返回中止方法 + */ + upload(formData: FormData, options: UploadOptions = {}): { abort: () => void } { + const { onSuccess, onError } = options; + + const url = `${config.baseApiUrl}${this.url}?${joinClientParams()}`; + + // 创建 AbortController 用于取消请求 + const abortController = new AbortController(); + + // 发起 fetch 请求 + fetch(url, { + method: 'POST', + body: formData, + signal: abortController.signal, + }) + .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(); + }, + }; + } + /** 静态方法 **/ /** @@ -119,6 +176,14 @@ class Api { static newDelete(url: string): Api { return Api.create(url, 'delete'); } + + /** + * 创建文件上传 api + * @param url url + */ + static newUpload(url: string): Api { + return Api.create(url, 'upload'); + } } export default Api; diff --git a/frontend/src/components/sysmsg/GlobalNotificationFab.vue b/frontend/src/components/sysmsg/GlobalNotificationFab.vue new file mode 100644 index 00000000..3aba5838 --- /dev/null +++ b/frontend/src/components/sysmsg/GlobalNotificationFab.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/components/sysmsg/db/DbSqlExecProgress.vue b/frontend/src/components/sysmsg/db/DbSqlExecProgress.vue index 586b8ca1..5ddfa07f 100644 --- a/frontend/src/components/sysmsg/db/DbSqlExecProgress.vue +++ b/frontend/src/components/sysmsg/db/DbSqlExecProgress.vue @@ -1,21 +1,56 @@ 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 d5b37dd4..8d63cb1a 100644 --- a/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts +++ b/frontend/src/components/sysmsg/db/db-sql-exec-progress.ts @@ -1,56 +1,90 @@ -import ProgressNotify from './DbSqlExecProgress.vue'; -import { ElNotification } from 'element-plus'; -import { h, reactive } from 'vue'; +import DbSqlExecProgress from './DbSqlExecProgress.vue'; +import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager'; import syssocket from '@/common/syssocket'; +import { reactive, nextTick } from 'vue'; -const sqlExecNotifyMap: Map = new Map(); +// 存储SQL执行任务的取消方法 +const sqlExecAborters = new Map void; progress?: any }>(); -// 构建 props(私有函数,不导出) -const buildProgressProps = (): any => { - return { - progress: { - title: { - type: String, - }, - executedStatements: { - type: Number, - }, - }, - }; -}; +// 存储待注册的 abort 方法(等待 WebSocket 消息到达) +const pendingSqlExecAborters = new Map void>(); export async function registerDbSqlExecProgress() { await syssocket.registerMsgHandler('sqlScriptRunProgress', function (message: any) { const content = message.params; const id = content.id; - let progress = sqlExecNotifyMap.get(id); + + // SQL执行完成 if (content.terminated) { - if (progress != undefined) { - progress.notification?.close(); - sqlExecNotifyMap.delete(id); - progress = undefined; - } + completeNotification(id, 1000); + sqlExecAborters.delete(id); return; } - if (progress == undefined) { - progress = { - props: reactive(buildProgressProps()), - notification: undefined, - }; - } + // 构建组件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(); - progress.props.progress.title = content.title; - progress.props.progress.executedStatements = content.executedStatements; - if (!sqlExecNotifyMap.has(id)) { - progress.notification = ElNotification({ - duration: 0, - title: message.title, - message: h(ProgressNotify, progress.props), - type: 'info', - showClose: false, - }); - sqlExecNotifyMap.set(id, progress); + // 更新通知状态为取消 + 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); } }); } + +/** + * 注册SQL执行任务的取消方法 + * @param execId 执行ID + * @param abort 取消方法 + */ +export function registerSqlExecAborter(execId: string, abort: () => void) { + // 先检查通知是否已经存在 + const task = activeNotifications.get(execId); + const progress = task?.componentProps?.progress || null; + + if (progress) { + // 通知已存在,直接注册 + sqlExecAborters.set(execId, { abort, progress }); + } else { + // 通知还未创建,保存为 pending + pendingSqlExecAborters.set(execId, abort); + } +} diff --git a/frontend/src/components/sysmsg/global-notification-manager.ts b/frontend/src/components/sysmsg/global-notification-manager.ts new file mode 100644 index 00000000..e0d7f271 --- /dev/null +++ b/frontend/src/components/sysmsg/global-notification-manager.ts @@ -0,0 +1,107 @@ +import { reactive } from 'vue'; + +// 活跃通知任务映射表 +export const activeNotifications = reactive>(new Map()); + +// 悬浮通知状态 +export const globalNotificationState = reactive({ + hasActiveNotifications: false, + activeCount: 0, + // 按类别统计 + categoryCount: reactive>(new Map()), +}); + +/** + * 更新悬浮通知状态 + */ +const updateNotificationState = () => { + globalNotificationState.activeCount = activeNotifications.size; + globalNotificationState.hasActiveNotifications = activeNotifications.size > 0; + + // 按类别统计 + const categoryMap = new Map(); + for (const [_, task] of activeNotifications) { + const category = task.category || 'default'; + categoryMap.set(category, (categoryMap.get(category) || 0) + 1); + } + globalNotificationState.categoryCount.clear(); + for (const [key, value] of categoryMap) { + globalNotificationState.categoryCount.set(key, value); + } +}; + +/** + * 创建或更新通知 + * @param id 通知唯一ID + * @param category 通知类别(如:machineFileUpload, machineFolderUpload, sqlScript等) + * @param content 通知内容 + * @param component 通知组件 + * @param componentProps 组件props + * @param options 通知选项 + */ +export const createOrUpdateNotification = ( + id: string, + category: string, + content: any, + component: any, + componentProps: any, + options: { + title: string; + onCancel?: () => void; // 取消回调 + } +) => { + // 添加到活跃任务 + activeNotifications.set(id, { + id, + category, + content, + component, + componentProps, + options, + timestamp: Date.now(), + }); + + updateNotificationState(); +}; + +/** + * 完成通知 + * @param id 通知唯一ID + * @param closeDelay 延迟关闭时间(毫秒) + */ +export const completeNotification = (id: string, closeDelay: number = 1000) => { + // 延迟从活跃列表中移除 + setTimeout(() => { + activeNotifications.delete(id); + updateNotificationState(); + }, closeDelay); +}; + +/** + * 关闭指定通知 + * @param id 通知唯一ID + */ +export const closeNotification = (id: string) => { + activeNotifications.delete(id); + updateNotificationState(); +}; + +/** + * 关闭指定类别的所有通知 + * @param category 通知类别 + */ +export const closeCategoryNotifications = (category: string) => { + for (const [id, task] of activeNotifications) { + if (task.category === category) { + closeNotification(id); + } + } +}; + +/** + * 关闭所有通知 + */ +export const closeAllNotifications = () => { + activeNotifications.clear(); + updateNotificationState(); +}; diff --git a/frontend/src/components/sysmsg/machine/MachineFileUploadProgress.vue b/frontend/src/components/sysmsg/machine/MachineFileUploadProgress.vue index deca1b7c..5ce317f2 100644 --- a/frontend/src/components/sysmsg/machine/MachineFileUploadProgress.vue +++ b/frontend/src/components/sysmsg/machine/MachineFileUploadProgress.vue @@ -17,43 +17,46 @@ {{ progress.filename }} + + + + {{ $t('common.cancel') }} +
- +
{{ percent }}%
-
-
- - - {{ t('components.terminal.machineFileUpload.uploaded') }} - - - {{ formatByteSize(progress.uploadedSize) }} - -
-
+
+
- {{ t('components.terminal.machineFileUpload.totalSize') }} + {{ $t('components.terminal.machineFileUpload.totalSize') }} + + {{ formatByteSize(progress.totalSize) }} + - - {{ formatByteSize(progress.totalSize) }} + + + + {{ $t('components.terminal.machineFileUpload.uploaded') }} + + {{ formatByteSize(progress.uploadedSize) }} + -
-
+ - {{ t('components.terminal.machineFileUpload.speed') }} - - - {{ speed }} + {{ $t('components.terminal.machineFileUpload.speed') }} + + {{ speed }} +
@@ -62,7 +65,6 @@ diff --git a/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue b/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue index 58151fa2..fb305bf5 100644 --- a/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue +++ b/frontend/src/components/sysmsg/machine/MachineFolderUploadProgress.vue @@ -13,12 +13,18 @@
- {{ progress.folderName }} - {{ progress.uploadedFiles }}/{{ progress.totalFiles }} + {{ + progress.folderName + }} + {{ progress.uploadedFiles }}/{{ progress.totalFiles }} + + + {{ $t('common.cancel') }} +
- +
@@ -28,37 +34,43 @@
-
{{ t('machine.uploading') }} ({{ t('machine.concurrentFiles', { count: progress.uploadingFiles.length }) }}):
+
+ {{ $t('machine.uploading') }} ({{ $t('machine.concurrentFiles', { count: progress.uploadingFiles.length }) }}): +
{{ file }}
- - -
- - {{ progress.lastFile }} -
diff --git a/frontend/src/components/sysmsg/machine/machine-file-upload-progress.ts b/frontend/src/components/sysmsg/machine/machine-file-upload-progress.ts index 1110fc42..e8638275 100644 --- a/frontend/src/components/sysmsg/machine/machine-file-upload-progress.ts +++ b/frontend/src/components/sysmsg/machine/machine-file-upload-progress.ts @@ -1,27 +1,13 @@ import syssocket from '@/common/syssocket'; -import { reactive, h } from 'vue'; -import { ElNotification } from 'element-plus'; +import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager'; import MachineFileUploadProgress from './MachineFileUploadProgress.vue'; +import { reactive, nextTick } from 'vue'; -// 文件上传进度通知映射表(key: uploadId, value: 通知实例) -const fileUploadNotifyMap: Map = new Map(); +// 存储上传任务的取消方法 +const uploadAborters = new Map void; progress?: any }>(); -/** - * 构建机器文件上传进度组件属性 - */ -const buildMachineFileUploadProgressProps = (): any => { - return { - progress: reactive({ - authCertName: '', // 授权凭证名 - path: '', // 文件路径 - filename: '', - uploadedSize: 0, - totalSize: 0, - timestamp: 0, - status: '', // '' | 'success' | 'exception' - }), - }; -}; +// 存储待注册的 abort 方法(等待 WebSocket 消息到达) +const pendingAborters = new Map void>(); /** * 注册机器文件上传进度消息处理 @@ -31,77 +17,79 @@ export async function registerMachineFileUploadProgress() { const content = message.params; const uploadId = content.uploadId; - // 上传完成或失败,关闭通知 + // 上传完成或失败 if (content.status === 'complete' || content.status === 'error') { - const notify = fileUploadNotifyMap.get(uploadId); - - if (notify && notify.notification) { - // 更新最终状态 - notify.props.progress.status = content.status === 'complete' ? 'success' : 'exception'; - notify.props.progress.percent = content.status === 'complete' ? 100 : notify.props.progress.percent; - - // 强制更新 VNode - try { - if (notify.notification.state) { - notify.notification.state.message = h(MachineFileUploadProgress, notify.props); - } else if (notify.notification.vm) { - notify.notification.vm.exposed?.message?.(h(MachineFileUploadProgress, notify.props)); - } - } catch (e) { - console.warn('[MachineFileUpload] Failed to update notification VNode:', e); - } - - // 1秒后关闭通知 - setTimeout(() => { - if (notify.notification) { - notify.notification.close(); - } - fileUploadNotifyMap.delete(uploadId); - }, 1000); - } + completeNotification(uploadId, 1000); + uploadAborters.delete(uploadId); return; } - // 获取或创建通知 - let notify = fileUploadNotifyMap.get(uploadId); - if (!notify) { - notify = { - props: buildMachineFileUploadProgressProps(), - notification: undefined, - }; - fileUploadNotifyMap.set(uploadId, notify); - } + // 构建组件props + const props = { + progress: reactive({ + authCertName: content.authCertName || '', + path: content.path || '', + filename: content.filename || '', + uploadedSize: content.uploadedSize || 0, + totalSize: content.totalSize || 0, + timestamp: content.timestamp || 0, + status: content.status || 'uploading', + }), + onCancel: () => { + const aborter = uploadAborters.get(uploadId); + if (aborter) { + aborter.abort(); - // 更新进度 - notify.props.progress.authCertName = content.authCertName || ''; - notify.props.progress.path = content.path || ''; - notify.props.progress.filename = content.filename || notify.props.progress.filename; - notify.props.progress.uploadedSize = content.uploadedSize || 0; - notify.props.progress.totalSize = content.totalSize || 0; - notify.props.progress.timestamp = content.timestamp || 0; - notify.props.progress.status = 'uploading'; + // 更新通知状态为取消 + if (aborter.progress) { + nextTick(() => { + aborter.progress.status = 'error'; + aborter.progress.filename = '已取消: ' + (aborter.progress.filename || ''); + }); - // 首次创建通知 - if (!notify.notification) { - notify.notification = ElNotification({ - duration: 0, - title: message.title || '机器文件上传', - message: h(MachineFileUploadProgress, notify.props), - showClose: true, - offset: 60, - customClass: 'machine-file-upload-notification', - }); - } else { - // 已存在通知,强制更新 message - try { - if (notify.notification.state) { - notify.notification.state.message = h(MachineFileUploadProgress, notify.props); - } else if (notify.notification.vm) { - notify.notification.vm.exposed?.message?.(h(MachineFileUploadProgress, notify.props)); + // 延迟后关闭通知 + setTimeout(() => { + completeNotification(uploadId, 1000); + uploadAborters.delete(uploadId); + }, 1500); + } else { + uploadAborters.delete(uploadId); + } } - } catch (e) { - console.warn('[MachineFileUpload] Failed to update notification VNode:', e); - } + }, + }; + + // 创建或更新上传通知 + createOrUpdateNotification(uploadId, 'machineFileUpload', content, MachineFileUploadProgress, props, { + title: message.title || 'machine.fileUpload', + onCancel: props.onCancel, + }); + + // 如果有待注册的 abort 方法,现在注册 + const pendingAbort = pendingAborters.get(uploadId); + if (pendingAbort) { + console.log('[MachineFileUpload] Registering pending aborter for uploadId:', uploadId); + uploadAborters.set(uploadId, { abort: pendingAbort, progress: props.progress }); + pendingAborters.delete(uploadId); } }); } + +/** + * 注册上传任务的取消方法 + * @param uploadId 上传ID + * @param abort 取消方法 + */ +export function registerUploadAborter(uploadId: string, abort: () => void) { + // 先检查通知是否已经存在 + const task = activeNotifications.get(uploadId); + const progress = task?.componentProps?.progress || null; + + if (progress) { + // 通知已存在,直接注册 + uploadAborters.set(uploadId, { abort, progress }); + } else { + // 通知还未创建,保存为 pending + pendingAborters.set(uploadId, abort); + } +} diff --git a/frontend/src/components/sysmsg/machine/machine-folder-upload-progress.ts b/frontend/src/components/sysmsg/machine/machine-folder-upload-progress.ts index 5a8738a8..68d891ec 100644 --- a/frontend/src/components/sysmsg/machine/machine-folder-upload-progress.ts +++ b/frontend/src/components/sysmsg/machine/machine-folder-upload-progress.ts @@ -1,31 +1,13 @@ -import { ElNotification } from 'element-plus'; -import { h, reactive } from 'vue'; -import MachineFolderUploadProgress from '@/components/sysmsg/machine/MachineFolderUploadProgress.vue'; import syssocket from '@/common/syssocket'; +import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager'; +import MachineFolderUploadProgress from './MachineFolderUploadProgress.vue'; +import { reactive, nextTick } from 'vue'; -// 文件夹上传通知 Map -const folderUploadNotifyMap = new Map(); +// 存储上传任务的取消方法 +const folderUploadAborters = new Map void; progress?: any }>(); -/** - * 构建文件夹上传进度组件的 props - */ -const buildMachineFolderUploadProgressProps = (): any => { - return { - progress: reactive({ - authCertName: '', // 授权凭证名 - path: '', // 文件路径 - folderName: '', - totalFiles: 0, - uploadedFiles: 0, - totalSize: 0, - uploadedSize: 0, - lastFile: '', - uploadingFiles: [] as string[], - timestamp: 0, - status: '', - }), - }; -}; +// 存储待注册的 abort 方法(等待 WebSocket 消息到达) +const pendingFolderAborters = new Map void>(); /** * 注册文件夹上传进度消息处理 @@ -39,81 +21,83 @@ export async function registerFolderUploadProgressHandler() { return; } - // 获取或创建通知 - let notify = folderUploadNotifyMap.get(uploadId); - - if (!notify) { - // 首次创建通知 - const props = buildMachineFolderUploadProgressProps(); - - const notificationInstance = ElNotification({ - title: '文件夹上传', - message: h(MachineFolderUploadProgress, props), - duration: 0, - position: 'top-right', - offset: 60, - customClass: 'machine-folder-upload-notify', - onClose: () => { - folderUploadNotifyMap.delete(uploadId); - }, - }); - - notify = { - props, - notification: notificationInstance, - }; - - folderUploadNotifyMap.set(uploadId, notify); - } - - // 上传完成或失败,关闭通知 + // 上传完成或失败 if (content.status === 'complete' || content.status === 'error') { - if (notify && notify.notification) { - // 更新最终状态 - notify.props.progress.status = content.status === 'complete' ? 'success' : 'exception'; - - // 强制更新 VNode - try { - if (notify.notification.state) { - notify.notification.state.message = h(MachineFolderUploadProgress, notify.props); - } - } catch (e) { - console.warn('[MachineFolderUpload] Failed to update notification VNode:', e); - } - - // 1秒后关闭通知 - setTimeout(() => { - if (notify.notification) { - notify.notification.close(); - } - folderUploadNotifyMap.delete(uploadId); - }, 1000); - } + completeNotification(uploadId, 1000); + folderUploadAborters.delete(uploadId); return; } - // 更新进度 - if (content.status === 'uploading') { - notify.props.progress.authCertName = content.authCertName || ''; - notify.props.progress.path = content.path || ''; - notify.props.progress.folderName = content.folderName || ''; - notify.props.progress.totalFiles = content.totalFiles || 0; - notify.props.progress.uploadedFiles = content.uploadedFiles || 0; - notify.props.progress.totalSize = content.totalSize || 0; - notify.props.progress.uploadedSize = content.uploadedSize || 0; - notify.props.progress.lastFile = content.lastFile || ''; - notify.props.progress.uploadingFiles = content.uploadingFiles || []; - notify.props.progress.timestamp = content.timestamp || 0; - notify.props.progress.status = 'uploading'; + // 构建组件props + const props = { + progress: reactive({ + authCertName: content.authCertName || '', + path: content.path || '', + folderName: content.folderName || '', + totalFiles: content.totalFiles || 0, + uploadedFiles: content.uploadedFiles || 0, + totalSize: content.totalSize || 0, + uploadedSize: content.uploadedSize || 0, + uploadingFiles: content.uploadingFiles || [], + timestamp: content.timestamp || 0, + status: content.status || 'uploading', + }), + onCancel: () => { + const aborter = folderUploadAborters.get(uploadId); + if (aborter) { + aborter.abort(); - // 强制更新 VNode - try { - if (notify.notification && notify.notification.state) { - notify.notification.state.message = h(MachineFolderUploadProgress, notify.props); + // 更新通知状态为取消 + if (aborter.progress) { + nextTick(() => { + aborter.progress.status = 'exception'; + aborter.progress.folderName = '已取消: ' + (aborter.progress.folderName || ''); + }); + + // 延迟后关闭通知 + setTimeout(() => { + completeNotification(uploadId, 1000); + folderUploadAborters.delete(uploadId); + }, 1500); + } else { + folderUploadAborters.delete(uploadId); + } } - } catch (e) { - console.warn('[MachineFolderUpload] Failed to update notification VNode:', e); - } + }, + }; + + // 创建或更新上传通知 + if (content.status === 'uploading') { + createOrUpdateNotification(uploadId, 'machineFolderUpload', content, MachineFolderUploadProgress, props, { + title: message.title || 'machine.folderUpload', + onCancel: props.onCancel, + }); + } + + // 如果有待注册的 abort 方法,现在注册 + const pendingAbort = pendingFolderAborters.get(uploadId); + if (pendingAbort) { + folderUploadAborters.set(uploadId, { abort: pendingAbort, progress: props.progress }); + pendingFolderAborters.delete(uploadId); } }); } + +/** + * 注册文件夹上传任务的取消方法 + * @param uploadId 上传ID + * @param abort 取消方法 + */ +export function registerFolderUploadAborter(uploadId: string, abort: () => void) { + // 先检查通知是否已经存在 + const task = activeNotifications.get(uploadId); + const progress = task?.componentProps?.progress || null; + + if (progress) { + // 通知已存在,直接注册 + folderUploadAborters.set(uploadId, { abort, progress }); + } else { + // 通知还未创建,保存为 pending + pendingFolderAborters.set(uploadId, abort); + } +} diff --git a/frontend/src/components/terminal/TerminalBody.vue b/frontend/src/components/terminal/TerminalBody.vue index 5ca3dbbd..6029cb87 100644 --- a/frontend/src/components/terminal/TerminalBody.vue +++ b/frontend/src/components/terminal/TerminalBody.vue @@ -19,7 +19,10 @@ import '@xterm/xterm/css/xterm.css'; import config from '@/common/config'; import { createWebSocket, joinClientParams } from '@/common/request'; import { downloadFile } from '@/common/utils/file'; +import { copyToClipboard } from '@/common/utils/string'; import { Contextmenu, ContextmenuItem } from '@/components/contextmenu'; +import { registerUploadAborter } from '@/components/sysmsg/machine/machine-file-upload-progress'; +import { registerFolderUploadAborter } from '@/components/sysmsg/machine/machine-folder-upload-progress'; import { useThemeConfig } from '@/store/themeConfig'; import { machineApi, uploadFile, uploadFolder } from '@/views/ops/machine/api'; import { useDebounceFn, useEventListener } from '@vueuse/core'; @@ -367,11 +370,6 @@ const setupContextMenu = () => { terminalRef.value.addEventListener('contextmenu', async (event: MouseEvent) => { event.preventDefault(); - // 如果没有 machineId,不显示文件传输菜单 - if (!props.machineId || !props.authCertName) { - return; // 直接返回,不显示任何菜单 - } - showContextMenu(event, term.getSelection()); }); }; @@ -386,26 +384,41 @@ const showContextMenu = (event: MouseEvent, selectedText: string) => { // 始终添加上传文件和上传文件夹按钮 state.contextmenu.items = [ + new ContextmenuItem('copy', 'common.copy') + .withIcon('CopyDocument') + .withHideFunc(() => !selectedText) + .withOnClick(() => { + copyToClipboard(selectedText); + }), + new ContextmenuItem('paste', 'common.paste').withIcon('Document').withOnClick(async () => { + try { + const text = await navigator.clipboard.readText(); + if (text) { + term.paste(text); + focus(); + } + } catch (err) { + console.log(err); + ElMessage.error(t('common.pasteFailed')); + } + }), new ContextmenuItem('download', 'components.terminal.downloadSelectedFile') .withIcon('Download') .withHideFunc(() => !selectedText) .withOnClick(() => { downloadSelectedFile(state.contextmenu.selectedItem); - contextmenuRef.value?.closeContextmenu(); }), new ContextmenuItem('uploadFile', 'components.terminal.uploadFileToCurrentDir') .withIcon('Upload') - .withHideFunc(() => false) + .withHideFunc(() => !props.machineId || !props.authCertName) .withOnClick(() => { triggerFilesUpload(); - contextmenuRef.value?.closeContextmenu(); }), new ContextmenuItem('uploadFolder', 'components.terminal.uploadFolderToCurrentDir') .withIcon('Upload') - .withHideFunc(() => false) + .withHideFunc(() => !props.machineId || !props.authCertName) .withOnClick(() => { triggerFolderUpload(); - contextmenuRef.value?.closeContextmenu(); }), ]; @@ -501,7 +514,7 @@ const uploadFilesToCurrentPath = async (files: FileList) => { const file = files[0]; // 使用统一的 HTTP 上传方法 - uploadFile( + const { uploadId, abort } = uploadFile( file, { machineId: props.machineId as number, @@ -520,6 +533,9 @@ const uploadFilesToCurrentPath = async (files: FileList) => { }, } ); + + // 注册取消方法 + registerUploadAborter(uploadId, abort); } catch (error: any) { ElMessage.error(t('components.terminal.uploadFailed', { error: error.message })); } @@ -532,7 +548,7 @@ const uploadFolderToCurrentPath = async (files: FileList) => { const currentPath = await getCurrentPathOrDefault(); // 使用文件夹上传 - uploadFolder( + const { uploadId, abort } = uploadFolder( files, { machineId: props.machineId as number, @@ -550,6 +566,9 @@ const uploadFolderToCurrentPath = async (files: FileList) => { }, } ); + + // 注册取消方法 + registerFolderUploadAborter(uploadId, abort); } catch (error: any) { ElMessage.error(t('components.terminal.uploadFailed', { error: error.message })); } diff --git a/frontend/src/i18n/en/common.ts b/frontend/src/i18n/en/common.ts index 6849925f..1c4d3916 100644 --- a/frontend/src/i18n/en/common.ts +++ b/frontend/src/i18n/en/common.ts @@ -54,6 +54,8 @@ export default { previousStep: 'Previous Step', nextStep: 'Next Step', copy: 'Copy', + paste: 'Paste', + pasteFailed: 'Paste failed', copySuccess: 'Copy Success', copyCell: 'Copy Cell', @@ -353,5 +355,13 @@ export default { title: 'please select the icon', placeholder: 'please enter content search icon or select icon', }, + + // System message notifications + sysmsg: { + notifications: { + title: 'System Notifications', + closeAll: 'Close All', + }, + }, }, }; diff --git a/frontend/src/i18n/en/db.ts b/frontend/src/i18n/en/db.ts index dd3db6b1..4b3efc11 100644 --- a/frontend/src/i18n/en/db.ts +++ b/frontend/src/i18n/en/db.ts @@ -225,6 +225,14 @@ export default { running: 'Running', waitRun: 'Wait Run', + + // SQL execution + sqlExecute: 'SQL Execution', + executedStatements: 'Executed', + elapsedTime: 'Elapsed', + scriptFileUploadSuccess: 'SQL file [{filename}] executed successfully', + scriptFileUploadCancelled: 'SQL file [{filename}] execution cancelled', + scriptFileUploadFailed: 'SQL file [{filename}] execution failed: {error}', }, es: { keywordPlaceholder: 'host / name / code', diff --git a/frontend/src/i18n/en/machine.ts b/frontend/src/i18n/en/machine.ts index 06ce2727..b8d13f1b 100644 --- a/frontend/src/i18n/en/machine.ts +++ b/frontend/src/i18n/en/machine.ts @@ -141,10 +141,20 @@ export default { fileExceedsSysConf: 'The uploaded file exceeds the system configuration [{uploadMaxFileSize}]', fileUploadSuccess: 'Machine file upload successful', fileUploadFail: 'Machine file upload failed', + fileUpload: 'File Upload', // Folder upload progress folderUploadProgress: 'Folder Upload Progress', + folderUpload: 'Folder Upload', uploading: 'Uploading', concurrentFiles: '{count} concurrent', + + // Upload notifications + uploadNotifications: { + title: 'Upload Notifications', + closeAll: 'Close All', + folders: '{count} folders', + files: '{count} files', + }, }, }; diff --git a/frontend/src/i18n/zh-cn/common.ts b/frontend/src/i18n/zh-cn/common.ts index f65a337b..94de7a79 100644 --- a/frontend/src/i18n/zh-cn/common.ts +++ b/frontend/src/i18n/zh-cn/common.ts @@ -54,6 +54,8 @@ export default { previousStep: '上一步', nextStep: '下一步', copy: '复制', + paste: '粘贴', + pasteFailed: '粘贴失败', copySuccess: '复制成功', copyCell: '复制单元格', search: '搜索', @@ -362,5 +364,13 @@ export default { title: '请选择图标', placeholder: '请输入内容搜索图标或者选择图标', }, + + // 系统消息通知 + sysmsg: { + notifications: { + title: '系统通知', + closeAll: '全部关闭', + }, + }, }, }; diff --git a/frontend/src/i18n/zh-cn/db.ts b/frontend/src/i18n/zh-cn/db.ts index aaf344fb..1fcf5e4f 100644 --- a/frontend/src/i18n/zh-cn/db.ts +++ b/frontend/src/i18n/zh-cn/db.ts @@ -219,5 +219,13 @@ export default { running: '运行中', waitRun: '待运行', + + // SQL执行 + sqlExecute: 'SQL执行', + executedStatements: '已执行', + elapsedTime: '已用时', + scriptFileUploadSuccess: 'SQL文件【{filename}】执行成功', + scriptFileUploadCancelled: 'SQL文件【{filename}】执行已取消', + scriptFileUploadFailed: 'SQL文件【{filename}】执行失败: {error}', }, }; diff --git a/frontend/src/i18n/zh-cn/machine.ts b/frontend/src/i18n/zh-cn/machine.ts index f08d5b38..e79f7b51 100644 --- a/frontend/src/i18n/zh-cn/machine.ts +++ b/frontend/src/i18n/zh-cn/machine.ts @@ -142,10 +142,20 @@ export default { fileExceedsSysConf: '上传的文件超过系统配置的【{uploadMaxFileSize}】', fileUploadSuccess: '机器文件上传成功', fileUploadFail: '机器文件上传失败', + fileUpload: '文件上传', // 文件夹上传进度 folderUploadProgress: '文件夹上传进度', + folderUpload: '文件夹上传', uploading: '正在上传', concurrentFiles: '{count} 个并发', + + // 上传通知 + uploadNotifications: { + title: '上传通知', + closeAll: '全部关闭', + folders: '{count} 个文件夹', + files: '{count} 个文件', + }, }, }; diff --git a/frontend/src/views/ops/db/api.ts b/frontend/src/views/ops/db/api.ts index 6cb95524..9a9cd914 100644 --- a/frontend/src/views/ops/db/api.ts +++ b/frontend/src/views/ops/db/api.ts @@ -1,5 +1,7 @@ import Api from '@/common/Api'; import { AesEncrypt } from '@/common/crypto'; +import { joinClientParams } from '@/common/request'; +import { registerSqlExecAborter } from '@/components/sysmsg/db/db-sql-exec-progress'; export const dbApi = { // 获取权限列表 @@ -79,3 +81,48 @@ export const encryptField = async (param: any, field: string) => { } return param; }; + +/** + * 上传SQL文件并执行 + * @param file 文件对象 + * @param params 上传参数 + * @param options 上传选项 + * @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象 + */ +export function uploadSqlFile( + file: File, + params: { + dbId: number; + dbName: string; + }, + options: { + onSuccess?: () => void; + onError?: (error: Error) => void; + } = {} +): { uploadId: string; abort: () => void } { + // 生成 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); + + // 创建 Api 实例 + const api = Api.newPost(`/dbs/${params.dbId}/exec-sql-file`); + + // 使用 Api.upload 发起请求 + const { abort } = api.upload(formData, { + onSuccess: () => { + options.onSuccess?.(); + }, + onError: (error) => { + options.onError?.(error); + }, + }); + + // 注册取消器(在获取到abort方法后) + registerSqlExecAborter(uploadId, abort); + + return { uploadId, abort }; +} diff --git a/frontend/src/views/ops/db/component/sqleditor/DbSqlEditor.vue b/frontend/src/views/ops/db/component/sqleditor/DbSqlEditor.vue index 0e0bc492..664dd83a 100644 --- a/frontend/src/views/ops/db/component/sqleditor/DbSqlEditor.vue +++ b/frontend/src/views/ops/db/component/sqleditor/DbSqlEditor.vue @@ -20,6 +20,7 @@ class="sql-file-exec" :before-upload="beforeUpload" :on-success="execSqlFileSuccess" + :http-request="handleSqlFileUpload" :headers="{ Authorization: token }" :action="getUploadSqlFileUrl()" :show-file-list="false" @@ -141,7 +142,7 @@ import { editor } from 'monaco-editor'; import DbTableData from '@/views/ops/db/component/table/DbTableData.vue'; import { DbInst } from '../../db'; -import { dbApi } from '../../api'; +import { dbApi, uploadSqlFile } from '../../api'; import MonacoEditor from '@/components/monaco/MonacoEditor.vue'; import { joinClientParams } from '@/common/request'; @@ -771,6 +772,29 @@ const beforeUpload = (file: File) => { ElMessage.success(t('db.scriptFileUploadRunning', { filename: file.name })); }; +// 自定义SQL文件上传处理 +const handleSqlFileUpload = (options: any) => { + const { file } = options; + + const { uploadId, abort } = uploadSqlFile( + file, + { + dbId: props.dbId as number, + dbName: props.dbName as string, + }, + { + onSuccess: () => { + ElMessage.success(t('db.scriptFileUploadSuccess', { filename: file.name })); + }, + onError: (error) => { + ElMessage.error(t('db.scriptFileUploadFailed', { filename: file.name, error: error.message })); + }, + } + ); + + return { abort }; +}; + // 执行sql成功 const execSqlFileSuccess = (res: any) => { if (res.code !== 200) { diff --git a/frontend/src/views/ops/machine/api.ts b/frontend/src/views/ops/machine/api.ts index d2535c61..c5f96396 100644 --- a/frontend/src/views/ops/machine/api.ts +++ b/frontend/src/views/ops/machine/api.ts @@ -1,11 +1,4 @@ -import Api from '@/common/Api'; -import { joinClientParams } from '@/common/request'; -import config from '@/common/config'; -import { randomUuid } from '@/common/utils/string'; -import { getToken } from '@/common/utils/storage'; -import { i18n } from '@/i18n'; - -const t = i18n.global.t; +import Api, { UploadOptions } from '@/common/Api'; export const machineApi = { // 获取权限列表 @@ -41,7 +34,8 @@ export const machineApi = { cpFile: Api.newPost('/machines/{machineId}/files/{fileId}/cp'), renameFile: Api.newPost('/machines/{machineId}/files/{fileId}/rename'), mvFile: Api.newPost('/machines/{machineId}/files/{fileId}/mv'), - uploadFile: Api.newPost('/machines/{machineId}/files/{fileId}/upload?' + joinClientParams()), + uploadFile: Api.newUpload('/machines/{machineId}/files/{fileId}/upload'), + uploadFolder: Api.newPost('/machines/{machineId}/files/{fileId}/upload-folder'), fileContent: Api.newGet('/machines/{machineId}/files/{fileId}/read'), downloadFile: Api.newGet('/machines/{machineId}/files/{fileId}/download'), createFile: Api.newPost('/machines/{machineId}/files/{id}/create-file'), @@ -103,20 +97,6 @@ export interface UploadParams { path: string; /** 文件名 */ filename: string; - /** 相对路径(文件夹上传时使用) */ - relativePath?: string; -} - -/** - * 文件上传选项 - */ -export interface UploadOptions { - /** 进度回调 */ - onProgress?: (percent: number, uploadedSize: number, totalSize: number, speed: string) => void; - /** 成功回调 */ - onSuccess?: () => void; - /** 错误回调 */ - onError?: (error: Error) => void; } /** @@ -124,13 +104,11 @@ export interface UploadOptions { * @param file 文件对象 * @param params 上传参数 * @param options 上传选项 - * @returns Promise + * @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象 */ -export async function uploadFile(file: File, params: UploadParams, options: UploadOptions = {}): Promise { - const { onProgress, onSuccess, onError } = options; - - // 如果没有 uploadId,自动生成 - const uploadId = params.uploadId || randomUuid(); +export function uploadFile(file: File, params: UploadParams, options: UploadOptions = {}): { uploadId: string; abort: () => void } { + // 业务层生成 uploadId + const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const formData = new FormData(); formData.append('file', file); @@ -141,54 +119,9 @@ export async function uploadFile(file: File, params: UploadParams, options: Uplo formData.append('fileId', String(params.fileId)); formData.append('path', params.path); - if (params.relativePath) { - formData.append('relativePath', params.relativePath); - } + const { abort } = machineApi.uploadFile.upload(formData, options); - const token = getToken(); - const url = `${config.baseApiUrl}/machines/${params.machineId}/files/${params.fileId}/upload?token=${token}`; - - try { - const xhr = new XMLHttpRequest(); - - // 进度回调 - xhr.upload.onprogress = (event) => { - if (event.lengthComputable && onProgress) { - const percent = Math.round((event.loaded / event.total) * 100); - const elapsed = (Date.now() - startTime) / 1000; - const speedBytes = elapsed > 0 ? event.loaded / elapsed : 0; - let speed = '0 B/s'; - if (speedBytes < 1024) { - speed = `${speedBytes.toFixed(0)} B/s`; - } else if (speedBytes < 1024 * 1024) { - speed = `${(speedBytes / 1024).toFixed(1)} KB/s`; - } else { - speed = `${(speedBytes / (1024 * 1024)).toFixed(1)} MB/s`; - } - onProgress(percent, event.loaded, event.total, speed); - } - }; - - // 完成回调 - xhr.onload = () => { - if (xhr.status === 200) { - onSuccess?.(); - } else { - onError?.(new Error(t('common.uploadFailed', { error: `HTTP ${xhr.status}` }))); - } - }; - - // 错误回调 - xhr.onerror = () => { - onError?.(new Error(t('common.uploadFailed', { error: '网络错误' }))); - }; - - const startTime = Date.now(); - xhr.open('POST', url); - xhr.send(formData); - } catch (error: any) { - onError?.(new Error(t('common.uploadFailed', { error: error.message }))); - } + return { uploadId, abort }; } /** @@ -210,27 +143,15 @@ export interface FolderUploadParams { } /** - * 文件夹上传选项 - */ -export interface FolderUploadOptions { - /** 成功回调 */ - onSuccess?: () => void; - /** 错误回调 */ - onError?: (error: Error) => void; -} - -/** - * 上传文件夹(使用 /upload-folder 接口) + * 上传文件夹(使用 /upload-folder 接口) * @param files 文件列表 * @param params 上传参数 * @param options 上传选项 - * @returns Promise + * @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象 */ -export async function uploadFolder(files: FileList | File[], params: FolderUploadParams, options: FolderUploadOptions = {}): Promise { - const { onSuccess, onError } = options; - - // 如果没有 uploadId,自动生成 - const uploadId = params.uploadId || randomUuid(); +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 formData = new FormData(); formData.append('uploadId', uploadId); @@ -253,29 +174,8 @@ export async function uploadFolder(files: FileList | File[], params: FolderUploa formData.append('paths', path); }); - const token = getToken(); - const url = `${config.baseApiUrl}/machines/${params.machineId}/files/${params.fileId}/upload-folder?token=${token}`; + // 使用 Api.upload 发起请求 + const { abort } = machineApi.uploadFolder.upload(formData, options); - try { - const xhr = new XMLHttpRequest(); - - // 完成回调 - xhr.onload = () => { - if (xhr.status === 200) { - onSuccess?.(); - } else { - onError?.(new Error(t('common.uploadFailed', { error: `HTTP ${xhr.status}` }))); - } - }; - - // 错误回调 - xhr.onerror = () => { - onError?.(new Error(t('common.uploadFailed', { error: '网络错误' }))); - }; - - xhr.open('POST', url); - xhr.send(formData); - } catch (error: any) { - onError?.(new Error(t('common.uploadFailed', { error: error.message }))); - } + return { uploadId, abort }; } diff --git a/frontend/src/views/ops/machine/file/MachineFile.vue b/frontend/src/views/ops/machine/file/MachineFile.vue index 3dd39e66..a9a0d66b 100755 --- a/frontend/src/views/ops/machine/file/MachineFile.vue +++ b/frontend/src/views/ops/machine/file/MachineFile.vue @@ -309,6 +309,8 @@ import { ElInput, ElMessage } from 'element-plus'; import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue'; import { machineApi, uploadFile, uploadFolder } from '../api'; +import { registerUploadAborter } from '@/components/sysmsg/machine/machine-file-upload-progress'; +import { registerFolderUploadAborter } from '@/components/sysmsg/machine/machine-folder-upload-progress'; import { isTrue, notBlank } from '@/common/assert'; import config from '@/common/config'; @@ -789,10 +791,8 @@ function handleFolderUpload(e: any) { return; } - console.log('[MachineFile] Folder upload:', files.length, 'files, total size:', totalFileSize); - // 使用文件夹上传接口 - uploadFolder( + const { uploadId, abort } = uploadFolder( files, { machineId: props.machineId as number, @@ -814,6 +814,9 @@ function handleFolderUpload(e: any) { } ); + // 注册取消方法 + registerFolderUploadAborter(uploadId, abort); + // 清空已选择的文件夹 const folderEle: any = document.getElementById('folderUploadInput'); if (folderEle) { @@ -831,7 +834,7 @@ const handleFileUpload = (content: any) => { } // 上传文件 - uploadFile( + const { uploadId, abort } = uploadFile( file, { machineId: props.machineId as number, @@ -853,6 +856,9 @@ const handleFileUpload = (content: any) => { }, } ); + + // 注册取消方法 + registerUploadAborter(uploadId, abort); }; const uploadSuccess = (res: any) => { diff --git a/server/internal/db/api/db.go b/server/internal/db/api/db.go index a2e3a6ff..dc33df7a 100644 --- a/server/internal/db/api/db.go +++ b/server/internal/db/api/db.go @@ -166,15 +166,17 @@ func (d *Db) ExecSql(rc *req.Ctx) { // 执行sql文件 func (d *Db) ExecSqlFile(rc *req.Ctx) { - multipart, err := rc.GetRequest().MultipartReader() - biz.ErrIsNilAppendErr(err, "failed to read sql file: %s") - file, err := multipart.NextPart() + fileheader, err := rc.FormFile("file") + biz.ErrIsNilAppendErr(err, "read form file error: %s") + + file, err := fileheader.Open() biz.ErrIsNilAppendErr(err, "failed to read sql file: %s") defer file.Close() - filename := file.FileName() + filename := fileheader.Filename dbId := getDbId(rc) - dbName := getDbName(rc) clientId := rc.Query("clientId") + dbName := rc.PostForm("db") + uploadId := rc.PostForm("uploadId") dbConn, err := d.dbApp.GetDbConn(rc.MetaCtx, dbId, dbName) biz.ErrIsNil(err) @@ -186,6 +188,7 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) { Filename: filename, DbConn: dbConn, ClientId: clientId, + UploadId: uploadId, })) } diff --git a/server/internal/db/application/db.go b/server/internal/db/application/db.go index 8061c8a3..3daa6996 100644 --- a/server/internal/db/application/db.go +++ b/server/internal/db/application/db.go @@ -187,6 +187,7 @@ func (d *dbAppImpl) GetDbConn(ctx context.Context, dbId uint64, dbName string) ( } di.CodePath = d.tagApp.ListTagPathByTypeAndCode(int8(tagentity.TagTypeDb), db.Code) di.Id = db.Id + di.DbCode = db.Code checkDb := di.GetDatabase() if db.GetDatabaseMode == entity.DbGetDatabaseModeAssign && !strings.Contains(" "+db.Database+" ", " "+checkDb+" ") { diff --git a/server/internal/db/application/db_sql_exec.go b/server/internal/db/application/db_sql_exec.go index c39360fa..e3ef0741 100644 --- a/server/internal/db/application/db_sql_exec.go +++ b/server/internal/db/application/db_sql_exec.go @@ -184,21 +184,23 @@ func (d *dbSqlExecAppImpl) ExecReader(ctx context.Context, execReader *dto.SqlRe clientId := execReader.ClientId filename := stringx.Truncate(execReader.Filename, 20, 10, "...") la := contextx.GetLoginAccount(ctx) - needSendMsg := la != nil && clientId != "" + needSendMsg := la != nil && clientId != "" && execReader.UploadId != "" startTime := time.Now() executedStatements := 0 - progressId := stringx.Rand(32) + dbInfo := dbConn.Info msgEvent := &msgdto.MsgTmplSendEvent{ TmplChannel: msgdto.MsgTmplSqlScriptRunSuccess, - Params: collx.M{"filename": filename, "dbId": dbConn.Info.Id, "dbName": dbConn.Info.Name}, + Params: collx.M{"filename": filename, "dbId": dbInfo.Id, "dbName": dbInfo.Name}, } progressMsgEvent := &msgdto.MsgTmplSendEvent{ TmplChannel: msgdto.MsgTmplSqlScriptRunProgress, Params: collx.M{ - "id": progressId, + "id": execReader.UploadId, + "dbCode": dbInfo.DbCode, + "dbName": dbInfo.Name, "title": filename, "executedStatements": executedStatements, "terminated": false, @@ -233,6 +235,16 @@ func (d *dbSqlExecAppImpl) ExecReader(ctx context.Context, execReader *dto.SqlRe // 使用方言切割器进行 SQL 切割 splitter := dbConn.GetDialect().GetSQLSplitter() err := splitter.SplitSQL(execReader.Reader, func(sql string) error { + // 检查context是否已取消 + if ctx.Err() != nil { + if needSendMsg { + progressMsgEvent.Params["executedStatements"] = executedStatements + progressMsgEvent.Params["status"] = "cancelled" + global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, progressMsgEvent) + } + return errorx.NewBizI(ctx, imsg.ErrSqlExecCancelled) + } + if executedStatements%50 == 0 { if needSendMsg { progressMsgEvent.Params["executedStatements"] = executedStatements diff --git a/server/internal/db/application/dto/sql_exec.go b/server/internal/db/application/dto/sql_exec.go index de8ffb9e..c1818c4b 100644 --- a/server/internal/db/application/dto/sql_exec.go +++ b/server/internal/db/application/dto/sql_exec.go @@ -28,4 +28,5 @@ type SqlReaderExec struct { Filename string ClientId string // 客户端id,若存在则会向其发送执行进度消息 + UploadId string // 上传id,用于记录上传进度 } diff --git a/server/internal/db/dbm/dbi/db_info.go b/server/internal/db/dbm/dbi/db_info.go index 3377a56c..6eb81be6 100644 --- a/server/internal/db/dbm/dbi/db_info.go +++ b/server/internal/db/dbm/dbi/db_info.go @@ -27,6 +27,7 @@ type DbInfo struct { InstanceId uint64 // 实例id Id uint64 // dbId + DbCode string Name string Type DbType // 类型,mysql postgres等 diff --git a/server/internal/db/imsg/en.go b/server/internal/db/imsg/en.go index a67401e7..00bf72c7 100644 --- a/server/internal/db/imsg/en.go +++ b/server/internal/db/imsg/en.go @@ -20,6 +20,7 @@ var En = map[i18n.MsgId]string{ ErrExistRunFailSql: "There is an execution error in sql", ErrNeedSubmitWorkTicket: "This operation needs to submit a work ticket for approval", + ErrSqlExecCancelled: "SQL execution cancelled", // db transfer LogDtsSave: "dts - Save data transfer task", diff --git a/server/internal/db/imsg/imsg.go b/server/internal/db/imsg/imsg.go index 7fe73bd0..bfb7544b 100644 --- a/server/internal/db/imsg/imsg.go +++ b/server/internal/db/imsg/imsg.go @@ -30,6 +30,7 @@ const ( ErrExistRunFailSql ErrNeedSubmitWorkTicket + ErrSqlExecCancelled // db transfer LogDtsSave diff --git a/server/internal/db/imsg/zh_cn.go b/server/internal/db/imsg/zh_cn.go index 715596eb..742542e1 100644 --- a/server/internal/db/imsg/zh_cn.go +++ b/server/internal/db/imsg/zh_cn.go @@ -20,6 +20,7 @@ var Zh_CN = map[i18n.MsgId]string{ ErrExistRunFailSql: "存在执行错误的sql", ErrNeedSubmitWorkTicket: "该操作需要提交工单审批执行", + ErrSqlExecCancelled: "SQL执行已取消", // db transfer LogDtsSave: "dts-保存数据迁移任务", diff --git a/server/internal/machine/api/machine_file.go b/server/internal/machine/api/machine_file.go index b2bbedcc..782b7442 100644 --- a/server/internal/machine/api/machine_file.go +++ b/server/internal/machine/api/machine_file.go @@ -100,6 +100,13 @@ type progressReader struct { } func (r *progressReader) Read(p []byte) (n int, err error) { + // 先检查 context 是否已取消 + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + default: + } + n, err = r.reader.Read(p) if n > 0 { r.readSize += int64(n) @@ -339,6 +346,27 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) { mi, err = m.machineFileApp.UploadFile(ctx, opForm, fileheader.Filename, reader) rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", path, fileheader.Filename)) + // 检查是否是取消操作 + if err != nil { + logx.ErrorfContext(ctx, "[UploadFile] Upload error: %v, uploadId: %s, ctx.Err: %v", err, uploadId, ctx.Err()) + if ctx.Err() != nil { + logx.InfofContext(ctx, "File upload cancelled by client: %s, uploadId: %s", fileheader.Filename, uploadId) + // 发送取消通知 + if hasProgressNotify { + progressMsgEvent := &msgdto.MsgTmplSendEvent{ + TmplChannel: msgdto.MsgTmplMachineFileUploadProgress, + Params: collx.M{ + "uploadId": uploadId, + "status": "error", + }, + ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id}, + } + global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, progressMsgEvent) + } + return + } + } + // 发送完成通知 if hasProgressNotify && err == nil { progressMsgEvent := &msgdto.MsgTmplSendEvent{ @@ -477,7 +505,7 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) { for _, chunk := range chunks { wg.Go(func() { defer gox.Recover(func(e error) { - logx.Errorf("upload folder error: %s", e) + logx.ErrorfContext(ctx, "upload folder error: %s", e) }) for _, file := range chunk { @@ -495,7 +523,7 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) { createfile, err := sftpCli.Create(fmt.Sprintf("%s/%s/%s", basePath, dir, fileHeader.Filename)) if err != nil { - logx.Errorf("create file error: %s", err) + logx.ErrorfContext(ctx, "create file error: %s", err) file.Close() // 从正在上传列表移除 @@ -539,7 +567,6 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) { "path": basePath, "uploadId": uploadId, "folderName": folderName, - "lastFile": fullPath, "totalFiles": totalFiles, "uploadedFiles": uploadedFiles, "totalSize": totalSize, @@ -559,7 +586,14 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) { _, err = io.Copy(createfile, reader) if err != nil { - logx.Errorf("copy file error: %s", err) + logx.ErrorfContext(ctx, "copy file error: %s, uploadId: %s", err, uploadId) + // 检查是否是取消操作 + if ctx.Err() != nil { + logx.InfofContext(ctx, "Folder upload cancelled by client, uploadId: %s", uploadId) + file.Close() + createfile.Close() + return + } } // 累加已上传大小 diff --git a/server/internal/machine/application/machine_file.go b/server/internal/machine/application/machine_file.go index 33eeefe0..51e29d53 100644 --- a/server/internal/machine/application/machine_file.go +++ b/server/internal/machine/application/machine_file.go @@ -88,7 +88,6 @@ type machineFileAppImpl struct { var _ MachineFile = (*machineFileAppImpl)(nil) - // 分页获取机器文件配置信息列表 func (m *machineFileAppImpl) GetPageList(condition *entity.MachineFile, pageParam model.PageParam, orderBy ...string) (*model.PageResult[*entity.MachineFile], error) { return m.GetRepo().GetPageList(condition, pageParam, orderBy...) @@ -305,7 +304,17 @@ func (m *machineFileAppImpl) UploadFile(ctx context.Context, opParam *dto.Machin return nil, err } defer file.Close() - io.Copy(file, reader) + + // 使用 io.Copy 并检查错误 + _, err = io.Copy(file, reader) + if err != nil { + // 检查是否是连接断开导致的错误 + if ctx.Err() != nil { + logx.WarnfContext(ctx, "Upload file cancelled by client: %s", filename) + return nil, ctx.Err() + } + return nil, fmt.Errorf("copy file error: %w", err) + } return &mcm.MachineInfo{Name: opParam.AuthCertName, Ip: opParam.AuthCertName}, nil } @@ -319,7 +328,17 @@ func (m *machineFileAppImpl) UploadFile(ctx context.Context, opParam *dto.Machin return mi, err } defer createfile.Close() - io.Copy(createfile, reader) + + // 使用 io.Copy 并检查错误 + _, err = io.Copy(createfile, reader) + if err != nil { + // 检查是否是连接断开导致的错误 + if ctx.Err() != nil { + logx.WarnfContext(ctx, "Upload file cancelled by client: %s", filename) + return mi, ctx.Err() + } + return mi, fmt.Errorf("copy file error: %w", err) + } return mi, err } diff --git a/server/internal/pkg/config/app.go b/server/internal/pkg/config/app.go index 43b33474..9fe95f49 100644 --- a/server/internal/pkg/config/app.go +++ b/server/internal/pkg/config/app.go @@ -1,5 +1,5 @@ package config const ( - Version = "v1.11.1" + Version = "v1.11.2" )