mirror of
https://gitee.com/dromara/mayfly-go
synced 2026-05-19 17:35:20 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a68bf12c | ||
|
|
7f94fd168b |
30
Dockerfile
30
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"]
|
||||
CMD ["mayfly-go"]
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@logicflow/core": "^2.2.1",
|
||||
"@logicflow/extension": "^2.2.1",
|
||||
"@logicflow/core": "^2.2.3",
|
||||
"@logicflow/extension": "^2.2.3",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"monaco-sql-languages": "^1.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode.vue": "^3.9.0",
|
||||
"qrcode.vue": "^3.9.1",
|
||||
"screenfull": "^6.0.2",
|
||||
"shiki": "^4.0.2",
|
||||
"shiki-stream": "^0.1.4",
|
||||
@@ -41,9 +41,9 @@
|
||||
"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-router": "^5.0.6",
|
||||
"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",
|
||||
"xlsx": "^0.18.5"
|
||||
@@ -69,7 +69,7 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.13",
|
||||
"vite-plugin-progress": "0.0.7",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<router-view v-if="!themeConfig.isWatermark" />
|
||||
|
||||
<Setings />
|
||||
|
||||
<!-- 全局系统通知悬浮按钮 -->
|
||||
<GlobalNotificationFab />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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<T = any, P = any> {
|
||||
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<T = any, P = any> {
|
||||
static newDelete<T = any, P = any>(url: string): Api<T, P> {
|
||||
return Api.create<T, P>(url, 'delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件上传 api
|
||||
* @param url url
|
||||
*/
|
||||
static newUpload<T = any, P = any>(url: string): Api<T, P> {
|
||||
return Api.create<T, P>(url, 'upload');
|
||||
}
|
||||
}
|
||||
|
||||
export default Api;
|
||||
|
||||
211
frontend/src/components/sysmsg/GlobalNotificationFab.vue
Normal file
211
frontend/src/components/sysmsg/GlobalNotificationFab.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="globalNotificationState.hasActiveNotifications"
|
||||
class="fixed z-[2000]"
|
||||
:style="{ bottom: position.bottom + 'px', right: position.right + 'px' }"
|
||||
>
|
||||
<el-badge
|
||||
:value="globalNotificationState.activeCount"
|
||||
:max="99"
|
||||
class="cursor-move"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<el-button
|
||||
circle
|
||||
type="primary"
|
||||
class="w-[50px] h-[50px] text-xl shadow-lg transition-all duration-300"
|
||||
:class="{ 'hover:scale-110 hover:shadow-xl': !isDragging }"
|
||||
@click="toggleNotificationPanel"
|
||||
>
|
||||
<SvgIcon name="Bell" />
|
||||
</el-button>
|
||||
</el-badge>
|
||||
|
||||
<!-- 展开的通知面板 -->
|
||||
<Transition name="slide-fade">
|
||||
<div
|
||||
v-if="isPanelVisible"
|
||||
class="absolute bottom-[60px] right-0 w-[420px] max-h-[500px] bg-white dark:bg-gray-800 rounded-lg shadow-2xl overflow-hidden z-[2001]"
|
||||
>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="m-0 text-base font-semibold text-gray-800 dark:text-gray-200">{{ $t('components.sysmsg.notifications.title') }}</h3>
|
||||
<el-button size="small" text @click="isPanelVisible = false">
|
||||
<SvgIcon name="Close" />
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-scrollbar max-height="400px">
|
||||
<div class="p-4">
|
||||
<!-- 直接展示所有通知 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="task in allTasks" :key="task.id" class="p-2 bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
|
||||
<!-- 显示通知标题 -->
|
||||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ translateTitle(task.options.title) }}</div>
|
||||
<!-- 直接渲染原有组件 -->
|
||||
<component :is="task.component" v-bind="task.componentProps" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="globalNotificationState.activeCount === 0" :description="$t('common.noData')" :image-size="80" />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { activeNotifications, globalNotificationState } from './global-notification-manager';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isPanelVisible = ref(false);
|
||||
|
||||
// 拖拽相关
|
||||
const STORAGE_KEY = 'global-notification-fab-position';
|
||||
const position = ref({ bottom: 20, right: 20 }); // 默认位置(对应 bottom-5 right-5)
|
||||
const isDragging = ref(false);
|
||||
const dragStart = ref({ x: 0, y: 0, initialBottom: 0, initialRight: 0 });
|
||||
const hasMoved = ref(false); // 标记是否发生了移动
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
// 只在左键拖拽时生效
|
||||
if (event.button !== 0) return;
|
||||
|
||||
isDragging.value = true;
|
||||
hasMoved.value = false;
|
||||
dragStart.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
initialBottom: position.value.bottom,
|
||||
initialRight: position.value.right,
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
|
||||
// 防止拖拽时选中文本
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const onDrag = (event: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const deltaY = event.clientY - dragStart.value.y;
|
||||
const deltaX = event.clientX - dragStart.value.x;
|
||||
|
||||
// 如果移动距离超过 3px,认为是拖拽而不是点击
|
||||
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
|
||||
hasMoved.value = true;
|
||||
}
|
||||
|
||||
// 更新位置(注意:鼠标向下移动时 bottom 应该减小)
|
||||
position.value.bottom = dragStart.value.initialBottom - deltaY;
|
||||
position.value.right = dragStart.value.initialRight - deltaX;
|
||||
|
||||
// 获取窗口尺寸用于边界限制
|
||||
const windowHeight = window.innerHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
// 确保不会移出屏幕(留出至少 50px 保证按钮可见)
|
||||
if (position.value.bottom < 0) position.value.bottom = 0;
|
||||
if (position.value.right < 0) position.value.right = 0;
|
||||
if (position.value.bottom > windowHeight - 50) position.value.bottom = windowHeight - 50;
|
||||
if (position.value.right > windowWidth - 50) position.value.right = windowWidth - 50;
|
||||
|
||||
// 如果发生了移动,阻止默认行为
|
||||
if (hasMoved.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
|
||||
// 恢复文本选择
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// 保存位置到 localStorage
|
||||
savePosition();
|
||||
};
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
});
|
||||
|
||||
// 保存位置到 localStorage
|
||||
const savePosition = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(position.value));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save notification fab position:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从 localStorage 加载位置
|
||||
const loadPosition = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
// 验证数据有效性
|
||||
if (typeof parsed.bottom === 'number' && typeof parsed.right === 'number') {
|
||||
position.value = parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load notification fab position:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时加载保存的位置
|
||||
onMounted(() => {
|
||||
loadPosition();
|
||||
});
|
||||
|
||||
// 所有任务列表
|
||||
const allTasks = computed(() => {
|
||||
return Array.from(activeNotifications.values());
|
||||
});
|
||||
|
||||
// 翻译title(支持i18n key和直接文本)
|
||||
const translateTitle = (title: string): string => {
|
||||
// 如果包含点号,说明是i18n key,需要翻译
|
||||
if (title.includes('.')) {
|
||||
return t(title);
|
||||
}
|
||||
// 否则直接返回原文本
|
||||
return title;
|
||||
};
|
||||
|
||||
const toggleNotificationPanel = () => {
|
||||
// 如果发生了拖拽移动,不触发点击事件
|
||||
if (hasMoved.value) {
|
||||
hasMoved.value = false;
|
||||
return;
|
||||
}
|
||||
isPanelVisible.value = !isPanelVisible.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,56 @@
|
||||
<template>
|
||||
<el-descriptions border size="small" :title="`${props.progress.title}`">
|
||||
<el-descriptions-item label="时间">{{ state.elapsedTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已处理">{{ progress.executedStatements }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="w-full py-1">
|
||||
<el-row> <TagCodePath :code="progress.dbCode" /> / {{ progress.dbName }} </el-row>
|
||||
|
||||
<!-- 文件名 -->
|
||||
<div class="flex items-center gap-2 mb-2 mt-2">
|
||||
<SvgIcon name="Document" :size="16" class="text-primary flex-shrink-0" />
|
||||
<span class="flex-1 text-sm font-semibold text-gray-700 dark:text-gray-200 truncate" :title="progress.title">
|
||||
{{ progress.title }}
|
||||
</span>
|
||||
<!-- 取消按钮 -->
|
||||
<el-button v-if="!progress.terminated && progress.status !== 'cancelled'" type="danger" size="small" text @click="handleCancel">
|
||||
<SvgIcon name="Close" :size="14" />
|
||||
{{ $t('common.cancel') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<el-descriptions border size="small">
|
||||
<el-descriptions-item :label="$t('db.executedStatements')">{{ progress.executedStatements }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('db.elapsedTime')">{{ state.elapsedTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, reactive } from 'vue';
|
||||
import { formatTime } from 'element-plus/es/components/countdown/src/utils';
|
||||
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
|
||||
|
||||
const props = defineProps({
|
||||
progress: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: '',
|
||||
executedStatements: 0,
|
||||
}),
|
||||
},
|
||||
interface Progress {
|
||||
dbCode: string;
|
||||
dbName: string;
|
||||
title: string;
|
||||
executedStatements: number;
|
||||
terminated: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
progress?: Progress;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
progress: () => ({
|
||||
dbCode: '',
|
||||
dbName: '',
|
||||
title: '',
|
||||
executedStatements: 0,
|
||||
terminated: false,
|
||||
status: '',
|
||||
}),
|
||||
onCancel: undefined,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
@@ -38,4 +73,11 @@ onUnmounted(async () => {
|
||||
timer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理取消执行
|
||||
const handleCancel = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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<string, any> = new Map();
|
||||
// 存储SQL执行任务的取消方法
|
||||
const sqlExecAborters = new Map<string, { abort: () => void; progress?: any }>();
|
||||
|
||||
// 构建 props(私有函数,不导出)
|
||||
const buildProgressProps = (): any => {
|
||||
return {
|
||||
progress: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
executedStatements: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
// 存储待注册的 abort 方法(等待 WebSocket 消息到达)
|
||||
const pendingSqlExecAborters = new Map<string, () => 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);
|
||||
}
|
||||
}
|
||||
|
||||
107
frontend/src/components/sysmsg/global-notification-manager.ts
Normal file
107
frontend/src/components/sysmsg/global-notification-manager.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
// 活跃通知任务映射表
|
||||
export const activeNotifications = reactive<Map<string, any>>(new Map());
|
||||
|
||||
// 悬浮通知状态
|
||||
export const globalNotificationState = reactive({
|
||||
hasActiveNotifications: false,
|
||||
activeCount: 0,
|
||||
// 按类别统计
|
||||
categoryCount: reactive<Map<string, number>>(new Map()),
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新悬浮通知状态
|
||||
*/
|
||||
const updateNotificationState = () => {
|
||||
globalNotificationState.activeCount = activeNotifications.size;
|
||||
globalNotificationState.hasActiveNotifications = activeNotifications.size > 0;
|
||||
|
||||
// 按类别统计
|
||||
const categoryMap = new Map<string, number>();
|
||||
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();
|
||||
};
|
||||
@@ -1,48 +1,62 @@
|
||||
<template>
|
||||
<div class="w-full py-1">
|
||||
<el-row>
|
||||
<TagCodePath :code="progress.authCertName" />
|
||||
</el-row>
|
||||
|
||||
<!-- 文件路径 -->
|
||||
<div v-if="progress.path" class="mb-3 px-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 truncate block" :title="progress.path">
|
||||
{{ progress.path }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件名 -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<SvgIcon name="Document" :size="16" class="text-primary flex-shrink-0" />
|
||||
<span class="flex-1 text-sm font-semibold text-gray-700 dark:text-gray-200 truncate" :title="progress.filename">
|
||||
{{ progress.filename }}
|
||||
</span>
|
||||
<!-- 取消按钮 -->
|
||||
<el-button v-if="progress.status === '' || progress.status === 'uploading'" type="danger" size="small" text @click="handleCancel">
|
||||
<SvgIcon name="Close" :size="14" />
|
||||
{{ $t('common.cancel') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex-1">
|
||||
<el-progress :percentage="percent" :status="progress.status" :stroke-width="10" :show-text="false" />
|
||||
<el-progress :percentage="percent" :status="progressStatus" :stroke-width="10" :show-text="false" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-primary min-w-[45px] text-right"> {{ percent }}% </span>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-3">
|
||||
<div class="flex items-center justify-between mb-2 text-xs">
|
||||
<span class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400 font-medium">
|
||||
<SvgIcon name="Upload" :size="14" />
|
||||
{{ t('components.terminal.machineFileUpload.uploaded') }}
|
||||
</span>
|
||||
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ formatByteSize(progress.uploadedSize) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-2 text-xs">
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-md px-3 py-2">
|
||||
<div class="flex items-center justify-between text-xs gap-4">
|
||||
<span class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400 font-medium">
|
||||
<SvgIcon name="Files" :size="14" />
|
||||
{{ t('components.terminal.machineFileUpload.totalSize') }}
|
||||
{{ $t('components.terminal.machineFileUpload.totalSize') }}
|
||||
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ formatByteSize(progress.totalSize) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ formatByteSize(progress.totalSize) }}
|
||||
|
||||
<span class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400 font-medium">
|
||||
<SvgIcon name="Upload" :size="14" />
|
||||
{{ $t('components.terminal.machineFileUpload.uploaded') }}
|
||||
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ formatByteSize(progress.uploadedSize) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
|
||||
<span class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400 font-medium">
|
||||
<SvgIcon name="Odometer" :size="14" />
|
||||
{{ t('components.terminal.machineFileUpload.speed') }}
|
||||
</span>
|
||||
<span class="font-mono font-semibold text-primary">
|
||||
{{ speed }}
|
||||
{{ $t('components.terminal.machineFileUpload.speed') }}
|
||||
<span class="font-mono font-semibold text-primary">
|
||||
{{ speed }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,24 +65,29 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatByteSize } from '@/common/utils/format';
|
||||
import { i18n } from '@/i18n';
|
||||
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface Progress {
|
||||
authCertName: string; // 授权凭证名
|
||||
path: string; // 文件路径
|
||||
filename: string;
|
||||
percent: number;
|
||||
uploadedSize: number;
|
||||
totalSize: number;
|
||||
timestamp?: number; // 时间戳,用于计算速度
|
||||
status: '' | 'success' | 'exception' | 'warning';
|
||||
status: '' | 'complete' | 'error' | 'uploading';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
progress?: Progress;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
progress: () => ({
|
||||
authCertName: '',
|
||||
path: '',
|
||||
filename: '',
|
||||
percent: 0,
|
||||
uploadedSize: 0,
|
||||
@@ -76,9 +95,20 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
timestamp: 0,
|
||||
status: '',
|
||||
}),
|
||||
onCancel: undefined,
|
||||
});
|
||||
|
||||
const t = i18n.global.t;
|
||||
const progressStatus = computed(() => {
|
||||
if (props.progress.status === 'complete') {
|
||||
return 'success';
|
||||
} else if (props.progress.status === 'error') {
|
||||
return 'danger';
|
||||
} else if (props.progress.status === 'uploading') {
|
||||
return 'primary';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// 计算百分比
|
||||
const percent = computed(() => {
|
||||
@@ -125,4 +155,11 @@ const speed = computed(() => {
|
||||
return `${(speedBytes / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理取消上传
|
||||
const handleCancel = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
<template>
|
||||
<div class="machine-folder-upload-progress">
|
||||
<!-- 文件夹信息 -->
|
||||
<div class="progress-header">
|
||||
<span class="folder-name">{{ progress.folderName }}</span>
|
||||
<span class="file-count">{{ progress.uploadedFiles }}/{{ progress.totalFiles }}</span>
|
||||
<div class="w-full py-2 max-w-[500px]">
|
||||
<el-row>
|
||||
<TagCodePath :code="progress.authCertName" />
|
||||
</el-row>
|
||||
|
||||
<!-- 文件路径 -->
|
||||
<div v-if="progress.path" class="mb-3 px-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 truncate block" :title="progress.path">
|
||||
{{ progress.path }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="font-semibold text-sm text-gray-700 dark:text-gray-200 truncate flex-1 mr-2" :title="progress.folderName">{{
|
||||
progress.folderName
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mr-2">{{ progress.uploadedFiles }}/{{ progress.totalFiles }}</span>
|
||||
<!-- 取消按钮 -->
|
||||
<el-button v-if="progress.status === '' || progress.status === 'uploading'" type="danger" size="small" text @click="handleCancel">
|
||||
{{ $t('common.cancel') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 整体进度条 -->
|
||||
<el-progress
|
||||
:percentage="percent"
|
||||
:status="progress.status"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
|
||||
<el-progress :percentage="percent" :status="progressStatus" :stroke-width="10" />
|
||||
|
||||
<!-- 整体进度信息 -->
|
||||
<div class="progress-info">
|
||||
<span class="size-info">{{ formatSize(progress.uploadedSize) }} / {{ formatSize(progress.totalSize) }}</span>
|
||||
<span class="percent">{{ percent }}%</span>
|
||||
<div class="mt-1.5 flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatSize(progress.uploadedSize) }} / {{ formatSize(progress.totalSize) }}</span>
|
||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-200">{{ percent }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- 正在上传的文件列表 -->
|
||||
<div v-if="progress.uploadingFiles && progress.uploadingFiles.length > 0" class="uploading-files">
|
||||
<div class="section-title">正在上传 ({{ progress.uploadingFiles.length }} 个并发):</div>
|
||||
<div v-for="(file, index) in progress.uploadingFiles" :key="index" class="uploading-file">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span class="file-path">{{ file }}</span>
|
||||
<div v-if="progress.uploadingFiles && progress.uploadingFiles.length > 0" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xs font-semibold text-primary mb-2">
|
||||
{{ $t('machine.uploading') }} ({{ $t('machine.concurrentFiles', { count: progress.uploadingFiles.length }) }}):
|
||||
</div>
|
||||
<div v-for="(file, index) in progress.uploadingFiles" :key="index" class="flex items-center gap-1.5 py-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<el-icon class="animate-[rotating_2s_linear_infinite] text-primary"><Loading /></el-icon>
|
||||
<span class="flex-1 truncate">{{ file }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最后完成的文件 -->
|
||||
<div v-if="progress.lastFile && progress.status === 'uploading'" class="last-file">
|
||||
<el-icon class="success-icon"><Check /></el-icon>
|
||||
<span class="file-path">{{ progress.lastFile }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Loading, Check } from '@element-plus/icons-vue';
|
||||
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
|
||||
import { Loading } from '@element-plus/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -45,6 +55,22 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
onCancel: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
if (props.progress.status === 'complete') {
|
||||
return 'success';
|
||||
} else if (props.progress.status === 'error') {
|
||||
return 'danger';
|
||||
} else if (props.progress.status === 'uploading') {
|
||||
return 'primary';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// 计算百分比
|
||||
@@ -63,112 +89,11 @@ const formatSize = (bytes: number): string => {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 处理取消上传
|
||||
const handleCancel = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.machine-folder-upload-progress {
|
||||
padding: 8px 0;
|
||||
max-width: 500px;
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.folder-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.size-info {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.uploading-files {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.uploading-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
.loading-icon {
|
||||
animation: rotating 2s linear infinite;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.last-file {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--el-color-success-light-9);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
|
||||
.success-icon {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
color: var(--el-color-success);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +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<string, any> = new Map();
|
||||
// 存储上传任务的取消方法
|
||||
const uploadAborters = new Map<string, { abort: () => void; progress?: any }>();
|
||||
|
||||
/**
|
||||
* 构建机器文件上传进度组件属性
|
||||
*/
|
||||
const buildMachineFileUploadProgressProps = (): any => {
|
||||
return {
|
||||
progress: reactive({
|
||||
filename: '',
|
||||
uploadedSize: 0,
|
||||
totalSize: 0,
|
||||
timestamp: 0,
|
||||
status: '', // '' | 'success' | 'exception'
|
||||
}),
|
||||
};
|
||||
};
|
||||
// 存储待注册的 abort 方法(等待 WebSocket 消息到达)
|
||||
const pendingAborters = new Map<string, () => void>();
|
||||
|
||||
/**
|
||||
* 注册机器文件上传进度消息处理
|
||||
@@ -29,75 +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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +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<string, any>();
|
||||
// 存储上传任务的取消方法
|
||||
const folderUploadAborters = new Map<string, { abort: () => void; progress?: any }>();
|
||||
|
||||
/**
|
||||
* 构建文件夹上传进度组件的 props
|
||||
*/
|
||||
const buildMachineFolderUploadProgressProps = (): any => {
|
||||
return {
|
||||
progress: reactive({
|
||||
folderName: '',
|
||||
totalFiles: 0,
|
||||
uploadedFiles: 0,
|
||||
totalSize: 0,
|
||||
uploadedSize: 0,
|
||||
lastFile: '',
|
||||
uploadingFiles: [] as string[],
|
||||
timestamp: 0,
|
||||
status: '',
|
||||
}),
|
||||
};
|
||||
};
|
||||
// 存储待注册的 abort 方法(等待 WebSocket 消息到达)
|
||||
const pendingFolderAborters = new Map<string, () => void>();
|
||||
|
||||
/**
|
||||
* 注册文件夹上传进度消息处理
|
||||
@@ -37,79 +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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
import config from '@/common/config';
|
||||
import { createWebSocket, joinClientParams } from '@/common/request';
|
||||
import { getToken } from '@/common/utils/storage';
|
||||
import { randomUuid } from '@/common/utils/string';
|
||||
import { machineApi, uploadFile, uploadFolder } from '@/views/ops/machine/api';
|
||||
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';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -31,8 +33,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import TerminalSearch from './TerminalSearch.vue';
|
||||
import { TerminalStatus } from './common';
|
||||
import themes from './themes.js';
|
||||
import machine from '@/i18n/en/machine';
|
||||
import { downloadFile } from '@/common/utils/file';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -57,7 +57,7 @@ const props = defineProps({
|
||||
*/
|
||||
machineId: { type: Number, default: 0 },
|
||||
/**
|
||||
* 认证证书名称(用于文件传输)
|
||||
* 授权凭证名(用于文件传输)
|
||||
*/
|
||||
authCertName: { type: String, default: '' },
|
||||
/**
|
||||
@@ -370,68 +370,55 @@ const setupContextMenu = () => {
|
||||
terminalRef.value.addEventListener('contextmenu', async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// 如果没有 machineId,不显示文件传输菜单
|
||||
if (!props.machineId || !props.authCertName) {
|
||||
return; // 直接返回,不显示任何菜单
|
||||
}
|
||||
|
||||
// 获取选中的文本
|
||||
const selectedText = term.getSelection();
|
||||
|
||||
if (selectedText) {
|
||||
// 如果有选中文本,可能是文件路径
|
||||
showFileContextMenu(event, selectedText);
|
||||
} else {
|
||||
// 没有选中文本,显示通用菜单
|
||||
showGeneralContextMenu(event);
|
||||
}
|
||||
showContextMenu(event, term.getSelection());
|
||||
});
|
||||
};
|
||||
|
||||
// 显示文件下载右键菜单
|
||||
const showFileContextMenu = (event: MouseEvent, filePath: string) => {
|
||||
state.contextmenu.selectedItem = filePath;
|
||||
// 显示组合右键菜单
|
||||
const showContextMenu = (event: MouseEvent, selectedText: string) => {
|
||||
state.contextmenu.selectedItem = selectedText;
|
||||
state.contextmenu.dropdown = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
|
||||
// 始终添加上传文件和上传文件夹按钮
|
||||
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(() => false)
|
||||
.withHideFunc(() => !selectedText)
|
||||
.withOnClick(() => {
|
||||
downloadSelectedFile(state.contextmenu.selectedItem);
|
||||
contextmenuRef.value?.closeContextmenu();
|
||||
}),
|
||||
];
|
||||
|
||||
// 打开右键菜单
|
||||
contextmenuRef.value?.openContextmenu({});
|
||||
};
|
||||
|
||||
// 显示通用右键菜单(上传文件/文件夹)
|
||||
const showGeneralContextMenu = (event: MouseEvent) => {
|
||||
state.contextmenu.selectedItem = '';
|
||||
state.contextmenu.dropdown = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
|
||||
state.contextmenu.items = [
|
||||
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();
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -525,13 +512,11 @@ const uploadFilesToCurrentPath = async (files: FileList) => {
|
||||
const currentPath = await getCurrentPathOrDefault();
|
||||
|
||||
const file = files[0];
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用统一的 HTTP 上传方法
|
||||
uploadFile(
|
||||
const { uploadId, abort } = uploadFile(
|
||||
file,
|
||||
{
|
||||
uploadId,
|
||||
machineId: props.machineId as number,
|
||||
authCertName: props.authCertName as string,
|
||||
protocol: props.protocol,
|
||||
@@ -548,6 +533,9 @@ const uploadFilesToCurrentPath = async (files: FileList) => {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 注册取消方法
|
||||
registerUploadAborter(uploadId, abort);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
|
||||
}
|
||||
@@ -559,13 +547,10 @@ const uploadFolderToCurrentPath = async (files: FileList) => {
|
||||
// 获取当前路径
|
||||
const currentPath = await getCurrentPathOrDefault();
|
||||
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用文件夹上传
|
||||
uploadFolder(
|
||||
const { uploadId, abort } = uploadFolder(
|
||||
files,
|
||||
{
|
||||
uploadId,
|
||||
machineId: props.machineId as number,
|
||||
authCertName: props.authCertName as string,
|
||||
protocol: props.protocol,
|
||||
@@ -581,6 +566,9 @@ const uploadFolderToCurrentPath = async (files: FileList) => {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 注册取消方法
|
||||
registerFolderUploadAborter(uploadId, abort);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -141,5 +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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ function initI18n() {
|
||||
});
|
||||
|
||||
const themeConfig = getThemeConfig();
|
||||
const globalI18n = themeConfig.globalI18n || "zh-cn";
|
||||
const globalI18n = themeConfig?.globalI18n || 'zh-cn';
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/essentials/fallback.html#explicit-fallback-with-one-locale
|
||||
return createI18n({
|
||||
@@ -45,7 +45,7 @@ function initI18n() {
|
||||
silentFallbackWarn: true,
|
||||
fallbackWarn: false,
|
||||
locale: globalI18n,
|
||||
fallbackLocale: "zh-cn",
|
||||
fallbackLocale: 'zh-cn',
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: '全部关闭',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -219,5 +219,13 @@ export default {
|
||||
|
||||
running: '运行中',
|
||||
waitRun: '待运行',
|
||||
|
||||
// SQL执行
|
||||
sqlExecute: 'SQL执行',
|
||||
executedStatements: '已执行',
|
||||
elapsedTime: '已用时',
|
||||
scriptFileUploadSuccess: 'SQL文件【{filename}】执行成功',
|
||||
scriptFileUploadCancelled: 'SQL文件【{filename}】执行已取消',
|
||||
scriptFileUploadFailed: 'SQL文件【{filename}】执行失败: {error}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,5 +142,20 @@ export default {
|
||||
fileExceedsSysConf: '上传的文件超过系统配置的【{uploadMaxFileSize}】',
|
||||
fileUploadSuccess: '机器文件上传成功',
|
||||
fileUploadFail: '机器文件上传失败',
|
||||
fileUpload: '文件上传',
|
||||
|
||||
// 文件夹上传进度
|
||||
folderUploadProgress: '文件夹上传进度',
|
||||
folderUpload: '文件夹上传',
|
||||
uploading: '正在上传',
|
||||
concurrentFiles: '{count} 个并发',
|
||||
|
||||
// 上传通知
|
||||
uploadNotifications: {
|
||||
title: '上传通知',
|
||||
closeAll: '全部关闭',
|
||||
folders: '{count} 个文件夹',
|
||||
files: '{count} 个文件',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
@@ -89,8 +83,8 @@ export function getMachineRdpSocketUrl(authCertName: any) {
|
||||
* 文件上传参数
|
||||
*/
|
||||
export interface UploadParams {
|
||||
/** 上传ID(前端生成,保证唯一性) */
|
||||
uploadId: string;
|
||||
/** 上传ID(可选,不传则内部自动生成) */
|
||||
uploadId?: string;
|
||||
/** 机器ID */
|
||||
machineId: number;
|
||||
/** 认证证书名称 */
|
||||
@@ -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,76 +104,32 @@ export interface UploadOptions {
|
||||
* @param file 文件对象
|
||||
* @param params 上传参数
|
||||
* @param options 上传选项
|
||||
* @returns Promise<void>
|
||||
* @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象
|
||||
*/
|
||||
export async function uploadFile(file: File, params: UploadParams, options: UploadOptions = {}): Promise<void> {
|
||||
const { onProgress, onSuccess, onError } = options;
|
||||
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);
|
||||
formData.append('uploadId', params.uploadId);
|
||||
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);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹上传参数
|
||||
*/
|
||||
export interface FolderUploadParams {
|
||||
/** 上传ID(前端生成,保证唯一性) */
|
||||
uploadId: string;
|
||||
/** 上传ID(可选,不传则内部自动生成) */
|
||||
uploadId?: string;
|
||||
/** 机器ID */
|
||||
machineId: number;
|
||||
/** 认证证书名称 */
|
||||
@@ -207,27 +143,18 @@ export interface FolderUploadParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹上传选项
|
||||
*/
|
||||
export interface FolderUploadOptions {
|
||||
/** 成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件夹(使用 /upload-folder 接口)
|
||||
* 上传文件夹(使用 /upload-folder 接口)
|
||||
* @param files 文件列表
|
||||
* @param params 上传参数
|
||||
* @param options 上传选项
|
||||
* @returns Promise<void>
|
||||
* @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象
|
||||
*/
|
||||
export async function uploadFolder(files: FileList | File[], params: FolderUploadParams, options: FolderUploadOptions = {}): Promise<void> {
|
||||
const { onSuccess, onError } = options;
|
||||
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', params.uploadId);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('basePath', params.path);
|
||||
formData.append('machineId', String(params.machineId));
|
||||
formData.append('authCertName', params.authCertName);
|
||||
@@ -247,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 };
|
||||
}
|
||||
|
||||
@@ -306,21 +306,22 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
|
||||
import { ElInput, ElMessage } from 'element-plus';
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
|
||||
import { machineApi, uploadFile, uploadFolder } from '../api';
|
||||
import { randomUuid } from '@/common/utils/string';
|
||||
import { registerUploadAborter } from '@/components/sysmsg/machine/machine-file-upload-progress';
|
||||
import { registerFolderUploadAborter } from '@/components/sysmsg/machine/machine-folder-upload-progress';
|
||||
|
||||
import { joinClientParams } from '@/common/request';
|
||||
import config from '@/common/config';
|
||||
import { isTrue, notBlank } from '@/common/assert';
|
||||
import { getToken } from '@/common/utils/storage';
|
||||
import { convertToBytes, formatByteSize } from '@/common/utils/format';
|
||||
import config from '@/common/config';
|
||||
import { joinClientParams } from '@/common/request';
|
||||
import { getMachineConfig } from '@/common/sysconfig';
|
||||
import { MachineProtocolEnum } from '../enums';
|
||||
import { convertToBytes, formatByteSize } from '@/common/utils/format';
|
||||
import { getToken } from '@/common/utils/storage';
|
||||
import { fuzzyMatchField } from '@/common/utils/string';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18nDeleteConfirm, useI18nDeleteSuccessMsg } from '@/hooks/useI18n';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { MachineProtocolEnum } from '../enums';
|
||||
|
||||
const MachineFileContent = defineAsyncComponent(() => import('./MachineFileContent.vue'));
|
||||
|
||||
@@ -790,16 +791,10 @@ function handleFolderUpload(e: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[MachineFile] Folder upload:', files.length, 'files, total size:', totalFileSize);
|
||||
|
||||
// 生成唯一的 uploadId
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用文件夹上传接口
|
||||
uploadFolder(
|
||||
const { uploadId, abort } = uploadFolder(
|
||||
files,
|
||||
{
|
||||
uploadId,
|
||||
machineId: props.machineId as number,
|
||||
authCertName: props.authCertName as string,
|
||||
protocol: props.protocol as number,
|
||||
@@ -819,6 +814,9 @@ function handleFolderUpload(e: any) {
|
||||
}
|
||||
);
|
||||
|
||||
// 注册取消方法
|
||||
registerFolderUploadAborter(uploadId, abort);
|
||||
|
||||
// 清空已选择的文件夹
|
||||
const folderEle: any = document.getElementById('folderUploadInput');
|
||||
if (folderEle) {
|
||||
@@ -835,14 +833,10 @@ const handleFileUpload = (content: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成唯一的 uploadId
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 上传文件
|
||||
uploadFile(
|
||||
const { uploadId, abort } = uploadFile(
|
||||
file,
|
||||
{
|
||||
uploadId,
|
||||
machineId: props.machineId as number,
|
||||
authCertName: props.authCertName as string,
|
||||
protocol: props.protocol as number,
|
||||
@@ -862,6 +856,9 @@ const handleFileUpload = (content: any) => {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 注册取消方法
|
||||
registerUploadAborter(uploadId, abort);
|
||||
};
|
||||
|
||||
const uploadSuccess = (res: any) => {
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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+" ") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,4 +28,5 @@ type SqlReaderExec struct {
|
||||
Filename string
|
||||
|
||||
ClientId string // 客户端id,若存在则会向其发送执行进度消息
|
||||
UploadId string // 上传id,用于记录上传进度
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type DbInfo struct {
|
||||
|
||||
InstanceId uint64 // 实例id
|
||||
Id uint64 // dbId
|
||||
DbCode string
|
||||
Name string
|
||||
|
||||
Type DbType // 类型,mysql postgres等
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
|
||||
ErrExistRunFailSql
|
||||
ErrNeedSubmitWorkTicket
|
||||
ErrSqlExecCancelled
|
||||
|
||||
// db transfer
|
||||
LogDtsSave
|
||||
|
||||
@@ -20,6 +20,7 @@ var Zh_CN = map[i18n.MsgId]string{
|
||||
|
||||
ErrExistRunFailSql: "存在执行错误的sql",
|
||||
ErrNeedSubmitWorkTicket: "该操作需要提交工单审批执行",
|
||||
ErrSqlExecCancelled: "SQL执行已取消",
|
||||
|
||||
// db transfer
|
||||
LogDtsSave: "dts-保存数据迁移任务",
|
||||
|
||||
@@ -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)
|
||||
@@ -313,6 +320,8 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
|
||||
progressMsgEvent := &msgdto.MsgTmplSendEvent{
|
||||
TmplChannel: msgdto.MsgTmplMachineFileUploadProgress,
|
||||
Params: collx.M{
|
||||
"authCertName": authCertName,
|
||||
"path": path,
|
||||
"uploadId": uploadId,
|
||||
"filename": fileheader.Filename,
|
||||
"uploadedSize": readSize,
|
||||
@@ -337,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{
|
||||
@@ -417,6 +447,8 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
||||
startMsgEvent := &msgdto.MsgTmplSendEvent{
|
||||
TmplChannel: msgdto.MsgTmplMachineFolderUploadProgress,
|
||||
Params: collx.M{
|
||||
"authCertName": authCertName,
|
||||
"path": basePath,
|
||||
"uploadId": uploadId,
|
||||
"folderName": folderName,
|
||||
"totalFiles": totalFiles,
|
||||
@@ -473,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 {
|
||||
@@ -491,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()
|
||||
|
||||
// 从正在上传列表移除
|
||||
@@ -531,9 +563,10 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
||||
progressMsgEvent := &msgdto.MsgTmplSendEvent{
|
||||
TmplChannel: msgdto.MsgTmplMachineFolderUploadProgress,
|
||||
Params: collx.M{
|
||||
"authCertName": authCertName,
|
||||
"path": basePath,
|
||||
"uploadId": uploadId,
|
||||
"folderName": folderName,
|
||||
"lastFile": fullPath,
|
||||
"totalFiles": totalFiles,
|
||||
"uploadedFiles": uploadedFiles,
|
||||
"totalSize": totalSize,
|
||||
@@ -553,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
|
||||
}
|
||||
}
|
||||
|
||||
// 累加已上传大小
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ var (
|
||||
entity.MsgSubtypeMachineFileUploadSuccess,
|
||||
entity.MsgStatusRead,
|
||||
imsg.MachineFileUploadSuccessMsg,
|
||||
MsgChannelSite, MsgChannelWs)
|
||||
MsgChannelSite)
|
||||
|
||||
MsgTmplMachineFileUploadFail = newMsgTmpl(entity.MsgTypeNotify,
|
||||
entity.MsgSubtypeMachineFileUploadFail,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
Version = "v1.11.1"
|
||||
Version = "v1.11.2"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user