mirror of
https://gitee.com/dromara/mayfly-go
synced 2026-05-15 15:35:19 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7e569b3a |
24
Dockerfile
24
Dockerfile
@@ -7,20 +7,34 @@ 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 wget -cO mayfly-go.zip ${MAYFLY_GO_URL} && \
|
||||
RUN apk add --no-cache wget unzip && \
|
||||
wget -cO mayfly-go.zip ${MAYFLY_GO_URL} && \
|
||||
unzip mayfly-go.zip && \
|
||||
mv ${MAYFLY_GO_DIR_NAME}/* /opt
|
||||
cp -r ${MAYFLY_GO_DIR_NAME}/. /opt/ && \
|
||||
rm -rf mayfly-go.zip ${MAYFLY_GO_DIR_NAME}
|
||||
|
||||
|
||||
FROM ${BASEIMAGES}
|
||||
|
||||
ARG TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
COPY --from=builder /opt/mayfly-go /usr/local/bin/mayfly-go
|
||||
# 安装必要的运行时依赖并创建非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/
|
||||
|
||||
# 设置工作目录和权限
|
||||
WORKDIR /mayfly-go
|
||||
RUN chown -R mayfly:mayfly /mayfly-go
|
||||
|
||||
# 切换到非root用户
|
||||
USER mayfly
|
||||
|
||||
EXPOSE 18888
|
||||
|
||||
CMD ["mayfly-go"]
|
||||
CMD ["./mayfly-go"]
|
||||
@@ -39,7 +39,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
16
frontend/src/common/utils/file.ts
Normal file
16
frontend/src/common/utils/file.ts
Normal 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);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const buildProgressProps = (): any => {
|
||||
return {
|
||||
progress: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
executedStatements: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -7,9 +7,16 @@
|
||||
<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 props = defineProps({
|
||||
progress: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: '',
|
||||
executedStatements: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
elapsedTime: '00:00:00',
|
||||
@@ -1,16 +1,25 @@
|
||||
import { buildProgressProps } from '@/components/progress-notify/progress-notify';
|
||||
import syssocket from './syssocket';
|
||||
import { h, reactive } from 'vue';
|
||||
import ProgressNotify from './DbSqlExecProgress.vue';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import ProgressNotify from '@/components/progress-notify/progress-notify.vue';
|
||||
|
||||
export async function initSysMsgs() {
|
||||
await registerDbSqlExecProgress();
|
||||
}
|
||||
import { h, reactive } from 'vue';
|
||||
import syssocket from '@/common/syssocket';
|
||||
|
||||
const sqlExecNotifyMap: Map<string, any> = new Map();
|
||||
|
||||
async function registerDbSqlExecProgress() {
|
||||
// 构建 props(私有函数,不导出)
|
||||
const buildProgressProps = (): any => {
|
||||
return {
|
||||
progress: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
executedStatements: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function registerDbSqlExecProgress() {
|
||||
await syssocket.registerMsgHandler('sqlScriptRunProgress', function (message: any) {
|
||||
const content = message.params;
|
||||
const id = content.id;
|
||||
5
frontend/src/components/sysmsg/db/index.ts
Normal file
5
frontend/src/components/sysmsg/db/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerDbSqlExecProgress } from './db-sql-exec-progress';
|
||||
|
||||
export function initDbSysMsgs() {
|
||||
registerDbSqlExecProgress();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="w-full py-1">
|
||||
<!-- 文件名 -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<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>
|
||||
</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" />
|
||||
</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">
|
||||
<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>
|
||||
<span class="font-mono font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ formatByteSize(progress.totalSize) }}
|
||||
</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 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatByteSize } from '@/common/utils/format';
|
||||
import { i18n } from '@/i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface Progress {
|
||||
filename: string;
|
||||
percent: number;
|
||||
uploadedSize: number;
|
||||
totalSize: number;
|
||||
timestamp?: number; // 时间戳,用于计算速度
|
||||
status: '' | 'success' | 'exception' | 'warning';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
progress?: Progress;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
progress: () => ({
|
||||
filename: '',
|
||||
percent: 0,
|
||||
uploadedSize: 0,
|
||||
totalSize: 0,
|
||||
timestamp: 0,
|
||||
status: '',
|
||||
}),
|
||||
});
|
||||
|
||||
const t = i18n.global.t;
|
||||
|
||||
// 计算百分比
|
||||
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`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<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>
|
||||
|
||||
<!-- 整体进度条 -->
|
||||
<el-progress
|
||||
:percentage="percent"
|
||||
:status="progress.status"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
|
||||
<!-- 整体进度信息 -->
|
||||
<div class="progress-info">
|
||||
<span class="size-info">{{ formatSize(progress.uploadedSize) }} / {{ formatSize(progress.totalSize) }}</span>
|
||||
<span class="percent">{{ 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>
|
||||
</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 { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
progress: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 计算百分比
|
||||
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];
|
||||
};
|
||||
</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>
|
||||
7
frontend/src/components/sysmsg/machine/index.ts
Normal file
7
frontend/src/components/sysmsg/machine/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerMachineFileUploadProgress } from './machine-file-upload-progress';
|
||||
import { registerFolderUploadProgressHandler } from './machine-folder-upload-progress';
|
||||
|
||||
export function initMachineSysMsgs() {
|
||||
registerMachineFileUploadProgress();
|
||||
registerFolderUploadProgressHandler();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import syssocket from '@/common/syssocket';
|
||||
import { reactive, h } from 'vue';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import MachineFileUploadProgress from './MachineFileUploadProgress.vue';
|
||||
|
||||
// 文件上传进度通知映射表(key: uploadId, value: 通知实例)
|
||||
const fileUploadNotifyMap: Map<string, any> = new Map();
|
||||
|
||||
/**
|
||||
* 构建机器文件上传进度组件属性
|
||||
*/
|
||||
const buildMachineFileUploadProgressProps = (): any => {
|
||||
return {
|
||||
progress: reactive({
|
||||
filename: '',
|
||||
uploadedSize: 0,
|
||||
totalSize: 0,
|
||||
timestamp: 0,
|
||||
status: '', // '' | 'success' | 'exception'
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册机器文件上传进度消息处理
|
||||
*/
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取或创建通知
|
||||
let notify = fileUploadNotifyMap.get(uploadId);
|
||||
if (!notify) {
|
||||
notify = {
|
||||
props: buildMachineFileUploadProgressProps(),
|
||||
notification: undefined,
|
||||
};
|
||||
fileUploadNotifyMap.set(uploadId, notify);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
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 (!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));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[MachineFileUpload] Failed to update notification VNode:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { h, reactive } from 'vue';
|
||||
import MachineFolderUploadProgress from '@/components/sysmsg/machine/MachineFolderUploadProgress.vue';
|
||||
import syssocket from '@/common/syssocket';
|
||||
|
||||
// 文件夹上传通知 Map
|
||||
const folderUploadNotifyMap = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* 构建文件夹上传进度组件的 props
|
||||
*/
|
||||
const buildMachineFolderUploadProgressProps = (): any => {
|
||||
return {
|
||||
progress: reactive({
|
||||
folderName: '',
|
||||
totalFiles: 0,
|
||||
uploadedFiles: 0,
|
||||
totalSize: 0,
|
||||
uploadedSize: 0,
|
||||
lastFile: '',
|
||||
uploadingFiles: [] as string[],
|
||||
timestamp: 0,
|
||||
status: '',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册文件夹上传进度消息处理
|
||||
*/
|
||||
export async function registerFolderUploadProgressHandler() {
|
||||
await syssocket.registerMsgHandler('machineFolderUploadProgress', function (message: any) {
|
||||
const content = message.params;
|
||||
const uploadId = content.uploadId;
|
||||
|
||||
if (!uploadId) {
|
||||
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);
|
||||
}
|
||||
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';
|
||||
|
||||
// 强制更新 VNode
|
||||
try {
|
||||
if (notify.notification && notify.notification.state) {
|
||||
notify.notification.state.message = h(MachineFolderUploadProgress, notify.props);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[MachineFolderUpload] Failed to update notification VNode:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 { getToken } from '@/common/utils/storage';
|
||||
import { randomUuid } from '@/common/utils/string';
|
||||
import { machineApi, uploadFile, uploadFolder } from '@/views/ops/machine/api';
|
||||
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
|
||||
import { useThemeConfig } from '@/store/themeConfig';
|
||||
import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
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';
|
||||
import machine from '@/i18n/en/machine';
|
||||
import { downloadFile } from '@/common/utils/file';
|
||||
|
||||
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,289 @@ const closeSocket = () => {
|
||||
socket && socket.readyState === 1 && socket.close();
|
||||
};
|
||||
|
||||
// 设置右键菜单
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 显示文件下载右键菜单
|
||||
const showFileContextMenu = (event: MouseEvent, filePath: string) => {
|
||||
state.contextmenu.selectedItem = filePath;
|
||||
state.contextmenu.dropdown = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
|
||||
state.contextmenu.items = [
|
||||
new ContextmenuItem('download', 'components.terminal.downloadSelectedFile')
|
||||
.withIcon('Download')
|
||||
.withHideFunc(() => false)
|
||||
.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)
|
||||
.withOnClick(() => {
|
||||
triggerFilesUpload();
|
||||
contextmenuRef.value?.closeContextmenu();
|
||||
}),
|
||||
new ContextmenuItem('uploadFolder', 'components.terminal.uploadFolderToCurrentDir')
|
||||
.withIcon('Upload')
|
||||
.withHideFunc(() => false)
|
||||
.withOnClick(() => {
|
||||
triggerFolderUpload();
|
||||
contextmenuRef.value?.closeContextmenu();
|
||||
}),
|
||||
];
|
||||
|
||||
// 打开右键菜单
|
||||
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];
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用统一的 HTTP 上传方法
|
||||
uploadFile(
|
||||
file,
|
||||
{
|
||||
uploadId,
|
||||
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 }));
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(t('components.terminal.uploadFailed', { error: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
// 上传文件夹到当前路径
|
||||
const uploadFolderToCurrentPath = async (files: FileList) => {
|
||||
try {
|
||||
// 获取当前路径
|
||||
const currentPath = await getCurrentPathOrDefault();
|
||||
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用文件夹上传
|
||||
uploadFolder(
|
||||
files,
|
||||
{
|
||||
uploadId,
|
||||
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 }));
|
||||
},
|
||||
}
|
||||
);
|
||||
} 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 +665,6 @@ const getStatus = (): TerminalStatus => {
|
||||
|
||||
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
|
||||
</script>
|
||||
<style lang="scss"></style>
|
||||
<style lang="scss" scoped>
|
||||
// 终端容器样式
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -270,6 +270,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',
|
||||
|
||||
@@ -279,6 +279,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: '可点击左边按钮配置',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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;
|
||||
|
||||
export const machineApi = {
|
||||
// 获取权限列表
|
||||
@@ -65,10 +71,205 @@ 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;
|
||||
/** 相对路径(文件夹上传时使用) */
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传选项
|
||||
*/
|
||||
export interface UploadOptions {
|
||||
/** 进度回调 */
|
||||
onProgress?: (percent: number, uploadedSize: number, totalSize: number, speed: string) => void;
|
||||
/** 成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件
|
||||
* @param file 文件对象
|
||||
* @param params 上传参数
|
||||
* @param options 上传选项
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function uploadFile(file: File, params: UploadParams, options: UploadOptions = {}): Promise<void> {
|
||||
const { onProgress, onSuccess, onError } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('uploadId', params.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 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 })));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹上传参数
|
||||
*/
|
||||
export interface FolderUploadParams {
|
||||
/** 上传ID(前端生成,保证唯一性) */
|
||||
uploadId: string;
|
||||
/** 机器ID */
|
||||
machineId: number;
|
||||
/** 认证证书名称 */
|
||||
authCertName: string;
|
||||
/** 协议类型 */
|
||||
protocol: number;
|
||||
/** 文件ID */
|
||||
fileId: number;
|
||||
/** 目标路径 */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹上传选项
|
||||
*/
|
||||
export interface FolderUploadOptions {
|
||||
/** 成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件夹(使用 /upload-folder 接口)
|
||||
* @param files 文件列表
|
||||
* @param params 上传参数
|
||||
* @param options 上传选项
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function uploadFolder(files: FileList | File[], params: FolderUploadParams, options: FolderUploadOptions = {}): Promise<void> {
|
||||
const { onSuccess, onError } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('uploadId', params.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);
|
||||
});
|
||||
|
||||
const token = getToken();
|
||||
const url = `${config.baseApiUrl}/machines/${params.machineId}/files/${params.fileId}/upload-folder?token=${token}`;
|
||||
|
||||
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 })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -311,7 +308,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
|
||||
import { ElInput, ElMessage } from 'element-plus';
|
||||
import { machineApi } from '../api';
|
||||
import { machineApi, uploadFile, uploadFolder } from '../api';
|
||||
import { randomUuid } from '@/common/utils/string';
|
||||
|
||||
import { joinClientParams } from '@/common/request';
|
||||
import config from '@/common/config';
|
||||
@@ -352,8 +350,6 @@ const state = reactive({
|
||||
basePath: '', // 基础路径
|
||||
nowPath: '', // 当前路径
|
||||
loading: true,
|
||||
progressNum: 0,
|
||||
uploadProgressShow: false,
|
||||
fileNameFilter: '',
|
||||
files: [] as any,
|
||||
selectionFiles: [] as any,
|
||||
@@ -381,7 +377,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 +773,95 @@ 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;
|
||||
}
|
||||
|
||||
console.log('[MachineFile] Folder upload:', files.length, 'files, total size:', totalFileSize);
|
||||
|
||||
// 生成唯一的 uploadId
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 使用文件夹上传接口
|
||||
uploadFolder(
|
||||
files,
|
||||
{
|
||||
uploadId,
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 清空已选择的文件夹
|
||||
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;
|
||||
}
|
||||
|
||||
// 生成唯一的 uploadId
|
||||
const uploadId = randomUuid();
|
||||
|
||||
// 上传文件
|
||||
uploadFile(
|
||||
file,
|
||||
{
|
||||
uploadId,
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const uploadSuccess = (res: any) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,32 @@ 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) {
|
||||
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 +279,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 +292,41 @@ 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{
|
||||
"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 +334,22 @@ 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 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 +385,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 +405,236 @@ 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{
|
||||
"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.Errorf("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.Errorf("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{
|
||||
"uploadId": uploadId,
|
||||
"folderName": folderName,
|
||||
"lastFile": fullPath,
|
||||
"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.Errorf("copy file error: %s", err)
|
||||
}
|
||||
|
||||
// 累加已上传大小
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
Version = "v1.11.0"
|
||||
Version = "v1.11.1"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user