Compare commits

...

3 Commits

Author SHA1 Message Date
meilin.huang
b0a68bf12c feat: 机器文件上传优化等,支持取消操作 2026-05-18 22:17:19 +08:00
meilin.huang
7f94fd168b feat: 机器文件上传优化 2026-05-17 22:31:08 +08:00
meilin.huang
9b7e569b3a feat: 机器终端支持文件&文件夹上传、支持选中文件下载 2026-05-14 21:29:13 +08:00
49 changed files with 2274 additions and 321 deletions

View File

@@ -9,18 +9,24 @@ ARG MAYFLY_GO_URL=https://gitee.com/dromara/mayfly-go/releases/download/${MAYFLY
RUN wget -cO mayfly-go.zip ${MAYFLY_GO_URL} && \
unzip mayfly-go.zip && \
mv ${MAYFLY_GO_DIR_NAME}/* /opt
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
COPY --from=builder /opt/mayfly-go /usr/local/bin/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
EXPOSE 18888
CMD ["mayfly-go"]
CMD ["mayfly-go"]

View File

@@ -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,18 +33,17 @@
"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",
"sortablejs": "^1.15.7",
"sql-formatter": "^15.7.3",
"trzsz": "^1.1.6",
"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"
@@ -70,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"
},

View File

@@ -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'));

View File

@@ -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;

View File

@@ -1,47 +0,0 @@
import { buildProgressProps } from '@/components/progress-notify/progress-notify';
import syssocket from './syssocket';
import { h, reactive } from 'vue';
import { ElNotification } from 'element-plus';
import ProgressNotify from '@/components/progress-notify/progress-notify.vue';
export async function initSysMsgs() {
await registerDbSqlExecProgress();
}
const sqlExecNotifyMap: Map<string, any> = new Map();
async function registerDbSqlExecProgress() {
await syssocket.registerMsgHandler('sqlScriptRunProgress', function (message: any) {
const content = message.params;
const id = content.id;
let progress = sqlExecNotifyMap.get(id);
if (content.terminated) {
if (progress != undefined) {
progress.notification?.close();
sqlExecNotifyMap.delete(id);
progress = undefined;
}
return;
}
if (progress == undefined) {
progress = {
props: reactive(buildProgressProps()),
notification: undefined,
};
}
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);
}
});
}

View File

@@ -6,6 +6,16 @@ import { MsgSubtypeEnum } from './commonEnum';
import EnumValue from './Enum';
import { h } from 'vue';
import { MessageRenderer } from '@/components/message/message';
import { initMachineSysMsgs } from '@/components/sysmsg/machine';
import { initDbSysMsgs } from '@/components/sysmsg/db';
/**
* 初始化全局系统消息
*/
export function initSysMsgs() {
initMachineSysMsgs();
initDbSysMsgs();
}
class SysSocket {
/**

View File

@@ -0,0 +1,16 @@
/**
* 下载文件
* @param url 文件下载地址
*/
export function downloadFile(url: string) {
// 使用隐藏的 iframe 下载,避免页面闪烁
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
// 1秒后移除 iframe
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}

View File

@@ -1,12 +0,0 @@
export const buildProgressProps = (): any => {
return {
progress: {
title: {
type: String,
},
executedStatements: {
type: Number,
},
},
};
};

View File

@@ -1,34 +0,0 @@
<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>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive } from 'vue';
import { formatTime } from 'element-plus/es/components/countdown/src/utils';
import { buildProgressProps } from './progress-notify';
const props = defineProps(buildProgressProps());
const state = reactive({
elapsedTime: '00:00:00',
});
let timer: any = undefined;
const startTime = Date.now();
onMounted(async () => {
timer = setInterval(() => {
const elapsed = Date.now() - startTime;
state.elapsedTime = formatTime(elapsed, 'HH:mm:ss');
}, 1000);
});
onUnmounted(async () => {
if (timer != undefined) {
clearInterval(timer); // 在Vue实例销毁前清除我们的定时器
timer = undefined;
}
});
</script>

View 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>

View File

@@ -0,0 +1,83 @@
<template>
<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';
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({
elapsedTime: '00:00:00',
});
let timer: any = undefined;
const startTime = Date.now();
onMounted(async () => {
timer = setInterval(() => {
const elapsed = Date.now() - startTime;
state.elapsedTime = formatTime(elapsed, 'HH:mm:ss');
}, 1000);
});
onUnmounted(async () => {
if (timer != undefined) {
clearInterval(timer); // 在Vue实例销毁前清除我们的定时器
timer = undefined;
}
});
// 处理取消执行
const handleCancel = () => {
if (props.onCancel) {
props.onCancel();
}
};
</script>

View File

@@ -0,0 +1,90 @@
import DbSqlExecProgress from './DbSqlExecProgress.vue';
import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager';
import syssocket from '@/common/syssocket';
import { reactive, nextTick } from 'vue';
// 存储SQL执行任务的取消方法
const sqlExecAborters = new Map<string, { abort: () => void; progress?: any }>();
// 存储待注册的 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;
// SQL执行完成
if (content.terminated) {
completeNotification(id, 1000);
sqlExecAborters.delete(id);
return;
}
// 构建组件props
const props = {
progress: reactive({
title: content.title || '',
executedStatements: content.executedStatements || 0,
terminated: content.terminated || false,
status: content.status || '',
dbCode: content.dbCode || '',
dbName: content.dbName || '',
}),
onCancel: () => {
const aborter = sqlExecAborters.get(id);
if (aborter) {
aborter.abort();
// 更新通知状态为取消
if (aborter.progress) {
nextTick(() => {
aborter.progress.status = 'cancelled';
aborter.progress.terminated = true;
});
// 延迟后关闭通知
setTimeout(() => {
completeNotification(id, 1000);
sqlExecAborters.delete(id);
}, 1500);
} else {
sqlExecAborters.delete(id);
}
}
},
};
// 创建或更新通知
createOrUpdateNotification(id, 'sqlScriptRun', content, DbSqlExecProgress, props, {
title: message.title || 'db.sqlExecute',
onCancel: props.onCancel,
});
// 如果有待注册的 abort 方法,现在注册
const pendingAbort = pendingSqlExecAborters.get(id);
if (pendingAbort) {
sqlExecAborters.set(id, { abort: pendingAbort, progress: props.progress });
pendingSqlExecAborters.delete(id);
}
});
}
/**
* 注册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);
}
}

View File

@@ -0,0 +1,5 @@
import { registerDbSqlExecProgress } from './db-sql-exec-progress';
export function initDbSysMsgs() {
registerDbSqlExecProgress();
}

View 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();
};

View File

@@ -0,0 +1,165 @@
<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-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="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 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') }}
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
{{ formatByteSize(progress.totalSize) }}
</span>
</span>
<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>
<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 class="font-mono font-semibold text-primary">
{{ speed }}
</span>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { formatByteSize } from '@/common/utils/format';
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: '' | 'complete' | 'error' | 'uploading';
}
interface Props {
progress?: Progress;
onCancel?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
progress: () => ({
authCertName: '',
path: '',
filename: '',
percent: 0,
uploadedSize: 0,
totalSize: 0,
timestamp: 0,
status: '',
}),
onCancel: 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 '';
}
});
// 计算百分比
const percent = computed(() => {
if (!props.progress.totalSize || !props.progress.uploadedSize) {
return 0;
}
return Math.min(100, Math.floor((props.progress.uploadedSize / props.progress.totalSize) * 100));
});
// 计算速度
const lastTimestamp = ref(0);
const lastUploadedSize = ref(0);
const speed = computed(() => {
if (!props.progress.timestamp || !props.progress.uploadedSize) {
return '0 B/s';
}
// 首次更新,记录初始值
if (lastTimestamp.value === 0) {
lastTimestamp.value = props.progress.timestamp;
lastUploadedSize.value = props.progress.uploadedSize;
return '0 B/s';
}
// 计算时间差和大小差
const timeDiff = (props.progress.timestamp - lastTimestamp.value) / 1000; // 转换为秒
const sizeDiff = props.progress.uploadedSize - lastUploadedSize.value;
// 更新时间戳和大小
lastTimestamp.value = props.progress.timestamp;
lastUploadedSize.value = props.progress.uploadedSize;
// 计算速度
if (timeDiff <= 0) return '0 B/s';
const speedBytes = sizeDiff / timeDiff;
// 格式化速度
if (speedBytes < 1024) {
return `${speedBytes.toFixed(0)} B/s`;
} else if (speedBytes < 1024 * 1024) {
return `${(speedBytes / 1024).toFixed(1)} KB/s`;
} else {
return `${(speedBytes / (1024 * 1024)).toFixed(1)} MB/s`;
}
});
// 处理取消上传
const handleCancel = () => {
if (props.onCancel) {
props.onCancel();
}
};
</script>

View File

@@ -0,0 +1,99 @@
<template>
<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="progressStatus" :stroke-width="10" />
<!-- 整体进度信息 -->
<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="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>
</template>
<script lang="ts" setup>
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import { Loading } from '@element-plus/icons-vue';
import { computed } from 'vue';
const props = defineProps({
progress: {
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 '';
}
});
// 计算百分比
const percent = computed(() => {
if (!props.progress.totalSize || !props.progress.uploadedSize) {
return 0;
}
return Math.min(100, Math.floor((props.progress.uploadedSize / props.progress.totalSize) * 100));
});
// 格式化文件大小
const formatSize = (bytes: number): string => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
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>

View File

@@ -0,0 +1,7 @@
import { registerMachineFileUploadProgress } from './machine-file-upload-progress';
import { registerFolderUploadProgressHandler } from './machine-folder-upload-progress';
export function initMachineSysMsgs() {
registerMachineFileUploadProgress();
registerFolderUploadProgressHandler();
}

View File

@@ -0,0 +1,95 @@
import syssocket from '@/common/syssocket';
import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager';
import MachineFileUploadProgress from './MachineFileUploadProgress.vue';
import { reactive, nextTick } from 'vue';
// 存储上传任务的取消方法
const uploadAborters = new Map<string, { abort: () => void; progress?: any }>();
// 存储待注册的 abort 方法(等待 WebSocket 消息到达)
const pendingAborters = new Map<string, () => void>();
/**
* 注册机器文件上传进度消息处理
*/
export async function registerMachineFileUploadProgress() {
await syssocket.registerMsgHandler('machineFileUploadProgress', function (message: any) {
const content = message.params;
const uploadId = content.uploadId;
// 上传完成或失败
if (content.status === 'complete' || content.status === 'error') {
completeNotification(uploadId, 1000);
uploadAborters.delete(uploadId);
return;
}
// 构建组件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();
// 更新通知状态为取消
if (aborter.progress) {
nextTick(() => {
aborter.progress.status = 'error';
aborter.progress.filename = '已取消: ' + (aborter.progress.filename || '');
});
// 延迟后关闭通知
setTimeout(() => {
completeNotification(uploadId, 1000);
uploadAborters.delete(uploadId);
}, 1500);
} else {
uploadAborters.delete(uploadId);
}
}
},
};
// 创建或更新上传通知
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);
}
}

View File

@@ -0,0 +1,103 @@
import syssocket from '@/common/syssocket';
import { createOrUpdateNotification, completeNotification, activeNotifications } from '../global-notification-manager';
import MachineFolderUploadProgress from './MachineFolderUploadProgress.vue';
import { reactive, nextTick } from 'vue';
// 存储上传任务的取消方法
const folderUploadAborters = new Map<string, { abort: () => void; progress?: any }>();
// 存储待注册的 abort 方法(等待 WebSocket 消息到达)
const pendingFolderAborters = new Map<string, () => void>();
/**
* 注册文件夹上传进度消息处理
*/
export async function registerFolderUploadProgressHandler() {
await syssocket.registerMsgHandler('machineFolderUploadProgress', function (message: any) {
const content = message.params;
const uploadId = content.uploadId;
if (!uploadId) {
return;
}
// 上传完成或失败
if (content.status === 'complete' || content.status === 'error') {
completeNotification(uploadId, 1000);
folderUploadAborters.delete(uploadId);
return;
}
// 构建组件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();
// 更新通知状态为取消
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);
}
}
},
};
// 创建或更新上传通知
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);
}
}

View File

@@ -3,26 +3,36 @@
<div ref="terminalRef" class="h-full w-full" :style="{ background: getTerminalTheme().background }" />
<TerminalSearch ref="terminalSearchRef" :search-addon="state.addon.search" @close="focus" />
<!-- 右键菜单 -->
<Contextmenu ref="contextmenuRef" :dropdown="state.contextmenu.dropdown" :items="state.contextmenu.items" />
</div>
</template>
<script lang="ts" setup>
import '@xterm/xterm/css/xterm.css';
import { Terminal, ITheme } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { SearchAddon } from '@xterm/addon-search';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ITheme, Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { storeToRefs } from 'pinia';
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 { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import { machineApi, uploadFile, uploadFolder } from '@/views/ops/machine/api';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import TerminalSearch from './TerminalSearch.vue';
import { TerminalStatus } from './common';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import themes from './themes.js';
import { TrzszFilter } from 'trzsz';
import { useI18n } from 'vue-i18n';
import { createWebSocket } from '@/common/request';
const { t } = useI18n();
@@ -42,12 +52,29 @@ const props = defineProps({
socketUrl: {
type: String,
},
/**
* 机器ID用于文件传输
*/
machineId: { type: Number, default: 0 },
/**
* 授权凭证名(用于文件传输)
*/
authCertName: { type: String, default: '' },
/**
* 文件ID用于文件传输
*/
fileId: { type: Number, default: 0 },
/**
* 协议类型(用于文件传输)
*/
protocol: { type: Number, default: 1 },
});
const emit = defineEmits(['statusChange']);
const terminalRef: any = ref(null);
const terminalSearchRef: any = ref(null);
const contextmenuRef: any = ref(null);
const { themeConfig } = storeToRefs(useThemeConfig());
@@ -56,6 +83,11 @@ let term: Terminal;
let socket: WebSocket;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// 静默模式标志:用于发送不显示的命令(如 pwd
let silentMode = false;
let silentResolve: ((value: string) => void) | null = null;
let silentBuffer = '';
const state = reactive({
// 插件
addon: {
@@ -64,6 +96,15 @@ const state = reactive({
weblinks: null as any,
},
status: -11,
// 右键菜单
contextmenu: {
dropdown: {
x: 0,
y: 0,
},
items: [] as ContextmenuItem[],
selectedItem: '',
},
});
onMounted(() => {
@@ -171,6 +212,42 @@ const initSocket = async () => {
console.log('terminal socket close...', e.reason);
state.status = TerminalStatus.Disconnected;
};
// 监听 WebSocket 消息,将服务器输出写入终端
socket.onmessage = (e: MessageEvent) => {
// 如果是静默模式,捕获输出但不显示
if (silentMode && silentResolve) {
silentBuffer += e.data;
// 使用正则表达式匹配绝对路径
// 匹配以 / 开头,不包含空格、换行符的连续字符
const pathMatch = silentBuffer.match(/(\/[\w\-\./_]*)/);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
console.log('[Silent Mode] Extracted path:', path);
silentResolve(path);
silentMode = false;
silentResolve = null;
silentBuffer = '';
return; // 不写入终端
}
// 如果缓冲区太大,超时处理
if (silentBuffer.length > 500) {
console.warn('[Silent Mode] Buffer too large, using default path');
silentResolve('~');
silentMode = false;
silentResolve = null;
silentBuffer = '';
}
return; // 不写入终端
}
// 正常模式,写入终端显示
term.write(e.data);
};
};
const startHeartbeat = () => {
@@ -200,38 +277,24 @@ const loadAddon = () => {
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 注册 trzsz
// initialize trzsz filter
const trzsz = new TrzszFilter({
// write the server output to the terminal
writeToTerminal: (data: any) => term.write(typeof data === 'string' ? data : new Uint8Array(data)),
// send the user input to the server
sendToServer: sendData,
// the terminal columns
terminalColumns: term.cols,
// there is a windows shell
isWindowsShell: false,
// 注册终端输入事件监听(将用户输入发送到 socket
term.onData((data: string) => sendData(data));
term.onBinary((data: string) => sendData(data));
// 注册终端大小变化事件
term.onResize((size: { cols: number; rows: number }) => {
sendResize(size.cols, size.rows);
});
// let trzsz process the server output
socket?.addEventListener('message', (e) => trzsz.processServerOutput(e.data));
// let trzsz process the user input
term.onData((data) => trzsz.processTerminalInput(data));
term.onBinary((data) => trzsz.processBinaryInput(data));
term.onResize((size) => {
sendResize(size.cols, size.rows);
// tell trzsz the terminal columns has been changed
trzsz.setTerminalColumns(size.cols);
});
// enable drag files or directories to upload
terminalRef.value.addEventListener('dragover', (event: Event) => event.preventDefault());
terminalRef.value.addEventListener('drop', (event: any) => {
event.preventDefault();
trzsz
.uploadFiles(event.dataTransfer.items)
.then(() => console.log('upload success'))
.catch((err: any) => console.log(err));
handleFileDrop(event.dataTransfer.items);
});
// 添加右键菜单支持文件下载和上传
setupContextMenu();
};
// 写入内容至终端
@@ -302,6 +365,277 @@ const closeSocket = () => {
socket && socket.readyState === 1 && socket.close();
};
// 设置右键菜单
const setupContextMenu = () => {
terminalRef.value.addEventListener('contextmenu', async (event: MouseEvent) => {
event.preventDefault();
showContextMenu(event, term.getSelection());
});
};
// 显示组合右键菜单
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(() => !selectedText)
.withOnClick(() => {
downloadSelectedFile(state.contextmenu.selectedItem);
}),
new ContextmenuItem('uploadFile', 'components.terminal.uploadFileToCurrentDir')
.withIcon('Upload')
.withHideFunc(() => !props.machineId || !props.authCertName)
.withOnClick(() => {
triggerFilesUpload();
}),
new ContextmenuItem('uploadFolder', 'components.terminal.uploadFolderToCurrentDir')
.withIcon('Upload')
.withHideFunc(() => !props.machineId || !props.authCertName)
.withOnClick(() => {
triggerFolderUpload();
}),
];
// 打开右键菜单
contextmenuRef.value?.openContextmenu({});
};
// 下载选中的文件
const downloadSelectedFile = async (filePath: string) => {
if (!props.machineId || !props.authCertName) {
ElMessage.error(t('components.terminal.downloadFailed', { error: '缺少机器信息' }));
return;
}
try {
// 获取当前路径
const currentPath = await getCurrentPathOrDefault();
// 从完整路径中提取文件名
const filename = filePath.trim().split('/').pop() || filePath.trim();
// 拼接完整路径
const fullPath = currentPath.endsWith('/') ? `${currentPath}${filename}` : `${currentPath}/${filename}`;
// 先验证文件是否存在
try {
await machineApi.fileStat.request({
machineId: props.machineId,
protocol: props.protocol,
fileId: props.fileId,
authCertName: props.authCertName,
path: fullPath,
});
} catch (error: any) {
return;
}
// 下载文件
downloadFile(
`${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${encodeURIComponent(fullPath)}&machineId=${props.machineId}&authCertName=${props.authCertName}&fileId=${props.fileId}&protocol=${props.protocol}&${joinClientParams()}`
);
ElMessage.success(t('components.terminal.startDownload', { file: fullPath }));
} catch (error: any) {
ElMessage.error(t('components.terminal.downloadFailed', { error: error.message }));
}
};
// 触发文件上传
const triggerFilesUpload = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.style.display = 'none';
input.addEventListener('change', () => {
if (input.files && input.files.length > 0) {
uploadFilesToCurrentPath(input.files);
}
document.body.removeChild(input);
});
document.body.appendChild(input);
input.click();
};
// 触发文件夹上传
const triggerFolderUpload = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
(input as any).webkitdirectory = true;
(input as any).directory = true;
input.style.display = 'none';
input.addEventListener('change', () => {
if (input.files && input.files.length > 0) {
uploadFolderToCurrentPath(input.files);
}
document.body.removeChild(input);
});
document.body.appendChild(input);
input.click();
};
// 上传文件到当前路径
const uploadFilesToCurrentPath = async (files: FileList) => {
try {
// 获取当前路径
const currentPath = await getCurrentPathOrDefault();
const file = files[0];
// 使用统一的 HTTP 上传方法
const { uploadId, abort } = uploadFile(
file,
{
machineId: props.machineId as number,
authCertName: props.authCertName as string,
protocol: props.protocol,
fileId: props.fileId as number,
path: currentPath,
filename: file.name,
},
{
onSuccess: () => {
ElMessage.success(t('components.terminal.uploadSuccess'));
},
onError: (error) => {
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
},
}
);
// 注册取消方法
registerUploadAborter(uploadId, abort);
} catch (error: any) {
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
}
};
// 上传文件夹到当前路径
const uploadFolderToCurrentPath = async (files: FileList) => {
try {
// 获取当前路径
const currentPath = await getCurrentPathOrDefault();
// 使用文件夹上传
const { uploadId, abort } = uploadFolder(
files,
{
machineId: props.machineId as number,
authCertName: props.authCertName as string,
protocol: props.protocol,
fileId: props.fileId as number,
path: currentPath,
},
{
onSuccess: () => {
ElMessage.success(t('components.terminal.uploadSuccess'));
},
onError: (error: Error) => {
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
},
}
);
// 注册取消方法
registerFolderUploadAborter(uploadId, abort);
} catch (error: any) {
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
}
};
// 获取当前路径(静默模式,失败返回默认值)
const getCurrentPathOrDefault = async (): Promise<string> => {
try {
return await getCurrentPath();
} catch (e) {
console.warn('获取当前路径失败,使用默认路径 ~:', e);
return '~';
}
};
// 获取当前路径(静默模式,不在终端显示)
const getCurrentPath = (): Promise<string> => {
return new Promise((resolve, reject) => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
reject('WebSocket 未连接');
return;
}
// 设置静默模式
silentMode = true;
silentResolve = resolve;
silentBuffer = '';
// 发送 pwd 命令(使用 \r 模拟回车)
sendData('pwd\r');
// 设置超时,防止永远等待
setTimeout(() => {
if (silentMode) {
silentMode = false;
silentResolve = null;
silentBuffer = '';
console.warn('[Silent Mode] Timeout getting current path');
resolve('~'); // 超时返回默认路径
}
}, 2000); // 2秒超时
});
};
// 处理文件拖拽上传
const handleFileDrop = async (items: DataTransferItemList) => {
if (!props.machineId || !props.authCertName) {
ElMessage.error(t('components.terminal.uploadFailed', { error: '缺少机器信息' }));
return;
}
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
await uploadFilesToCurrentPath(files as any);
}
};
const close = () => {
console.log('in terminal body close');
closeSocket();
@@ -319,4 +653,6 @@ const getStatus = (): TerminalStatus => {
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
</script>
<style lang="scss"></style>
<style lang="scss" scoped>
// 终端容器样式
</style>

View File

@@ -78,6 +78,10 @@
:ref="(el) => setTerminalRef(el, openTerminal.terminalId)"
:cmd="openTerminal.cmd"
:socket-url="openTerminal.socketUrl"
:machine-id="openTerminal.meta?.id || 0"
:auth-cert-name="openTerminal.meta?.selectAuthCert?.name || ''"
:file-id="0"
:protocol="openTerminal.meta?.protocol || 1"
/>
</div>
</el-dialog>

View File

@@ -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',
@@ -270,6 +272,30 @@ export default {
previous: 'Previous',
next: 'Next',
noMatchMsg: 'No matching item is found',
// File transfer related
downloadFile: 'Download File',
downloadSelectedFile: 'Download Selected Path File',
uploadFile: 'Upload File',
uploadFileToCurrentDir: 'Upload File to Current Directory',
uploadFolder: 'Upload Folder',
uploadFolderToCurrentDir: 'Upload Folder to Current Directory',
chooseUploadType: 'Choose Upload Type',
startDownload: 'Start downloading file: {file}',
downloadFailed: 'File download failed: {error}',
uploadSuccess: 'File uploaded successfully',
uploadFailed: 'File upload failed: {error}',
uploading: 'Upload progress: {percent}%',
uploadToPath: 'File will be uploaded to: {path}',
uploadPathTip: 'Tip: File will be uploaded to home directory (~). To upload to another directory, use cd command first, then use drag-and-drop upload.',
// Machine file upload progress notification
machineFileUpload: {
uploadProgress: 'Machine File Upload Progress',
uploaded: 'Uploaded',
totalSize: 'Total Size',
speed: 'Speed',
},
},
crontab: {
crontabInputPlaceholder: 'Click the left button to configure',
@@ -329,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',
},
},
},
};

View File

@@ -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',

View File

@@ -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',
},
},
};

View File

@@ -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,
});
}

View File

@@ -54,6 +54,8 @@ export default {
previousStep: '上一步',
nextStep: '下一步',
copy: '复制',
paste: '粘贴',
pasteFailed: '粘贴失败',
copySuccess: '复制成功',
copyCell: '复制单元格',
search: '搜索',
@@ -279,6 +281,30 @@ export default {
previous: '上一个',
next: '下一个',
noMatchMsg: '未查询到匹配项',
// 文件传输相关
downloadFile: '下载文件',
downloadSelectedFile: '下载选中路径的文件',
uploadFile: '上传文件',
uploadFileToCurrentDir: '上传文件到当前目录',
uploadFolder: '上传文件夹',
uploadFolderToCurrentDir: '上传文件夹到当前目录',
chooseUploadType: '请选择上传类型',
startDownload: '开始下载文件: {file}',
downloadFailed: '文件下载失败: {error}',
uploadSuccess: '文件上传成功',
uploadFailed: '文件上传失败: {error}',
uploading: '上传进度: {percent}%',
uploadToPath: '文件将上传到路径: {path}',
uploadPathTip: '提示: 文件将上传到用户家目录(~)。如需上传到其他目录,请先在终端中执行 cd 命令切换到目标目录,然后使用拖拽上传功能。',
// 机器文件上传进度通知
machineFileUpload: {
uploadProgress: '机器文件上传进度',
uploaded: '已上传',
totalSize: '总大小',
speed: '速度',
},
},
crontab: {
crontabInputPlaceholder: '可点击左边按钮配置',
@@ -338,5 +364,13 @@ export default {
title: '请选择图标',
placeholder: '请输入内容搜索图标或者选择图标',
},
// 系统消息通知
sysmsg: {
notifications: {
title: '系统通知',
closeAll: '全部关闭',
},
},
},
};

View File

@@ -219,5 +219,13 @@ export default {
running: '运行中',
waitRun: '待运行',
// SQL执行
sqlExecute: 'SQL执行',
executedStatements: '已执行',
elapsedTime: '已用时',
scriptFileUploadSuccess: 'SQL文件【{filename}】执行成功',
scriptFileUploadCancelled: 'SQL文件【{filename}】执行已取消',
scriptFileUploadFailed: 'SQL文件【{filename}】执行失败: {error}',
},
};

View File

@@ -142,5 +142,20 @@ export default {
fileExceedsSysConf: '上传的文件超过系统配置的【{uploadMaxFileSize}】',
fileUploadSuccess: '机器文件上传成功',
fileUploadFail: '机器文件上传失败',
fileUpload: '文件上传',
// 文件夹上传进度
folderUploadProgress: '文件夹上传进度',
folderUpload: '文件夹上传',
uploading: '正在上传',
concurrentFiles: '{count} 个并发',
// 上传通知
uploadNotifications: {
title: '上传通知',
closeAll: '全部关闭',
folders: '{count} 个文件夹',
files: '{count} 个文件',
},
},
};

View File

@@ -16,7 +16,7 @@ import '@/theme/tailwind.css';
import '@/assets/font/font.css';
import '@/assets/icon/icon.js';
import { getThemeConfig } from './common/utils/storage';
import { initSysMsgs } from './common/sysmsgs';
import { initSysMsgs } from './common/syssocket';
const app = createApp(App);

View File

@@ -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 };
}

View File

@@ -11,6 +11,8 @@
<el-descriptions-item :span="1" :label="$t('common.code')">{{ state.detail.code }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.name')">{{ state.detail.name }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')"><TagCodePath :code="state.detail.code" /></el-descriptions-item>
<el-descriptions-item :span="3" label="Host">
<SvgIcon :name="getDbDialect(state.detail.type).getInfo().icon" :size="20" />
{{ state.detail.host }}:{{ state.detail.port }}
@@ -34,6 +36,7 @@ import { reactive } from 'vue';
import { dbApi } from '../api';
import { formatDate } from '@/common/utils/format';
import { getDbDialect } from '../dialect/index';
import TagCodePath from '../../component/TagCodePath.vue';
const props = defineProps({
id: {

View File

@@ -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) {

View File

@@ -72,7 +72,15 @@
draggable
append-to-body
>
<TerminalBody ref="terminal" :cmd="terminalDialog.cmd" :socket-url="getMachineTerminalSocketUrl(props.authCertName)" />
<TerminalBody
ref="terminal"
:cmd="terminalDialog.cmd"
:socket-url="getMachineTerminalSocketUrl(props.authCertName)"
:machine-id="props.machineId"
:auth-cert-name="props.authCertName"
:file-id="0"
:protocol="1"
/>
</el-dialog>
<script-edit

View File

@@ -1,5 +1,4 @@
import Api from '@/common/Api';
import { joinClientParams } from '@/common/request';
import Api, { UploadOptions } from '@/common/Api';
export const machineApi = {
// 获取权限列表
@@ -35,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'),
@@ -65,10 +65,117 @@ export const cmdConfApi = {
delete: Api.newDelete('/machine/security/cmd-confs/{id}'),
};
/**
* 获取终端 WebSocket URL
*/
export function getMachineTerminalSocketUrl(authCertName: any) {
return `/machines/terminal/${authCertName}`;
}
/**
* 获取 RDP WebSocket URL
*/
export function getMachineRdpSocketUrl(authCertName: any) {
return `/api/machines/rdp/${authCertName}`;
}
/**
* 文件上传参数
*/
export interface UploadParams {
/** 上传ID可选不传则内部自动生成 */
uploadId?: string;
/** 机器ID */
machineId: number;
/** 认证证书名称 */
authCertName: string;
/** 协议类型 */
protocol: number;
/** 文件ID */
fileId: number;
/** 目标路径 */
path: string;
/** 文件名 */
filename: string;
}
/**
* 上传单个文件
* @param file 文件对象
* @param params 上传参数
* @param options 上传选项
* @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象
*/
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', uploadId);
formData.append('machineId', String(params.machineId));
formData.append('authCertName', params.authCertName);
formData.append('protocol', String(params.protocol));
formData.append('fileId', String(params.fileId));
formData.append('path', params.path);
const { abort } = machineApi.uploadFile.upload(formData, options);
return { uploadId, abort };
}
/**
* 文件夹上传参数
*/
export interface FolderUploadParams {
/** 上传ID可选不传则内部自动生成 */
uploadId?: string;
/** 机器ID */
machineId: number;
/** 认证证书名称 */
authCertName: string;
/** 协议类型 */
protocol: number;
/** 文件ID */
fileId: number;
/** 目标路径 */
path: string;
}
/**
* 上传文件夹(使用 /upload-folder 接口)
* @param files 文件列表
* @param params 上传参数
* @param options 上传选项
* @returns { uploadId: string; abort: () => void } 返回包含 uploadId 和中止方法的对象
*/
export function uploadFolder(files: FileList | File[], params: FolderUploadParams, options: UploadOptions = {}): { uploadId: string; abort: () => void } {
// 业务层生成 uploadId
const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
const formData = new FormData();
formData.append('uploadId', uploadId);
formData.append('basePath', params.path);
formData.append('machineId', String(params.machineId));
formData.append('authCertName', params.authCertName);
formData.append('protocol', String(params.protocol));
// 添加所有文件
const paths: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relativePath = (file as any).webkitRelativePath || file.name;
formData.append('files', file);
paths.push(relativePath);
}
// 添加路径数组
paths.forEach((path) => {
formData.append('paths', path);
});
// 使用 Api.upload 发起请求
const { abort } = machineApi.uploadFolder.upload(formData, options);
return { uploadId, abort };
}

View File

@@ -11,7 +11,7 @@
<el-descriptions-item :span="1" :label="$t('common.code')">{{ state.machineDetail.code }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.name')">{{ state.machineDetail.name }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')"><TagCodePath :path="state.machineDetail.tags" /></el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')"><TagCodePath :code="state.machineDetail.code" /></el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ state.machineDetail.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('machine.port')">{{ state.machineDetail.port }}</el-descriptions-item>

View File

@@ -1,9 +1,6 @@
<template>
<div class="machine-file h-full">
<div class="h-full flex flex-col">
<!-- 文件上传进度条 -->
<el-progress v-if="uploadProgressShow" class="ml-4 w-[90%]" :text-inside="true" :stroke-width="20" :percentage="progressNum" />
<!-- 文件路径 -->
<el-row class="mb-2 ml-4">
<el-breadcrumb separator-icon="ArrowRight">
@@ -54,7 +51,7 @@
:before-upload="beforeUpload"
:on-success="uploadSuccess"
action=""
:http-request="uploadFile"
:http-request="handleFileUpload"
:headers="{ token }"
:show-file-list="false"
name="file"
@@ -73,7 +70,7 @@
ref="folderUploadRef"
webkitdirectory
directory
@change="uploadFolder"
@change="handleFolderUpload"
style="display: none"
/>
</div>
@@ -309,20 +306,22 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
import { ElInput, ElMessage } from 'element-plus';
import { machineApi } from '../api';
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 { 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'));
@@ -352,8 +351,6 @@ const state = reactive({
basePath: '', // 基础路径
nowPath: '', // 当前路径
loading: true,
progressNum: 0,
uploadProgressShow: false,
fileNameFilter: '',
files: [] as any,
selectionFiles: [] as any,
@@ -381,7 +378,7 @@ const state = reactive({
machineConfig: { uploadMaxFileSize: '1GB' },
});
const { basePath, nowPath, loading, fileNameFilter, progressNum, uploadProgressShow, fileContent, createFileDialog } = toRefs(state);
const { basePath, nowPath, loading, fileNameFilter, fileContent, createFileDialog } = toRefs(state);
onMounted(async () => {
state.basePath = props.path;
@@ -777,90 +774,91 @@ function addFinderToList() {
folderUploadRef.value.click();
}
function uploadFolder(e: any) {
//e.target.files为文件夹里面的文件
// 把文件夹数据放到formData里面下面的files和paths字段根据接口来定
var form = new FormData();
form.append('basePath', state.nowPath);
form.append('authCertName', props.authCertName as any);
form.append('machineId', props.machineId as any);
form.append('protocol', props.protocol as any);
form.append('fileId', props.fileId as any);
let totalFileSize = 0;
for (let file of e.target.files) {
totalFileSize += file.size;
form.append('files', file);
form.append('paths', file.webkitRelativePath);
function handleFolderUpload(e: any) {
const files = e.target.files;
if (!files || files.length === 0) {
return;
}
try {
if (!checkUploadFileSize(totalFileSize)) {
return;
}
// 计算总文件大小
let totalFileSize = 0;
for (let file of files) {
totalFileSize += file.size;
}
// 上传操作
machineApi.uploadFile
.xhrReq(form, {
url: `${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/upload-folder?${joinClientParams()}`,
headers: { 'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundaryF1uyUD0tWdqmJqpl' },
onUploadProgress: onUploadProgress,
baseURL: '',
timeout: 3 * 60 * 60 * 1000,
})
.then(() => {
// 检查文件大小
if (!checkUploadFileSize(totalFileSize)) {
return;
}
// 使用文件夹上传接口
const { uploadId, abort } = uploadFolder(
files,
{
machineId: props.machineId as number,
authCertName: props.authCertName as string,
protocol: props.protocol as number,
fileId: props.fileId as number,
path: state.nowPath,
},
{
onSuccess: () => {
ElMessage.success(t('machine.uploadSuccess'));
setTimeout(() => {
refresh();
state.uploadProgressShow = false;
}, 3000);
})
.catch(() => {
state.uploadProgressShow = false;
});
} finally {
//无论上传成功与否,都把已选择的文件夹清空,否则选择同一文件夹没有反应
const folderEle: any = document.getElementById('folderUploadInput');
if (folderEle) {
folderEle.value = '';
}, 1000);
},
onError: (error) => {
ElMessage.error(error.message);
},
}
);
// 注册取消方法
registerFolderUploadAborter(uploadId, abort);
// 清空已选择的文件夹
const folderEle: any = document.getElementById('folderUploadInput');
if (folderEle) {
folderEle.value = '';
}
}
const onUploadProgress = (progressEvent: any) => {
state.uploadProgressShow = true;
let complete = ((progressEvent.loaded / progressEvent.total) * 100) | 0;
state.progressNum = complete;
};
const uploadFile = (content: any) => {
const params = new FormData();
const handleFileUpload = (content: any) => {
const file = content.file;
const path = state.nowPath;
params.append('file', content.file);
params.append('path', path);
params.append('authCertName', props.authCertName as any);
params.append('machineId', props.machineId as any);
params.append('protocol', props.protocol as any);
params.append('fileId', props.fileId as any);
params.append('token', token);
machineApi.uploadFile
.xhrReq(params, {
url: `${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/upload?${joinClientParams()}`,
headers: { 'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundaryF1uyUD0tWdqmJqpl' },
onUploadProgress: onUploadProgress,
baseURL: '',
timeout: 3 * 60 * 60 * 1000,
})
.then(() => {
ElMessage.success(t('machine.uploadSuccess'));
setTimeout(() => {
refresh();
state.uploadProgressShow = false;
}, 3000);
})
.catch(() => {
state.uploadProgressShow = false;
});
// 检查文件大小
if (!checkUploadFileSize(file.size)) {
return;
}
// 上传文件
const { uploadId, abort } = uploadFile(
file,
{
machineId: props.machineId as number,
authCertName: props.authCertName as string,
protocol: props.protocol as number,
fileId: props.fileId as number,
path: path,
filename: file.name,
},
{
onSuccess: () => {
ElMessage.success(t('machine.uploadSuccess'));
setTimeout(() => {
refresh();
}, 1000);
},
onError: (error: Error) => {
ElMessage.error(error.message);
},
}
);
// 注册取消方法
registerUploadAborter(uploadId, abort);
};
const uploadSuccess = (res: any) => {

View File

@@ -39,6 +39,10 @@
@status-change="terminalStatusChange(dt.key, $event)"
:ref="(el: any) => setTerminalRef(el, dt.key)"
:socket-url="dt.socketUrl"
:machine-id="dt.params.id"
:auth-cert-name="dt.authCert"
:file-id="0"
:protocol="dt.params.protocol"
/>
<machine-rdp
v-if="dt.params.protocol != MachineProtocolEnum.Ssh.value"

View File

@@ -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,
}))
}

View File

@@ -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+" ") {

View File

@@ -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

View File

@@ -28,4 +28,5 @@ type SqlReaderExec struct {
Filename string
ClientId string // 客户端id若存在则会向其发送执行进度消息
UploadId string // 上传id用于记录上传进度
}

View File

@@ -27,6 +27,7 @@ type DbInfo struct {
InstanceId uint64 // 实例id
Id uint64 // dbId
DbCode string
Name string
Type DbType // 类型mysql postgres等

View File

@@ -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",

View File

@@ -30,6 +30,7 @@ const (
ErrExistRunFailSql
ErrNeedSubmitWorkTicket
ErrSqlExecCancelled
// db transfer
LogDtsSave

View File

@@ -20,6 +20,7 @@ var Zh_CN = map[i18n.MsgId]string{
ErrExistRunFailSql: "存在执行错误的sql",
ErrNeedSubmitWorkTicket: "该操作需要提交工单审批执行",
ErrSqlExecCancelled: "SQL执行已取消",
// db transfer
LogDtsSave: "dts-保存数据迁移任务",

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"fmt"
"io"
"io/fs"
@@ -15,6 +16,7 @@ import (
msgdto "mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/pkg/event"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/contextx"
"mayfly-go/pkg/global"
"mayfly-go/pkg/gox"
"mayfly-go/pkg/logx"
@@ -28,6 +30,7 @@ import (
"sort"
"strings"
"sync"
"time"
"github.com/pkg/sftp"
"github.com/spf13/cast"
@@ -83,6 +86,39 @@ const (
max_read_size = 1 * 1024 * 1024
)
// progressReader 用于 HTTP 上传时推送进度
type progressReader struct {
reader io.Reader
total int64
readSize int64
uploadId string
filename string
path string
ctx context.Context
startTime time.Time
onProgress func(readSize int64) // 进度回调函数
}
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)
// 如果有回调函数,调用它
if r.onProgress != nil {
r.onProgress(r.readSize)
}
}
return n, err
}
func (m *MachineFile) MachineFiles(rc *req.Ctx) {
condition := &entity.MachineFile{MachineId: GetMachineId(rc)}
res, err := m.machineFileApp.GetPageList(condition, rc.GetPageParam())
@@ -250,6 +286,7 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
protocol := cast.ToInt(rc.PostForm("protocol"))
machineId := cast.ToUint64(rc.PostForm("machineId"))
authCertName := rc.PostForm("authCertName")
uploadId := rc.PostForm("uploadId") // 前端传递的 uploadId
fileheader, err := rc.FormFile("file")
biz.ErrIsNilAppendErr(err, "read form file error: %s")
@@ -262,6 +299,43 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
file, _ := fileheader.Open()
defer file.Close()
// 是否需要推送进度通知
hasProgressNotify := uploadId != ""
startTime := time.Now()
var mi *mcm.MachineInfo
var reader io.Reader = file
if hasProgressNotify {
// 创建带进度回调的 Reader
reader = &progressReader{
reader: file,
total: fileheader.Size,
uploadId: uploadId,
filename: fileheader.Filename,
path: path,
ctx: ctx,
startTime: startTime,
onProgress: func(readSize int64) {
progressMsgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFileUploadProgress,
Params: collx.M{
"authCertName": authCertName,
"path": path,
"uploadId": uploadId,
"filename": fileheader.Filename,
"uploadedSize": readSize,
"totalSize": fileheader.Size,
"status": "uploading",
"timestamp": time.Now().UnixMilli(),
},
ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, progressMsgEvent)
},
}
}
opForm := &dto.MachineFileOp{
MachineId: machineId,
AuthCertName: authCertName,
@@ -269,9 +343,43 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
Path: path,
}
mi, err := m.machineFileApp.UploadFile(ctx, opForm, fileheader.Filename, file)
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{
TmplChannel: msgdto.MsgTmplMachineFileUploadProgress,
Params: collx.M{
"uploadId": uploadId,
"status": "complete",
},
ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, progressMsgEvent)
}
// 发送文件上传结果消息
msgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFileUploadSuccess,
@@ -307,19 +415,19 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
fileheaders := mf.File["files"]
biz.IsTrue(len(fileheaders) > 0, "files cannot be empty")
allFileSize := collx.ArrayReduce(fileheaders, 0, func(i int64, fh *multipart.FileHeader) int64 {
totalSize := collx.ArrayReduce(fileheaders, 0, func(i int64, fh *multipart.FileHeader) int64 {
return i + fh.Size
})
ctx := rc.MetaCtx
maxUploadFileSize := config.GetMachine().UploadMaxFileSize
biz.IsTrueI(ctx, allFileSize <= maxUploadFileSize, imsg.ErrUploadFileOutOfLimit, "size", maxUploadFileSize)
biz.IsTrueI(ctx, totalSize <= maxUploadFileSize, imsg.ErrUploadFileOutOfLimit, "size", maxUploadFileSize)
paths := mf.Value["paths"]
authCertName := mf.Value["authCertName"][0]
machineId := cast.ToUint64(mf.Value["machineId"][0])
// protocol
protocol := cast.ToInt(mf.Value["protocol"][0])
uploadId := mf.Value["uploadId"][0] // 前端传递的 uploadId
opForm := &dto.MachineFileOp{
MachineId: machineId,
@@ -327,82 +435,246 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
AuthCertName: authCertName,
}
if protocol == entity.MachineProtocolRdp {
m.machineFileApp.UploadFiles(ctx, opForm, basePath, fileheaders, paths)
return
}
folderName := filepath.Dir(paths[0])
mcli, err := m.machineFileApp.GetMachineCli(rc.MetaCtx, authCertName)
biz.ErrIsNil(err)
totalFiles := len(fileheaders)
uploadedFiles := 0
mi := mcli.Info
// 是否需要推送进度通知
hasProgressNotify := uploadId != ""
sftpCli, err := mcli.GetSftpCli()
biz.ErrIsNil(err)
rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", basePath, folderName))
folderFiles := make([]FolderFile, len(paths))
// 先创建目录并将其包装为folderFile结构
mkdirs := make(map[string]bool, 0)
for i, path := range paths {
dir := filepath.Dir(path)
// 目录已建,则无需重复建
if !mkdirs[dir] {
biz.ErrIsNilAppendErr(sftpCli.MkdirAll(basePath+"/"+dir), "create dir error: %s")
mkdirs[dir] = true
}
folderFiles[i] = FolderFile{
Dir: dir,
Fileheader: fileheaders[i],
// 发送开始通知
if hasProgressNotify {
startMsgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFolderUploadProgress,
Params: collx.M{
"authCertName": authCertName,
"path": basePath,
"uploadId": uploadId,
"folderName": folderName,
"totalFiles": totalFiles,
"uploadedFiles": 0,
"totalSize": totalSize,
"uploadedSize": 0,
"percent": 0,
"status": "uploading",
},
ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, startMsgEvent)
}
msgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFileUploadSuccess,
Params: collx.M{
"filename": folderName,
"path": basePath,
"machineName": mi.Name,
"machineCode": mi.Code,
},
ReceiverIds: []uint64{rc.GetLoginAccount().Id},
}
if protocol == entity.MachineProtocolRdp {
// RDP 协议上传
m.machineFileApp.UploadFiles(ctx, opForm, basePath, fileheaders, paths)
uploadedFiles = totalFiles
} else {
// SSH 协议上传
mcli, err := m.machineFileApp.GetMachineCli(rc.MetaCtx, authCertName)
biz.ErrIsNil(err)
// 分组处理
groupNum := 30
chunks := collx.ArraySplit(folderFiles, groupNum)
mi := mcli.Info
sftpCli, err := mcli.GetSftpCli()
biz.ErrIsNil(err)
rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", basePath, folderName))
var wg sync.WaitGroup
isSuccess := true
for _, chunk := range chunks {
wg.Go(func() {
defer gox.Recover(func(e error) {
isSuccess = false
msgEvent.TmplChannel = msgdto.MsgTmplMachineFileUploadFail
msgEvent.Params["error"] = e.Error()
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, msgEvent)
})
for _, file := range chunk {
fileHeader := file.Fileheader
dir := file.Dir
file, _ := fileHeader.Open()
defer file.Close()
logx.Debugf("upload folder: dir=%s -> filename=%s", dir, fileHeader.Filename)
createfile, err := sftpCli.Create(fmt.Sprintf("%s/%s/%s", basePath, dir, fileHeader.Filename))
biz.ErrIsNilAppendErr(err, "create file error: %s")
defer createfile.Close()
io.Copy(createfile, file)
folderFiles := make([]FolderFile, len(paths))
// 先创建目录并将其包装为folderFile结构
mkdirs := make(map[string]bool, 0)
for i, path := range paths {
dir := filepath.Dir(path)
// 目录已建,则无需重复建
if !mkdirs[dir] {
biz.ErrIsNilAppendErr(sftpCli.MkdirAll(basePath+"/"+dir), "create dir error: %s")
mkdirs[dir] = true
}
})
folderFiles[i] = FolderFile{
Dir: dir,
Fileheader: fileheaders[i],
}
}
// 分组处理
groupNum := 3
chunks := collx.ArraySplit(folderFiles, groupNum)
var wg sync.WaitGroup
var mu sync.Mutex // 保护并发访问
var currentUploading []string // 正在上传的文件列表
var uploadedSize int64 = 0 // 已上传的总大小
for _, chunk := range chunks {
wg.Go(func() {
defer gox.Recover(func(e error) {
logx.ErrorfContext(ctx, "upload folder error: %s", e)
})
for _, file := range chunk {
fileHeader := file.Fileheader
dir := file.Dir
fullPath := dir + "/" + fileHeader.Filename
file, _ := fileHeader.Open()
// 添加到正在上传列表
if hasProgressNotify {
mu.Lock()
currentUploading = append(currentUploading, fullPath)
mu.Unlock()
}
createfile, err := sftpCli.Create(fmt.Sprintf("%s/%s/%s", basePath, dir, fileHeader.Filename))
if err != nil {
logx.ErrorfContext(ctx, "create file error: %s", err)
file.Close()
// 从正在上传列表移除
if hasProgressNotify {
mu.Lock()
for i, p := range currentUploading {
if p == fullPath {
currentUploading = append(currentUploading[:i], currentUploading[i+1:]...)
break
}
}
mu.Unlock()
}
return
}
// 使用 progressReader 包装,追踪单个文件上传进度
var reader io.Reader = file
if hasProgressNotify {
reader = &progressReader{
reader: file,
total: fileHeader.Size,
uploadId: uploadId,
filename: fileHeader.Filename,
path: fullPath,
ctx: ctx,
startTime: time.Now(),
// 回调函数:更新全局进度
onProgress: func(readBytes int64) {
mu.Lock()
currentTotalUploaded := uploadedSize + readBytes
uploadingFiles := make([]string, len(currentUploading))
copy(uploadingFiles, currentUploading)
mu.Unlock()
progressMsgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFolderUploadProgress,
Params: collx.M{
"authCertName": authCertName,
"path": basePath,
"uploadId": uploadId,
"folderName": folderName,
"totalFiles": totalFiles,
"uploadedFiles": uploadedFiles,
"totalSize": totalSize,
"uploadedSize": currentTotalUploaded,
"status": "uploading",
"uploadingFiles": uploadingFiles,
"timestamp": time.Now().UnixMilli(),
},
ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, progressMsgEvent)
},
}
}
_, err = io.Copy(createfile, reader)
if err != nil {
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
}
}
// 累加已上传大小
mu.Lock()
uploadedSize += fileHeader.Size
mu.Unlock()
createfile.Close()
file.Close()
// 从正在上传列表移除,增加已完成计数
if hasProgressNotify {
mu.Lock()
for i, p := range currentUploading {
if p == fullPath {
currentUploading = append(currentUploading[:i], currentUploading[i+1:]...)
break
}
}
uploadedFiles++
mu.Unlock()
}
}
})
}
// 等待所有协程执行完成
wg.Wait()
}
// 等待所有协程执行完成
wg.Wait()
if isSuccess {
// 发送完成通知
if hasProgressNotify {
status := "complete"
if uploadedFiles < totalFiles {
status = "error"
}
completeMsgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFolderUploadProgress,
Params: collx.M{
"uploadId": uploadId,
"folderName": folderName,
"totalFiles": totalFiles,
"uploadedFiles": uploadedFiles,
"totalSize": totalSize,
"uploadedSize": totalSize, // 完成时已上传大小等于总大小
"percent": 100,
"status": status,
},
ReceiverIds: []uint64{contextx.GetLoginAccount(ctx).Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, completeMsgEvent)
}
// 发送成功/失败消息通知
if protocol != entity.MachineProtocolRdp {
// SSH 协议:使用 mcli 获取机器信息
mcli, err := m.machineFileApp.GetMachineCli(rc.MetaCtx, authCertName)
if err == nil && mcli != nil {
msgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFileUploadSuccess,
Params: collx.M{
"filename": folderName,
"path": basePath,
"machineName": mcli.Info.Name,
"machineCode": mcli.Info.Code,
},
ReceiverIds: []uint64{rc.GetLoginAccount().Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, msgEvent)
}
} else {
// RDP 协议:直接发送通知
msgEvent := &msgdto.MsgTmplSendEvent{
TmplChannel: msgdto.MsgTmplMachineFileUploadSuccess,
Params: collx.M{
"filename": folderName,
"path": basePath,
},
ReceiverIds: []uint64{rc.GetLoginAccount().Id},
}
global.EventBus.Publish(ctx, event.EventTopicMsgTmplSend, msgEvent)
}
}

View File

@@ -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
}

View File

@@ -43,7 +43,7 @@ var (
entity.MsgSubtypeMachineFileUploadSuccess,
entity.MsgStatusRead,
imsg.MachineFileUploadSuccessMsg,
MsgChannelSite, MsgChannelWs)
MsgChannelSite)
MsgTmplMachineFileUploadFail = newMsgTmpl(entity.MsgTypeNotify,
entity.MsgSubtypeMachineFileUploadFail,
@@ -80,6 +80,30 @@ var (
Channels: []*entity.MsgChannel{MsgChannelWs},
}
// 机器文件上传进度消息模板
MsgTmplMachineFileUploadProgress = &MsgTmplChannel{
Tmpl: &entity.MsgTmpl{
ExtraData: model.ExtraData{
Extra: collx.M{
"category": "machineFileUploadProgress",
},
},
},
Channels: []*entity.MsgChannel{MsgChannelWs},
}
// 机器文件夹上传进度消息模板
MsgTmplMachineFolderUploadProgress = &MsgTmplChannel{
Tmpl: &entity.MsgTmpl{
ExtraData: model.ExtraData{
Extra: collx.M{
"category": "machineFolderUploadProgress",
},
},
},
Channels: []*entity.MsgChannel{MsgChannelWs},
}
MsgTmplFlowUserTaskTodo = newMsgTmpl(entity.MsgTypeNotify,
entity.MsgSubtypeFlowUserTaskTodo,
entity.MsgStatusUnRead,

View File

@@ -1,5 +1,5 @@
package config
const (
Version = "v1.11.0"
Version = "v1.11.2"
)