🔖 项目重命名.

This commit is contained in:
lijiahangmax
2024-05-16 00:03:30 +08:00
parent f7189e34cb
commit d3a045ec20
1511 changed files with 4199 additions and 4128 deletions

View File

@@ -0,0 +1,225 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>文件列表</h3>
<!-- 操作 -->
<a-button-group size="mini" :disabled="startStatus">
<a-button @click="clear">清空</a-button>
<!-- 选择文件 -->
<a-upload v-model:file-list="files"
:auto-upload="false"
:show-file-list="false"
:multiple="true">
<template #upload-button>
<a-button type="primary">选择文件</a-button>
</template>
</a-upload>
<!-- 选择文件夹 -->
<a-upload v-model:file-list="files"
:auto-upload="false"
:show-file-list="false"
:directory="true">
<template #upload-button>
<a-button type="primary">选择文件夹</a-button>
</template>
</a-upload>
</a-button-group>
</div>
<!-- 文件列表 -->
<div v-if="files.length" class="files-container">
<a-scrollbar style="overflow-y: auto; height: 100%;">
<a-upload class="files-wrapper"
:class="[ startStatus ? 'uploading-files-wrapper' : 'waiting-files-wrapper' ]"
v-model:file-list="files"
:auto-upload="false"
:show-cancel-button="false"
:show-retry-button="false"
:show-remove-button="!startStatus"
:show-file-list="true">
<template #upload-button />
<template #file-name="{ fileItem }">
<div class="file-name-wrapper">
<!-- 文件名称 -->
<span class="file-name text-ellipsis"
:title="fileItem.file.webkitRelativePath || fileItem.file.name">
{{ fileItem.file.webkitRelativePath || fileItem.file.name }}
</span>
<!-- 文件大小 -->
<span class="file-size span-blue">
{{ getFileSize(fileItem.file.size) }}
</span>
</div>
</template>
</a-upload>
</a-scrollbar>
</div>
<!-- 未选择文件 -->
<a-result v-else
class="usn"
status="404"
subtitle="请先点击上方按钮选择文件" />
</div>
</template>
<script lang="ts">
export default {
name: 'batchUploadFiles'
};
</script>
<script lang="ts" setup>
import type { FileItem } from '@arco-design/web-vue';
import type { IFileUploader } from '@/components/system/uploader/const';
import { computed, onUnmounted, ref } from 'vue';
import { getFileSize } from '@/utils/file';
import FileUploader from '@/components/system/uploader/file-uploader';
const emits = defineEmits(['update:fileList', 'end', 'error', 'clearFile']);
const props = defineProps<{
fileList: Array<FileItem>;
}>();
const startStatus = ref(false);
const uploader = ref<IFileUploader>();
const files = computed<Array<FileItem>>({
get() {
return props.fileList;
},
set(e) {
emits('update:fileList', e);
}
});
// 开始上传
const startUpload = async (token: string) => {
// 修改状态
startStatus.value = true;
props.fileList.forEach(s => s.status = 'uploading');
// 开始上传
try {
uploader.value = new FileUploader(token, props.fileList);
uploader.value?.setHook(() => {
emits('end');
});
await uploader.value?.start();
} catch (e) {
emits('error');
}
};
// 清空
const clear = () => {
startStatus.value = false;
emits('clearFile');
};
// 关闭
const close = () => {
startStatus.value = false;
uploader.value?.close();
};
defineExpose({ startUpload, close });
// 卸载时关闭
onUnmounted(() => {
uploader.value?.close();
});
</script>
<style lang="less" scoped>
@file-size-width: 82px;
.files-container {
width: 100%;
height: calc(100% - 36px);
position: relative;
}
:deep(.waiting-files-wrapper) {
.arco-upload-list {
padding: 0 12px 0 0 !important;
}
.arco-upload-list-item-name {
margin-right: 0 !important;
}
.arco-upload-list-item .arco-upload-progress {
display: none;
}
}
:deep(.uploading-files-wrapper) {
.arco-upload-list {
padding: 0 !important;
}
.arco-upload-list-item-name {
margin-right: 10px !important;
}
}
.files-wrapper {
:deep(.arco-upload-wrapper) {
position: absolute;
height: 100%;
}
:deep(.arco-upload) {
display: none;
}
:deep(.arco-upload-list) {
padding: 0;
max-height: 100%;
}
:deep(.arco-upload-list-item-error) {
.arco-upload-list-item-name {
margin-right: 0 !important;
}
.arco-upload-progress {
display: none;
}
}
:deep(.arco-upload-list-item-name-text) {
width: 100%;
}
:deep(.arco-upload-list-item:first-of-type) {
margin-top: 0 !important;
}
}
.file-name-wrapper {
display: flex;
justify-content: space-between;
.file-name {
width: calc(100% - @file-size-width);
padding: 2px 0;
color: var(--color-text-1);
}
.file-size {
width: @file-size-width;
display: inline-flex;
font-size: 13px;
justify-content: flex-end;
align-items: center;
user-select: none;
}
}
:deep(.arco-scrollbar) {
position: absolute;
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>批量上传</h3>
<!-- 操作 -->
<a-button-group size="mini">
<!-- 重置 -->
<a-button v-if="status.value === UploadTaskStepStatus.WAITING.value"
@click="emits('clear')">
重置
</a-button>
<!-- 取消上传 -->
<a-button v-if="status.value === UploadTaskStepStatus.REQUESTING.value"
type="primary"
status="warning"
@click="emits('abort')">
取消上传
</a-button>
<!-- 开始上传 -->
<a-button v-if="status.value === UploadTaskStepStatus.WAITING.value"
type="primary"
@click="submit">
开始上传
</a-button>
</a-button-group>
</div>
<!-- 表单 -->
<a-form :model="formModel"
ref="formRef"
class="form-wrapper"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 上传描述 -->
<a-form-item field="description" label="上传描述">
<a-input v-model="formModel.description"
placeholder="请输入上传描述"
allow-clear />
</a-form-item>
<!-- 上传路径 -->
<a-form-item field="remotePath"
style="margin-bottom: 4px;"
label="上传路径"
help="${username} 用户名 ${home} 用户家目录">
<a-input v-model="formModel.remotePath"
placeholder="请输入上传路径"
allow-clear />
</a-form-item>
<!-- 上传主机 -->
<a-form-item field="hostIdList" label="上传主机">
<div class="selected-host">
<!-- 已选择数量 -->
<span class="usn" v-if="formModel.hostIdList?.length">
已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机
</span>
<span class="usn pointer span-blue" @click="emits('openHost')">
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
</span>
</div>
</a-form-item>
</a-form>
</div>
</template>
<script lang="ts">
export default {
name: 'batchUploadForm'
};
</script>
<script lang="ts" setup>
import type { UploadTaskCreateRequest } from '@/api/exec/upload-task';
import type { UploadTaskStatusType } from '../types/const';
import { ref } from 'vue';
import formRules from '../types/form.rules';
import { UploadTaskStepStatus } from '../types/const';
const emits = defineEmits(['upload', 'openHost', 'abort', 'clear']);
const props = defineProps<{
status: UploadTaskStatusType;
formModel: UploadTaskCreateRequest;
}>();
const formRef = ref<any>();
// 提交表单
const submit = async () => {
// 验证参数
let error = await formRef.value.validate();
if (error) {
return false;
}
emits('upload');
};
</script>
<style lang="less" scoped>
.selected-host {
width: 100%;
height: 32px;
padding: 0 12px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text-2);
background: var(--color-fill-2);
transition: all 0.3s;
&-count {
font-size: 16px;
font-weight: 600;
display: inline-block;
margin: 0 6px;
}
&:hover {
background: var(--color-fill-3);
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>上传主机</h3>
<!-- 操作 -->
<a-button-group size="mini">
<!-- 返回 -->
<a-button @click="emits('back')">返回</a-button>
<!-- 取消上传 -->
<a-button v-if="status.value === UploadTaskStepStatus.UPLOADING.value"
type="primary"
status="warning"
@click="emits('cancel')">
取消上传
</a-button>
</a-button-group>
</div>
<!-- 主机列表 -->
<div class="wrapper">
<a-scrollbar style="overflow-y: auto; height: 100%;">
<!-- 主机 -->
<div v-for="host in task.hosts"
class="host-item"
:class="[ selectedHost === host.id ? 'host-item-active' : '']"
@click="changeSelectedHost(host.id)">
<!-- 主机信息 -->
<div class="host-item-host">
<!-- 主机名称 -->
<div class="host-item-name text-ellipsis" :title="host.name">
{{ host.name }}
</div>
<!-- 主机地址 -->
<div class="host-item-address text-ellipsis" :title="host.address">
{{ host.address }}
</div>
</div>
<!-- 主机状态 -->
<a-space class="host-item-status"
direction="vertical"
size="mini">
<!-- 传输中 -->
<span class="host-item-status-text span-green" title="传输中">
{{ getStatusCount(host.files, [UploadTaskFileStatus.WAITING, UploadTaskFileStatus.UPLOADING]) }}
</span>
<!-- 已完成 -->
<span class="host-item-status-text span-blue" title="已完成">
{{ getStatusCount(host.files, [UploadTaskFileStatus.FINISHED, UploadTaskFileStatus.CANCELED]) }}
</span>
<!-- 已失败 -->
<span class="host-item-status-text span-red" title="已失败">
{{ getStatusCount(host.files, [UploadTaskFileStatus.FAILED]) }}
</span>
</a-space>
</div>
</a-scrollbar>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'batchUploadHosts'
};
</script>
<script lang="ts" setup>
import type { UploadTaskQueryResponse } from '@/api/exec/upload-task';
import type { UploadTaskFile } from '@/api/exec/upload-task';
import type { UploadTaskStatusType } from '../types/const';
import { UploadTaskStepStatus } from '../types/const';
import { UploadTaskFileStatus } from '@/views/exec/upload-task/types/const';
const emits = defineEmits(['update:selectedHost', 'back', 'cancel']);
const props = defineProps<{
status: UploadTaskStatusType;
selectedHost: number;
task: UploadTaskQueryResponse;
}>();
// 修改选中的主机
const changeSelectedHost = (id: number) => {
emits('update:selectedHost', id);
};
// 获取已完成数量
const getStatusCount = (files: Array<UploadTaskFile>, status: Array<string>) => {
return files.filter(s => status.includes(s.status)).length;
};
</script>
<style lang="less" scoped>
.wrapper {
width: 100%;
height: calc(100% - 36px);
position: relative;
.host-item {
padding: 12px;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
position: relative;
background: var(--color-fill-1);
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--color-fill-2);
}
&-host {
width: calc(100% - 48px);
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: center;
}
&-status {
user-select: none;
&-text {
width: 100%;
max-width: 48px;
display: inline-flex;
justify-content: flex-end;
font-weight: 600;
}
}
&-name {
width: 100%;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-1);
}
&-address {
width: 100%;
font-size: 12px;
color: var(--color-text-3);
}
}
.host-item-active {
background: var(--color-fill-2) !important;
&::after {
width: 3px;
height: 100%;
border-radius: 4px 6px 6px 4px;
display: block;
position: absolute;
top: 0;
right: 1px;
background: rgb(var(--arcoblue-6));
content: '';
}
}
}
:deep(.arco-scrollbar) {
position: absolute;
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>传输列表</h3>
</div>
<div class="wrapper">
<a-scrollbar style="overflow-y: auto; height: 100%;">
<!-- 主机 -->
<div v-for="file in files" class="file-item">
<!-- 图标 -->
<div class="file-item-icon span-blue">
<icon-file />
</div>
<!-- 文件路径 -->
<div class="file-item-path text-ellipsis" :title="file.filePath">
{{ file.filePath }}
</div>
<!-- 状态 -->
<div class="file-item-status">
<!-- 文件大小 -->
<div class="file-item-size span-blue">
<!-- 当前大小 -->
<template v-if="file.status === UploadTaskFileStatus.WAITING || file.status === UploadTaskFileStatus.UPLOADING">
{{ getFileSize(file.current || 0) }}
</template>
<!-- 总大小 -->
<template v-else>
{{ getFileSize(file.fileSize) }}
</template>
</div>
<!-- 进度 -->
<a-tooltip position="left"
:content="((file.current || 0) / file.fileSize * 100).toFixed(2) + '%'"
mini>
<a-progress type="circle"
size="mini"
:status="getDictValue(fileStatusKey, file.status, 'status') as any"
:percent="(file.current || 0) / file.fileSize" />
</a-tooltip>
</div>
</div>
</a-scrollbar>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'batchUploadProgress'
};
</script>
<script lang="ts" setup>
import type { UploadTaskFile } from '@/api/exec/upload-task';
import { fileStatusKey } from '../types/const';
import { UploadTaskFileStatus } from '@/views/exec/upload-task/types/const';
import { useDictStore } from '@/store';
import { getFileSize } from '@/utils/file';
const emits = defineEmits(['update:selectedHost']);
const props = defineProps<{
files: Array<UploadTaskFile>;
}>();
const { getDictValue } = useDictStore();
</script>
<style lang="less" scoped>
@icon-width: 24px;
@status-width: 102px;
.wrapper {
width: 100%;
height: calc(100% - 36px);
position: relative;
.file-item {
padding: 12px;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
background: var(--color-fill-1);
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--color-fill-2);
}
&-icon {
width: @icon-width;
font-size: 18px;
}
&-path {
padding: 2px 0;
width: calc(100% - @icon-width - @status-width);
display: inline-block;
font-size: 14px;
color: var(--color-text-1);
}
&-size {
font-size: 12px;
margin-right: 12px;
user-select: none;
}
&-status {
width: @status-width;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
}
:deep(.arco-scrollbar) {
position: absolute;
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div class="container">
<a-steps :current="status.step"
:status="status.status as any"
direction="vertical">
<!-- 创建任务 -->
<a-step description="创建上传任务">创建任务</a-step>
<!-- 上传文件 -->
<a-step>
上传文件
<template #icon v-if="status.step === 2">
<icon-loading />
</template>
<template #description>
<span>将文件上传到临时分区</span><br>
<span v-if="status.step === 2" class="span-red desc-tips">
在此期间请不要关闭页面
</span>
</template>
</a-step>
<!-- 分发文件 -->
<a-step>
分发文件
<template #icon v-if="status.step === 3">
<icon-loading />
</template>
<template #description>
<span>将文件分发到目标服务器</span><br>
<span v-if="status.step === 3" class="span-blue desc-tips">
在此期间可以关闭页面
</span>
</template>
</a-step>
<!-- 上传完成 -->
<a-step description="释放资源">上传完成</a-step>
</a-steps>
</div>
</template>
<script lang="ts">
export default {
name: 'batchUploadStep'
};
</script>
<script lang="ts" setup>
import type { UploadTaskStatusType } from '../types/const';
defineProps<{
status: UploadTaskStatusType;
}>();
</script>
<style lang="less" scoped>
.container {
user-select: none;
}
.desc-tips {
display: inline-block;
margin-top: 6px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,331 @@
<template>
<a-spin class="panel-container full" :loading="loading">
<!-- 上传步骤 -->
<batch-upload-step class="panel-item first-panel-container"
:status="taskStatus" />
<!-- 上传表单 -->
<batch-upload-form v-if="taskStatus.formPanel"
class="panel-item center-panel-container"
:form-model="formModel"
:status="taskStatus"
@upload="doCreateUploadTask"
@abort="abortUploadRequest"
@open-host="openHostModal"
@clear="clearForm" />
<!-- 上传主机 -->
<batch-upload-hosts v-else
class="panel-item center-panel-container"
v-model:selected-host="selectedHost"
:status="taskStatus"
:task="task"
@back="backFormPanel"
@cancel="doCancelUploadTask" />
<!-- 文件列表 -->
<batch-upload-files v-if="taskStatus.formPanel"
v-model:file-list="fileList"
class="panel-item last-panel-container"
ref="filesRef"
@end="uploadRequestEnd"
@error="uploadRequestError"
@clear-file="clearFile" />
<!-- 传输进度 -->
<template v-else>
<template v-for="host in task.hosts">
<batch-upload-progress v-if="host.id === selectedHost"
class="panel-item last-panel-container"
:files="host.files" />
</template>
</template>
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
@selected="setSelectedHost" />
</a-spin>
</template>
<script lang="ts">
export default {
name: 'uploadPanel'
};
</script>
<script lang="ts" setup>
import type { FileItem } from '@arco-design/web-vue';
import type { UploadTaskCreateRequest, UploadTaskQueryResponse } from '@/api/exec/upload-task';
import type { UploadTaskStatusType } from '../types/const';
import { onMounted, onUnmounted, ref } from 'vue';
import { UploadTaskStepStatus } from '../types/const';
import { UploadTaskStatus } from '@/views/exec/upload-task/types/const';
import { cancelUploadTask, createUploadTask, startUploadTask, getUploadTask, getUploadTaskStatus } from '@/api/exec/upload-task';
import useLoading from '@/hooks/loading';
import { Message } from '@arco-design/web-vue';
import BatchUploadStep from './batch-upload-step.vue';
import BatchUploadForm from './batch-upload-form.vue';
import BatchUploadFiles from './batch-upload-files.vue';
import BatchUploadHosts from './batch-upload-hosts.vue';
import BatchUploadProgress from './batch-upload-progress.vue';
import AuthorizedHostModal from '@/components/asset/host/authorized-host-modal/index.vue';
const defaultForm = (): UploadTaskCreateRequest => {
return {
description: '',
remotePath: '',
hostIdList: [],
files: []
};
};
const { loading, setLoading } = useLoading();
const pullIntervalId = ref();
const taskId = ref();
const task = ref<UploadTaskQueryResponse>({} as UploadTaskQueryResponse);
const selectedHost = ref();
const formModel = ref<UploadTaskCreateRequest>({ ...defaultForm() });
const fileList = ref<Array<FileItem>>([]);
const taskStatus = ref<UploadTaskStatusType>(UploadTaskStepStatus.WAITING);
const filesRef = ref();
const hostModal = ref();
// 打开日志
const openLog = async (id: number) => {
setLoading(true);
taskId.value = id;
taskStatus.value = UploadTaskStepStatus.WAITING;
try {
// 查询任务
const { data } = await getUploadTask(id);
task.value = data;
selectedHost.value = data.hosts[0].id;
// 设置任务状态
if (data.status === UploadTaskStatus.FINISHED
|| data.status === UploadTaskStatus.CANCELED) {
taskStatus.value = UploadTaskStepStatus.FINISHED;
} else if (data.status === UploadTaskStatus.FAILED) {
taskStatus.value = UploadTaskStepStatus.FAILED;
} else {
taskStatus.value = UploadTaskStepStatus.UPLOADING;
}
} catch (e) {
} finally {
setLoading(false);
}
};
defineExpose({ openLog });
// 设置选中主机
const setSelectedHost = (hosts: Array<number>) => {
formModel.value.hostIdList = hosts;
};
// 创建上传任务
const doCreateUploadTask = async () => {
// 获取文件
const files = fileList.value.map(s => {
return {
fileId: s.uid,
filePath: s.file?.webkitRelativePath || s.file?.name,
fileSize: s.file?.size,
};
});
if (!files || !files.length) {
Message.error('请先选择需要上传的文件');
return;
}
// 创建任务
setLoading(true);
taskStatus.value = UploadTaskStepStatus.WAITING;
try {
formModel.value.files = files;
const { data } = await createUploadTask(formModel.value);
taskId.value = data.id;
taskStatus.value = UploadTaskStepStatus.REQUESTING;
// 上传文件
await filesRef.value.startUpload(data.token);
} catch (e) {
taskStatus.value = UploadTaskStepStatus.FAILED;
} finally {
setLoading(false);
}
};
// 取消上传任务
const doCancelUploadTask = async () => {
setLoading(true);
try {
// 取消上传
await cancelUploadTask(taskId.value, false);
taskStatus.value = UploadTaskStepStatus.WAITING;
Message.success('已取消');
} catch (e) {
} finally {
setLoading(false);
}
};
// 中断上传请求
const abortUploadRequest = () => {
taskStatus.value = UploadTaskStepStatus.WAITING;
filesRef.value?.close();
};
// 上传请求结束
const uploadRequestEnd = async () => {
if (taskStatus.value.value === UploadTaskStepStatus.REQUESTING.value) {
// 如果结束后还是请求中则代表请求完毕
setLoading(true);
try {
// 开始上传
await startUploadTask(taskId.value);
// 查询任务
const { data } = await getUploadTask(taskId.value);
task.value = data;
selectedHost.value = data.hosts[0].id;
taskStatus.value = UploadTaskStepStatus.UPLOADING;
} catch (e) {
// 设置失败
await uploadRequestError();
} finally {
setLoading(false);
}
} else {
// 手动停止或者其他原因则修改为取消上传
await doCancelUploadTask();
}
};
// 上传请求失败
const uploadRequestError = async () => {
setLoading(true);
try {
// 开始上传
await cancelUploadTask(taskId.value, true);
taskStatus.value = UploadTaskStepStatus.FAILED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 加载轮询状态
const pullTaskStatus = async () => {
if (!taskId.value || !task.value) {
return;
}
// 非上传中则不查询
if (taskStatus.value.value !== UploadTaskStepStatus.UPLOADING.value) {
return;
}
// 查询状态
const { data } = await getUploadTaskStatus([taskId.value], true);
if (!data.length) {
return;
}
const taskStatusData = data[0];
// 设置任务状态
if (taskStatusData.status === UploadTaskStatus.FINISHED
|| taskStatusData.status === UploadTaskStatus.CANCELED) {
taskStatus.value = UploadTaskStepStatus.FINISHED;
} else if (taskStatusData.status === UploadTaskStatus.FAILED) {
taskStatus.value = UploadTaskStepStatus.FAILED;
}
// 设置文件进度
for (let host of task.value.hosts) {
for (let file of host.files) {
const fileStatus = taskStatusData.files.find(s => s.id === file.id);
if (fileStatus) {
file.status = fileStatus.status;
file.current = fileStatus.current;
}
}
}
};
// 打开主机模态框
const openHostModal = () => {
hostModal.value.open(formModel.value.hostIdList);
};
// 返回表单页面
const backFormPanel = () => {
taskStatus.value = UploadTaskStepStatus.WAITING;
taskId.value = undefined;
task.value = undefined as any;
selectedHost.value = undefined as any;
};
// 清空表单
const clearForm = () => {
formModel.value = { ...defaultForm() };
};
// 清空文件
const clearFile = () => {
fileList.value = [];
};
// 设置轮询状态
onMounted(() => {
pullIntervalId.value = setInterval(pullTaskStatus, 5000);
});
// 卸载状态查询
onUnmounted(() => {
clearInterval(pullIntervalId.value);
});
</script>
<style lang="less" scoped>
@step-width: 258px;
@center-width: 398px;
@last-width: calc(100% - @step-width - 16px - @center-width - 16px);
.panel-container {
height: 100%;
display: flex;
position: relative;
.panel-item {
height: 100%;
padding: 16px;
border-radius: 4px;
margin-right: 16px;
position: relative;
background: var(--color-bg-2);
}
.first-panel-container {
width: @step-width;
}
.center-panel-container {
width: @center-width;
}
.last-panel-container {
margin-right: 0;
width: @last-width;
}
}
:deep(.panel-header) {
width: 100%;
height: 28px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
h3, > span {
margin: 0;
overflow: hidden;
white-space: nowrap;
}
h3 {
color: var(--color-text-1);
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="layout-container upload-container">
<!-- 上传面板 -->
<upload-panel ref="panel" />
</div>
</template>
<script lang="ts">
export default {
name: 'batchUpload'
};
</script>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from './types/const';
import { useRoute } from 'vue-router';
import UploadPanel from './components/upload-panel.vue';
const route = useRoute();
const panel = ref();
// 加载字典值
onMounted(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 跳转日志
onMounted(async () => {
const idParam = route.query.id;
const keyParam = route.query.key;
if (idParam) {
await panel.value?.openLog(Number.parseInt(idParam as string));
} else if (keyParam) {
await panel.value?.openLog(Number.parseInt(keyParam as string));
}
});
</script>
<style lang="less" scoped>
.upload-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
}
</style>

View File

@@ -0,0 +1,55 @@
// 上传任务状态定义
export interface UploadTaskStatusType {
value: string;
step: number;
status: string;
formPanel: boolean;
}
// 上传任务状态
export const UploadTaskStepStatus = {
// 等待中
WAITING: {
value: 'WAITING',
step: 1,
status: 'process',
formPanel: true,
},
// 请求中
REQUESTING: {
value: 'REQUESTING',
step: 2,
status: 'process',
formPanel: true,
},
// 上传中
UPLOADING: {
value: 'UPLOADING',
step: 3,
status: 'process',
formPanel: false,
},
// 已完成
FINISHED: {
value: 'FINISHED',
step: 4,
status: 'finish',
formPanel: false,
},
// 已失败
FAILED: {
value: 'FAILED',
step: 4,
status: 'error',
formPanel: false,
},
};
// 上传任务状态 字典项
export const taskStatusKey = 'uploadTaskStatus';
// 上传任务文件状态 字典项
export const fileStatusKey = 'uploadTaskFileStatus';
// 加载的字典值
export const dictKeys = [taskStatusKey, fileStatusKey];

View File

@@ -0,0 +1,25 @@
import type { FieldRule } from '@arco-design/web-vue';
export const description = [{
maxLength: 128,
message: '描述长度不能大于128位'
}] as FieldRule[];
export const hostIdList = [{
required: true,
message: '请选择上传主机'
}] as FieldRule[];
export const remotePath = [{
required: true,
message: '请输入上传路径'
}, {
maxLength: 1024,
message: '上传路径长度不能大于1024位'
}] as FieldRule[];
export default {
description,
hostIdList,
remotePath,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,170 @@
<template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
:data="row.hosts"
:expandable="expandable"
:scroll="{ y: '100%' }"
:pagination="false"
:bordered="false">
<!-- 执行主机 -->
<template #hostName="{ record }">
<span class="table-cell-value span-blue">
{{ record.hostName }}
</span>
<br>
<span class="table-cell-sub-value usn text-copy"
style="font-size: 12px;"
@click="copy(record.hostAddress)">
{{ record.hostAddress }}
</span>
</template>
<!-- 错误信息 -->
<template #errorMessage="{ record }">
<span class="span-red">
{{ record.errorMessage }}
</span>
</template>
<!-- 执行状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(execHostStatusKey, record.status, 'color')">
{{ getDictValue(execHostStatusKey, record.status) }}
</a-tag>
</template>
<!-- 执行时间 -->
<template #startTime="{ record }">
<span class="table-cell-value">
{{ (record.startTime && dateFormat(new Date(record.startTime))) || '-' }}
</span>
<br>
<span class="table-cell-sub-value usn" style="font-size: 12px;">
持续时间: {{ formatDuration(record.startTime, record.finishTime) || '-' }}
</span>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 命令 -->
<a-button type="text"
size="mini"
@click="emits('viewCommand', record.command)">
命令
</a-button>
<!-- 参数 -->
<a-button type="text"
size="mini"
@click="emits('viewParams', record.parameter)">
参数
</a-button>
<!-- 下载 -->
<a-button type="text"
size="mini"
@click="downloadLogFile(record.id)">
下载
</a-button>
<!-- 中断 -->
<a-popconfirm content="确认要中断命令吗, 删除后会中断执行?"
position="left"
type="warning"
@ok="interruptedHost(record)">
<a-button v-permission="['asset:exec-command-log:interrupt']"
type="text"
size="mini"
status="danger"
:disabled="record.status !== execHostStatus.WAITING && record.status !== execHostStatus.RUNNING">
中断
</a-button>
</a-popconfirm>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:exec-command-log:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</template>
<script lang="ts">
export default {
name: 'execCommandHostLogTable'
};
</script>
<script lang="ts" setup>
import type { ExecLogQueryResponse, ExecHostLogQueryResponse } from '@/api/exec/exec-log';
import { deleteExecCommandHostLog } from '@/api/exec/exec-command-log';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/host-table.columns';
import { execHostStatusKey, execHostStatus } from '@/components/exec/log/const';
import { useDictStore } from '@/store';
import { useExpandable } from '@/types/table';
import { dateFormat, formatDuration } from '@/utils';
import { downloadExecCommandLogFile, interruptHostExecCommand } from '@/api/exec/exec-command-log';
import { copy } from '@/hooks/copy';
import { downloadFile } from '@/utils/file';
const props = defineProps<{
row: ExecLogQueryResponse;
}>();
const emits = defineEmits(['viewCommand', 'viewParams', 'refreshHost']);
const expandable = useExpandable({ width: 90 });
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue } = useDictStore();
// 下载文件
const downloadLogFile = async (id: number) => {
const data = await downloadExecCommandLogFile(id);
downloadFile(data);
};
// 中断执行
const interruptedHost = async (record: ExecHostLogQueryResponse) => {
try {
setLoading(true);
// 调用中断接口
await interruptHostExecCommand({
hostLogId: record.id
});
Message.success('已中断');
record.status = execHostStatus.INTERRUPTED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 删除当前行
const deleteRow = async ({ id, logId }: {
id: number,
logId: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteExecCommandHostLog(id);
Message.success('删除成功');
emits('refreshHost', logId);
} catch (e) {
} finally {
setLoading(false);
}
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,162 @@
<template>
<a-modal v-model:visible="visible"
body-class="modal-form-large"
title-align="start"
title="清空批量执行日志"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
ok-text="清空"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true">
<!-- 执行时间 -->
<a-form-item field="startTimeRange" label="执行时间">
<a-range-picker v-model="formModel.startTimeRange"
:time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }"
show-time
format="YYYY-MM-DD HH:mm:ss" />
</a-form-item>
<!-- 执行用户 -->
<a-form-item field="userId" label="执行用户">
<user-selector v-model="formModel.userId"
placeholder="请选择执行用户"
allow-clear />
</a-form-item>
<!-- 执行描述 -->
<a-form-item field="description" label="执行描述">
<a-input v-model="formModel.description"
placeholder="请输入执行描述"
allow-clear />
</a-form-item>
<!-- 执行命令 -->
<a-form-item field="command" label="执行命令">
<a-input v-model="formModel.command"
placeholder="请输入执行命令"
allow-clear />
</a-form-item>
<!-- 执行状态 -->
<a-form-item field="status" label="执行状态">
<a-select v-model="formModel.status"
:options="toOptions(execStatusKey)"
placeholder="请选择执行状态" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'execCommandLogClearModal'
};
</script>
<script lang="ts" setup>
import type { ExecLogQueryRequest } from '@/api/exec/exec-log';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import { execStatusKey } from '@/components/exec/log/const';
import { getExecCommandLogCount, clearExecCommandLog } from '@/api/exec/exec-command-log';
import { Message, Modal } from '@arco-design/web-vue';
import { useDictStore } from '@/store';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['clear']);
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const { toOptions } = useDictStore();
const formRef = ref<any>();
const formModel = ref<ExecLogQueryRequest>({});
const defaultForm = (): ExecLogQueryRequest => {
return {
id: undefined,
userId: undefined,
description: undefined,
command: undefined,
status: undefined,
startTimeRange: undefined
};
};
// 打开
const open = (record: any) => {
renderForm({ ...defaultForm(), ...record });
setVisible(true);
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ open });
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 获取总数量
const { data } = await getExecCommandLogCount(formModel.value);
if (data) {
// 清空
doClear(data);
} else {
// 无数据
Message.warning('当前条件未查询到数据');
}
} catch (e) {
} finally {
setLoading(false);
}
return false;
};
// 执行删除
const doClear = (count: number) => {
Modal.confirm({
title: '删除清空',
content: `确定要删除 ${count} 条数据吗? 确定后将立即删除且无法恢复!`,
onOk: async () => {
setLoading(true);
try {
// 调用删除
await clearExecCommandLog(formModel.value);
emits('clear');
// 清空
setVisible(false);
handlerClear();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,427 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
:itemOptions="{ 5: { span: 2 } }"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- 执行描述 -->
<a-form-item field="description" label="执行描述">
<a-input v-model="formModel.description"
placeholder="请输入执行描述"
allow-clear />
</a-form-item>
<!-- 执行状态 -->
<a-form-item field="status" label="执行状态">
<a-select v-model="formModel.status"
:options="toOptions(execStatusKey)"
placeholder="请选择执行状态"
allow-clear />
</a-form-item>
<!-- 执行用户 -->
<a-form-item field="userId" label="执行用户">
<user-selector v-model="formModel.userId"
placeholder="请选择执行用户"
allow-clear />
</a-form-item>
<!-- 执行命令 -->
<a-form-item field="command" label="执行命令">
<a-input v-model="formModel.command"
placeholder="请输入执行命令"
allow-clear />
</a-form-item>
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 执行时间 -->
<a-form-item field="startTimeRange" label="执行时间">
<a-range-picker v-model="formModel.startTimeRange"
:time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }"
show-time
format="YYYY-MM-DD HH:mm:ss" />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
批量执行日志列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 执行命令 -->
<a-button v-permission="['asset:exec-command:exec']"
type="primary"
@click="$router.push({ name: 'execCommand' })">
执行命令
<template #icon>
<icon-thunderbolt />
</template>
</a-button>
<!-- 清空 -->
<a-button v-permission="['asset:exec-command-log:management:clear']"
status="danger"
@click="openClear">
清空
<template #icon>
<icon-close />
</template>
</a-button>
<!-- 删除 -->
<a-popconfirm :content="`确认删除选中的 ${selectedKeys.length} 条记录吗? 删除后会中断执行!`"
position="br"
type="warning"
@ok="deleteSelectRows">
<a-button v-permission="['asset:exec-command-log:delete']"
type="secondary"
status="danger"
:disabled="selectedKeys.length === 0">
删除
<template #icon>
<icon-delete />
</template>
</a-button>
</a-popconfirm>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
v-model:selected-keys="selectedKeys"
:row-selection="rowSelection"
:expandable="expandable"
:data="tableRenderData"
:pagination="pagination"
:bordered="false"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
@expand="loadExecHost">
<!-- 展开表格 -->
<template #expand-row="{ record }">
<exec-command-host-log-table :row="record"
@view-command="s => emits('viewCommand', s)"
@view-params="s => emits('viewParams', s)"
@refresh-host="refreshExecHost" />
</template>
<!-- 执行命令 -->
<template #command="{ record }">
<span :title="record.command">
{{ record.command }}
</span>
</template>
<!-- 执行状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(execStatusKey, record.status, 'color')">
{{ getDictValue(execStatusKey, record.status) }}
</a-tag>
</template>
<!-- 执行时间 -->
<template #startTime="{ record }">
<span class="table-cell-value">
{{ (record.startTime && dateFormat(new Date(record.startTime))) || '-' }}
</span>
<br>
<span class="table-cell-sub-value usn" style="font-size: 12px;">
持续时间: {{ formatDuration(record.startTime, record.finishTime) || '-' }}
</span>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 重新执行 -->
<a-popconfirm content="确定要重新执行吗?"
position="left"
type="warning"
@ok="doReExecCommand(record)">
<a-button v-permission="['asset:exec-command:exec']"
type="text"
size="mini">
重新执行
</a-button>
</a-popconfirm>
<!-- 命令 -->
<a-button type="text"
size="mini"
@click="emits('viewCommand', record.command)">
命令
</a-button>
<!-- 日志 -->
<a-button v-permission="['asset:exec-command-log:query', 'asset:exec-command:exec']"
type="text"
size="mini"
title="ctrl + 左键新页面打开"
@click="(e) => emits('viewLog', record.id, e.ctrlKey)">
日志
</a-button>
<!-- 中断 -->
<a-popconfirm content="确定要中断执行吗?"
position="left"
type="warning"
@ok="doInterruptExecCommand(record)">
<a-button v-permission="['asset:exec-command-log:interrupt']"
type="text"
size="mini"
status="danger"
:disabled="record.status !== execStatus.WAITING && record.status !== execStatus.RUNNING">
中断
</a-button>
</a-popconfirm>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗, 删除后会中断执行?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:exec-command-log:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'execCommandLogTable'
};
</script>
<script lang="ts" setup>
import type { TableData } from '@arco-design/web-vue/es/table/interface';
import type { ExecLogQueryResponse, ExecLogQueryRequest } from '@/api/exec/exec-log';
import { reactive, ref, onMounted, onUnmounted } from 'vue';
import {
batchDeleteExecCommandLog,
deleteExecCommandLog,
getExecCommandHostLogList,
getExecCommandLogPage,
getExecCommandLogStatus
} from '@/api/exec/exec-command-log';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { execStatus, execStatusKey } from '@/components/exec/log/const';
import { useExpandable, usePagination, useRowSelection } from '@/types/table';
import { useDictStore } from '@/store';
import { dateFormat, formatDuration } from '@/utils';
import { reExecCommand } from '@/api/exec/exec-command';
import { interruptExecCommand } from '@/api/exec/exec-command-log';
import UserSelector from '@/components/user/user/selector/index.vue';
import ExecCommandHostLogTable from './exec-command-host-log-table.vue';
const emits = defineEmits(['viewCommand', 'viewParams', 'viewLog', 'openClear']);
const pagination = usePagination();
const rowSelection = useRowSelection();
const expandable = useExpandable();
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue } = useDictStore();
const pullIntervalId = ref();
const tableRef = ref();
const selectedKeys = ref<number[]>([]);
const tableRenderData = ref<ExecLogQueryResponse[]>([]);
const formModel = reactive<ExecLogQueryRequest>({
id: undefined,
userId: undefined,
description: undefined,
command: undefined,
status: undefined,
startTimeRange: undefined,
});
// 打开清理
const openClear = () => {
emits('openClear', { ...formModel, id: undefined });
};
// 删除选中行
const deleteSelectRows = async () => {
try {
setLoading(true);
// 调用删除接口
await batchDeleteExecCommandLog(selectedKeys.value);
Message.success(`成功删除 ${selectedKeys.value.length} 条数据`);
selectedKeys.value = [];
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteExecCommandLog(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 重新执行命令
const doReExecCommand = async (record: ExecLogQueryResponse) => {
try {
setLoading(true);
// 调用中断接口
await reExecCommand({
logId: record.id
});
Message.success('已重新执行');
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 中断执行
const doInterruptExecCommand = async (record: ExecLogQueryResponse) => {
try {
setLoading(true);
// 调用中断接口
await interruptExecCommand({
logId: record.id
});
Message.success('已中断');
record.status = execStatus.COMPLETED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 刷新执行主机
const refreshExecHost = (id: number) => {
// 获取到执行主机
const exec = tableRenderData.value.find(s => s.id === id);
if (!exec) {
return;
}
// 加载数据
getExecCommandHostLogList(id).then(s => {
exec.hosts = s.data;
});
};
// 加载主机数据
const loadExecHost = async (key: number | string, record: TableData) => {
if (record.hosts?.length) {
return;
}
// 加载数据
const { data } = await getExecCommandHostLogList(record.id);
record.hosts = data;
};
// 加载状态
const pullExecStatus = async () => {
const unCompleteIdList = tableRenderData.value
.filter(s => s.status === execStatus.WAITING || s.status === execStatus.RUNNING)
.map(s => s.id);
if (!unCompleteIdList.length) {
return;
}
// 加载未完成的状态
const { data: { logList, hostList } } = await getExecCommandLogStatus(unCompleteIdList);
// 设置任务状态
logList.forEach(s => {
const tableRow = tableRenderData.value.find(r => r.id === s.id);
if (!tableRow) {
return;
}
tableRow.status = s.status;
tableRow.startTime = s.startTime;
tableRow.finishTime = s.finishTime;
});
// 设置主机状态
hostList.forEach(s => {
const host = tableRenderData.value
.find(r => r.id === s.logId)
?.hosts
?.find(r => r.id === s.id);
if (!host) {
return;
}
host.status = s.status;
host.startTime = s.startTime;
host.finishTime = s.finishTime;
host.exitCode = s.exitCode;
host.errorMessage = s.errorMessage;
});
};
// 加载数据
const doFetchTableData = async (request: ExecLogQueryRequest) => {
try {
setLoading(true);
const { data } = await getExecCommandLogPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
selectedKeys.value = [];
tableRef.value.expandAll(false);
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
defineExpose({
fetchTableData
});
onMounted(() => {
// 加载数据
fetchTableData();
// 注册状态轮询
pullIntervalId.value = setInterval(pullExecStatus, 10000);
});
onUnmounted(() => {
// 卸载状态轮询
clearInterval(pullIntervalId.value);
});
</script>
<style lang="less" scoped>
:deep(.arco-table-cell-fixed-expand) {
width: 100% !important;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<exec-command-log-table ref="tableRef"
@view-command="viewCommand"
@view-params="viewParams"
@view-log="viewLog"
@open-clear="openClearModal" />
<!-- 清理模态框 -->
<exec-command-log-clear-modal ref="clearModal"
@clear="clearCallback" />
<!-- 执行日志模态框 -->
<exec-log-panel-modal ref="logModal"
type="BATCH" />
<!-- json 模态框 -->
<json-editor-modal ref="jsonModal"
:esc-to-close="true" />
<!-- shell 模态框 -->
<shell-editor-modal ref="shellModal"
:footer="false"
:esc-to-close="true" />
</div>
</template>
<script lang="ts">
export default {
name: 'execCommandLog'
};
</script>
<script lang="ts" setup>
import { ref, onBeforeMount } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from '@/components/exec/log/const';
import { useRouter } from 'vue-router';
import { openNewRoute } from '@/router';
import ExecCommandLogTable from './components/exec-command-log-table.vue';
import ExecCommandLogClearModal from './components/exec-command-log-clear-modal.vue';
import JsonEditorModal from '@/components/view/json-editor/modal/index.vue';
import ShellEditorModal from '@/components/view/shell-editor/modal/index.vue';
import ExecLogPanelModal from '@/components/exec/log/panel-modal/index.vue';
const router = useRouter();
const render = ref(false);
const tableRef = ref();
const logModal = ref();
const clearModal = ref();
const jsonModal = ref();
const shellModal = ref();
// 打开清理模态框
const openClearModal = (e: any) => {
clearModal.value.open(e);
};
// 查看命令
const viewCommand = (data: string) => {
shellModal.value.open(data, '命令');
};
// 查看参数
const viewParams = (data: string) => {
jsonModal.value.open(JSON.parse(data));
};
// 查看日志
const viewLog = (id: number, newWindow: boolean) => {
if (newWindow) {
// 跳转新页面
openNewRoute({
name: 'execCommand',
query: {
id
}
});
} else {
logModal.value.open(id);
}
};
// 清理回调
const clearCallback = () => {
tableRef.value.fetchTableData();
};
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,57 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { isNumber } from '@/utils/is';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '执行主机',
dataIndex: 'hostName',
slotName: 'hostName',
align: 'left',
ellipsis: true,
tooltip: true,
}, {
title: '退出码',
dataIndex: 'exitCode',
slotName: 'exitCode',
align: 'left',
width: 118,
render: ({ record }) => {
return isNumber(record.exitCode) ? record.exitCode : '-';
},
}, {
title: '执行状态',
dataIndex: 'status',
slotName: 'status',
align: 'left',
width: 118,
}, {
title: '错误信息',
dataIndex: 'errorMessage',
slotName: 'errorMessage',
align: 'left',
ellipsis: true,
tooltip: true,
width: 168,
}, {
title: '执行时间',
dataIndex: 'startTime',
slotName: 'startTime',
align: 'left',
width: 190,
}, {
title: '操作',
slotName: 'handle',
width: 258,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,53 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '执行描述',
dataIndex: 'description',
slotName: 'description',
align: 'left',
width: 168,
ellipsis: true,
tooltip: true,
}, {
title: '执行命令',
dataIndex: 'command',
slotName: 'command',
align: 'left',
ellipsis: true,
}, {
title: '执行用户',
dataIndex: 'username',
slotName: 'username',
align: 'left',
width: 118,
ellipsis: true,
}, {
title: '执行状态',
dataIndex: 'status',
slotName: 'status',
align: 'left',
width: 118,
}, {
title: '执行时间',
dataIndex: 'startTime',
slotName: 'startTime',
align: 'left',
width: 190,
}, {
title: '操作',
slotName: 'handle',
width: 288,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,52 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>执行命令</h3>
<span v-permission="['asset:exec-template:query']"
class="span-blue usn pointer"
@click="emits('openTemplate')">
从模板中选择
</span>
</div>
<!-- 命令编辑器 -->
<div class="editor-wrapper">
<slot />
</div>
<!-- 命名提示信息 -->
<div v-pre class="editor-help">
使用 @{{ xxx }} 来替换参数, 输入_可以获取全部变量
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'execCommandPanelEditor'
};
</script>
<script lang="ts" setup>
const emits = defineEmits(['openTemplate']);
</script>
<style lang="less" scoped>
.editor-wrapper {
width: 100%;
height: calc(100% - 56px);
position: relative;
}
.editor-help {
user-select: none;
display: flex;
margin-top: 8px;
height: 18px;
color: var(--color-text-3);
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>执行参数</h3>
<!-- 操作 -->
<a-button-group size="mini">
<a-button @click="emits('reset')">重置</a-button>
<a-button type="primary" @click="emits('exec')">执行</a-button>
</a-button-group>
</div>
<!-- 命令表单 -->
<slot name="form" />
<a-divider v-if="schemaCount"
orientation="center"
style="margin: 12px 0 26px 0;">
命令参数
</a-divider>
<!-- 参数表单 -->
<slot v-if="schemaCount" name="params" />
</div>
</template>
<script lang="ts">
export default {
name: 'execCommandPanelForm'
};
</script>
<script lang="ts" setup>
const emits = defineEmits(['reset', 'exec']);
defineProps<{
schemaCount: number
}>();
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,158 @@
<template>
<div class="container">
<!-- 表头 -->
<div class="panel-header">
<h3>执行历史</h3>
<span class="history-help">
展示最近 {{ historyCount }} 条执行记录
</span>
</div>
<!-- 加载中 -->
<a-skeleton v-if="loading" :animation="true">
<a-skeleton-line :rows="4"
:line-height="40"
:line-spacing="8" />
</a-skeleton>
<!-- 无数据 -->
<div v-else-if="!historyLogs.length" class="flex-center mt16">
<a-empty description="无执行记录" />
</div>
<!-- 批量执行日志 -->
<div v-else class="exec-history-rows">
<div v-for="record in historyLogs"
:key="record.id"
class="exec-history"
@click="emits('selected', record)">
<!-- 机器数量 -->
<span class="exec-history-count">
{{ record.hostIdList?.length || 0 }}
</span>
<!-- 执行描述 -->
<span class="exec-history-desc">
{{ record.description }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'execCommandPanelHistory'
};
</script>
<script lang="ts" setup>
import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import type { ExecCommandRequest } from '@/api/exec/exec-command';
import { onMounted, ref } from 'vue';
import { getExecCommandLogHistory } from '@/api/exec/exec-command-log';
import { historyCount } from '../types/const';
import useLoading from '@/hooks/loading';
const emits = defineEmits(['selected']);
const { loading, setLoading } = useLoading(true);
const historyLogs = ref<Array<ExecLogQueryResponse>>([]);
// 添加
const add = (record: ExecCommandRequest) => {
const index = historyLogs.value.findIndex(s => s.description === record.description);
if (index === -1) {
// 不存在
historyLogs.value.unshift({
description: record.description,
command: record.command,
parameterSchema: record.parameterSchema,
timeout: record.timeout,
hostIdList: record.hostIdList
} as ExecLogQueryResponse);
} else {
// 存在
const his = historyLogs.value[index];
historyLogs.value.splice(index, 1);
historyLogs.value.unshift({
...his,
command: record.command,
parameterSchema: record.parameterSchema,
timeout: record.timeout,
hostIdList: record.hostIdList
} as ExecLogQueryResponse);
}
};
defineExpose({ add });
// 加载批量执行日志
const fetchExecHistory = async () => {
setLoading(true);
try {
const { data } = await getExecCommandLogHistory(historyCount);
historyLogs.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
// 加载批量执行日志
onMounted(fetchExecHistory);
</script>
<style lang="less" scoped>
.exec-history-rows {
position: absolute;
width: calc(100% - 32px);
height: calc(100% - 64px);
overflow: auto;
&::-webkit-scrollbar-track {
display: none;
}
}
.exec-history {
padding: 8px;
border-radius: 2px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
background: var(--color-fill-2);
transition: all .2s;
user-select: none;
cursor: pointer;
&:hover {
background: var(--color-fill-3);
}
&-count {
width: 24px;
height: 24px;
border-radius: 2px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #FFF;
background: rgb(var(--arcoblue-6));
}
&-desc {
color: var(--color-text-2);
width: calc(100% - 36px);
overflow: hidden;
white-space: nowrap;
text-align: end;
text-overflow: ellipsis;
}
}
.history-help {
color: var(--color-text-3);
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<!-- 命令执行 -->
<a-spin class="exec-container" :loading="loading">
<!-- 执行参数 -->
<exec-command-panel-form class="exec-form-container"
:schema-count="parameterSchema.length"
@exec="execCommand"
@reset="resetForm">
<!-- 命令表单 -->
<template #form>
<a-form :model="formModel"
ref="formRef"
class="form-wrapper"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<a-row :gutter="16">
<!-- 执行主机 -->
<a-col :span="24">
<a-form-item field="hostIdList" label="执行主机">
<div class="selected-host">
<!-- 已选择数量 -->
<span class="usn" v-if="formModel.hostIdList?.length">
已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机
</span>
<span class="usn pointer span-blue" @click="openSelectHost">
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
</span>
</div>
</a-form-item>
</a-col>
<!-- 执行描述 -->
<a-col :span="24">
<a-form-item field="description" label="执行描述">
<a-input v-model="formModel.description"
placeholder="请输入执行描述"
allow-clear />
</a-form-item>
</a-col>
<!-- 超时时间 -->
<a-col :span="14">
<a-form-item field="timeout"
label="超时时间"
:hide-asterisk="true">
<a-input-number v-model="formModel.timeout"
placeholder="为0则不超时"
:min="0"
:max="100000"
hide-button>
<template #suffix>
</template>
</a-input-number>
</a-form-item>
</a-col>
<!-- 脚本执行 -->
<a-col :span="10">
<a-form-item field="scriptExec"
label="脚本执行"
:hide-asterisk="true">
<div class="flex-center">
<a-switch v-model="formModel.scriptExec"
type="round"
:checked-value="EnabledStatus.ENABLED"
:unchecked-value="EnabledStatus.DISABLED" />
<div class="question-right ml8">
<a-tooltip content="启用后会将命令写入脚本文件 传输到主机后执行">
<icon-question-circle />
</a-tooltip>
</div>
</div>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
<!-- 参数表单 -->
<template #params>
<a-form :model="parameterFormModel"
ref="parameterFormRef"
label-align="right">
<a-form-item v-for="item in parameterSchema"
:key="item.name"
:field="item.name as string"
:label="item.name"
required>
<a-input v-model="parameterFormModel[item.name as string]"
:placeholder="item.desc"
allow-clear />
</a-form-item>
</a-form>
</template>
</exec-command-panel-form>
<!-- 执行命令 -->
<exec-command-panel-editor class="exec-command-container"
@open-template="() => templateModal.open()">
<exec-editor v-model="formModel.command"
theme="vs-dark"
:parameter="parameterSchema" />
</exec-command-panel-editor>
<!-- 执行历史 -->
<exec-command-panel-history class="exec-history-container"
ref="historyRef"
@selected="setWithExecLog" />
<!-- 命令模板模态框 -->
<exec-template-modal ref="templateModal"
@selected="setWithTemplate" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
@selected="setSelectedHost" />
</a-spin>
</template>
<script lang="ts">
export default {
name: 'execPanel'
};
</script>
<script lang="ts" setup>
import type { ExecTemplateQueryResponse } from '@/api/exec/exec-template';
import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import type { ExecCommandRequest } from '@/api/exec/exec-command';
import type { TemplateParam } from '@/components/view/exec-editor/const';
import { ref } from 'vue';
import formRules from '../types/form.rules';
import useLoading from '@/hooks/loading';
import { batchExecCommand } from '@/api/exec/exec-command';
import { getExecTemplateWithAuthorized } from '@/api/exec/exec-template';
import { EnabledStatus } from '@/types/const';
import { Message } from '@arco-design/web-vue';
import ExecEditor from '@/components/view/exec-editor/index.vue';
import AuthorizedHostModal from '@/components/asset/host/authorized-host-modal/index.vue';
import ExecCommandPanelForm from './exec-command-panel-form.vue';
import ExecCommandPanelHistory from './exec-command-panel-history.vue';
import ExecCommandPanelEditor from './exec-command-panel-editor.vue';
import ExecTemplateModal from '@/components/exec/template/modal/index.vue';
const emits = defineEmits(['submit']);
const defaultForm = (): ExecCommandRequest => {
return {
description: '',
command: '',
timeout: 0,
scriptExec: EnabledStatus.DISABLED,
parameterSchema: '[]',
hostIdList: [],
};
};
const { loading, setLoading } = useLoading();
const hostModal = ref<any>();
const historyRef = ref<any>();
const formRef = ref<any>();
const templateModal = ref<any>();
const parameterFormRef = ref<any>();
const formModel = ref<ExecCommandRequest>({ ...defaultForm() });
const parameterFormModel = ref<Record<string, any>>({});
const parameterSchema = ref<Array<TemplateParam>>([]);
// 打开选择主机
const openSelectHost = () => {
hostModal.value.open(formModel.value.hostIdList);
};
// 设置选中主机
const setSelectedHost = (hosts: Array<number>) => {
formModel.value.hostIdList = hosts;
};
// 从执行模板设置
const setWithTemplate = async ({ id }: ExecTemplateQueryResponse) => {
setLoading(true);
try {
// 查询模板信息
const { data } = await getExecTemplateWithAuthorized(id);
formModel.value = {
...formModel.value,
description: data.name,
command: data.command,
timeout: data.timeout,
scriptExec: data.scriptExec,
hostIdList: data.hostIdList,
};
parameterSchema.value = data.parameterSchema ? JSON.parse(data.parameterSchema) : [];
if (parameterSchema.value.length) {
parameterFormModel.value = parameterSchema.value.reduce((acc, cur) => ({
...acc,
[cur.name as string]: cur.value
}), {});
} else {
parameterFormModel.value = {};
}
} catch (e) {
} finally {
setLoading(false);
}
};
// 从执行日志设置
const setWithExecLog = (record: ExecLogQueryResponse) => {
formModel.value = {
...formModel.value,
command: record.command,
description: record.description,
timeout: record.timeout,
scriptExec: record.scriptExec,
hostIdList: record.hostIdList
};
parameterSchema.value = record.parameterSchema ? JSON.parse(record.parameterSchema) : [];
if (parameterSchema.value.length) {
parameterFormModel.value = parameterSchema.value.reduce((acc, cur) => ({
...acc,
[cur.name as string]: cur.value
}), {});
} else {
parameterFormModel.value = {};
}
};
// 执行
const execCommand = async () => {
setLoading(true);
try {
// 验证参数
let error = await formRef.value.validate();
if (error) {
return false;
}
error = await parameterFormRef.value?.validate();
if (error) {
return false;
}
if (!formModel.value.command) {
Message.error('请输入命令');
return false;
}
// 设置 schema
for (let ps of parameterSchema.value) {
ps.value = parameterFormModel.value[ps.name as string];
}
// 执行命令
const request = {
...formModel.value,
parameterSchema: JSON.stringify(parameterSchema.value),
};
const { data } = await batchExecCommand(request);
// 设置历史命令
historyRef.value.add(request);
Message.success('已开始执行');
emits('submit', data);
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 重置
const resetForm = () => {
formModel.value = Object.assign({}, { ...defaultForm() });
parameterFormModel.value = {};
parameterSchema.value = [];
};
</script>
<style lang="less" scoped>
@form-width: 420px;
@history-width: 320px;
@command-gap: @form-width + @history-width + 32px;
.exec-container {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
position: absolute;
.exec-form-container {
width: @form-width;
}
.exec-command-container {
width: calc(100% - @command-gap);
margin: 0 16px;
}
.exec-history-container {
width: @history-width;
}
.exec-form-container, .exec-command-container, .exec-history-container {
background: var(--color-bg-2);
border-radius: 4px;
height: 100%;
padding: 16px;
position: relative;
}
}
.exec-form-container {
.form-wrapper {
margin-top: 8px;
}
.selected-host {
width: 100%;
height: 32px;
padding: 0 8px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text-2);
background: var(--color-fill-2);
transition: all 0.3s;
&-count {
font-size: 16px;
font-weight: 600;
display: inline-block;
margin: 0 6px;
}
&:hover {
background: var(--color-fill-3);
}
}
}
:deep(.panel-header) {
width: 100%;
height: 28px;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
align-items: flex-start;
h3, > span {
margin: 0;
overflow: hidden;
white-space: nowrap;
}
h3 {
color: var(--color-text-1);
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="layout-container full">
<!-- 执行面板 -->
<div v-show="!logVisible" class="panel-wrapper">
<exec-command-panel @submit="openLog" />
</div>
<!-- 执行日志 -->
<div v-if="logVisible" class="panel-wrapper">
<exec-log-panel ref="log"
type="BATCH"
:visibleBack="true"
@back="setLogVisible(false)" />
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'execCommand'
};
</script>
<script lang="ts" setup>
import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import { nextTick, onMounted, ref } from 'vue';
import useVisible from '@/hooks/visible';
import { useDictStore } from '@/store';
import { dictKeys } from '@/components/exec/log/const';
import { useRoute } from 'vue-router';
import { getExecCommandLog } from '@/api/exec/exec-command-log';
import ExecCommandPanel from './components/exec-command-panel.vue';
import ExecLogPanel from '@/components/exec/log/panel/index.vue';
const { visible: logVisible, setVisible: setLogVisible } = useVisible();
const route = useRoute();
const log = ref();
// 打开日志
const openLog = (record: ExecLogQueryResponse) => {
setLogVisible(true);
nextTick(() => {
log.value.open(record);
});
};
// 打开日志
const openLogWithId = async (id: number) => {
setLogVisible(true);
// 查询日志
const { data } = await getExecCommandLog(id);
openLog(data);
};
// 加载字典值
onMounted(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 跳转日志
onMounted(async () => {
const idParam = route.query.id;
const keyParam = route.query.key;
if (idParam) {
await openLogWithId(Number.parseInt(idParam as string));
} else if (keyParam) {
await openLogWithId(Number.parseInt(keyParam as string));
}
});
</script>
<style lang="less" scoped>
.panel-wrapper {
width: 100%;
height: 100%;
position: relative;
}
</style>

View File

@@ -0,0 +1,2 @@
// 执行
export const historyCount = 20;

View File

@@ -0,0 +1,39 @@
import type { FieldRule } from '@arco-design/web-vue';
export const description = [{
maxLength: 128,
message: '执行描述长度不能大于128位'
}] as FieldRule[];
export const hostIdList = [{
required: true,
message: '请选择执行主机'
}] as FieldRule[];
export const command = [{
required: true,
message: '请输入执行命令'
}] as FieldRule[];
export const timeout = [{
required: true,
message: '请输入超时时间'
}, {
type: 'number',
min: 0,
max: 100000,
message: '超时时间需要在 0 - 100000 之间'
}] as FieldRule[];
export const scriptExec = [{
required: true,
message: '请选择是否使用脚本执行'
}] as FieldRule[];
export default {
description,
hostIdList,
command,
timeout,
scriptExec,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,281 @@
<template>
<a-drawer v-model:visible="visible"
title="执行命令"
width="66%"
:esc-to-close="false"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@cancel="handleClose">
<a-spin class="full modal-form-small" :loading="loading">
<!-- 命令表单 -->
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<a-row :gutter="16">
<!-- 执行描述 -->
<a-col :span="16">
<a-form-item field="description"
label="执行描述"
:hide-asterisk="true">
<a-input v-model="formModel.description"
placeholder="请输入执行描述"
allow-clear />
</a-form-item>
</a-col>
<!-- 超时时间 -->
<a-col :span="8">
<a-form-item field="timeout"
label="超时时间"
:hide-asterisk="true">
<a-input-number v-model="formModel.timeout"
placeholder="为0则不超时"
:min="0"
:max="100000"
hide-button>
<template #suffix>
</template>
</a-input-number>
</a-form-item>
</a-col>
<!-- 执行主机 -->
<a-col :span="16">
<a-form-item field="hostIdList"
label="执行主机"
:hide-asterisk="true">
<div class="selected-host">
<!-- 已选择数量 -->
<span class="usn" v-if="formModel.hostIdList?.length">
已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机
</span>
<span class="usn pointer span-blue" @click="openSelectHost">
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
</span>
</div>
</a-form-item>
</a-col>
<!-- 脚本执行 -->
<a-col :span="8">
<a-form-item field="scriptExec"
label="脚本执行"
:hide-asterisk="true">
<div class="flex-center">
<a-switch v-model="formModel.scriptExec"
type="round"
:checked-value="EnabledStatus.ENABLED"
:unchecked-value="EnabledStatus.DISABLED" />
<div class="question-right ml8">
<a-tooltip position="tr" content="启用后会将命令写入脚本文件 传输到主机后执行">
<icon-question-circle />
</a-tooltip>
</div>
</div>
</a-form-item>
</a-col>
<!-- 执行命令 -->
<a-col :span="24">
<a-form-item field="command"
label="执行命令"
:hide-label="true"
:help="'使用 @{{ xxx }} 来替换参数, 输入_可以获取全部变量'">
<exec-editor v-model="formModel.command"
container-class="command-editor"
theme="vs-dark"
:parameter="parameterSchema" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<!-- 命令参数 -->
<a-divider v-if="parameterSchema.length"
orientation="center"
style="margin: 12px 0 26px 0;">
命令参数
</a-divider>
<!-- 参数表单 -->
<a-form v-if="parameterSchema.length"
:model="parameterFormModel"
ref="parameterFormRef"
label-align="left"
:auto-label-width="true">
<a-row :gutter="16">
<a-col v-for="item in parameterSchema"
:key="item.name"
:span="12">
<a-form-item :field="item.name as string"
:label="item.name"
required>
<a-input v-model="parameterFormModel[item.name as string]"
:placeholder="item.desc"
allow-clear />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'execTemplateExecDrawer'
};
</script>
<script lang="ts" setup>
import type { TemplateParam } from '@/components/view/exec-editor/const';
import type { ExecTemplateQueryResponse } from '@/api/exec/exec-template';
import type { ExecCommandRequest } from '@/api/exec/exec-command';
import { onUnmounted, ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../../exec-command/types/form.rules';
import { Message } from '@arco-design/web-vue';
import { EnabledStatus } from '@/types/const';
import { batchExecCommand } from '@/api/exec/exec-command';
import { getExecTemplateWithAuthorized } from '@/api/exec/exec-template';
import ExecEditor from '@/components/view/exec-editor/index.vue';
const emits = defineEmits(['openHost']);
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const formRef = ref<any>();
const parameterFormRef = ref<any>();
const formModel = ref<ExecCommandRequest>({});
const parameterFormModel = ref<Record<string, any>>({});
const parameterSchema = ref<Array<TemplateParam>>([]);
// 打开
const open = async (id: number) => {
renderForm({} as ExecTemplateQueryResponse);
setVisible(true);
setLoading(true);
try {
// 查询模板信息
const { data } = await getExecTemplateWithAuthorized(id);
renderForm(data);
} catch (e) {
} finally {
setLoading(false);
}
};
// 渲染表单
const renderForm = (record: ExecTemplateQueryResponse) => {
formModel.value = {
description: record.name,
timeout: record.timeout,
scriptExec: record.scriptExec,
command: record.command,
hostIdList: record.hostIdList || [],
};
if (record.parameterSchema) {
parameterSchema.value = JSON.parse(record.parameterSchema);
const params = {} as any;
for (let param of parameterSchema.value) {
params[param.name as keyof any] = param.defaultValue;
}
parameterFormModel.value = params;
} else {
parameterSchema.value = [];
parameterFormModel.value = {};
}
};
// 设置选中主机
const setSelectedHost = (hosts: Array<number>) => {
formModel.value.hostIdList = hosts;
};
defineExpose({ open, setSelectedHost });
// 打开选择主机
const openSelectHost = () => {
emits('openHost', formModel.value.hostIdList);
};
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
let error = await formRef.value.validate();
if (error) {
return false;
}
error = await parameterFormRef.value?.validate();
if (error) {
return false;
}
// 设置 schema
for (let ps of parameterSchema.value) {
ps.value = parameterFormModel.value[ps.name as string];
}
// 执行命令
await batchExecCommand({
...formModel.value,
parameterSchema: JSON.stringify(parameterSchema.value),
});
Message.success('已开始执行');
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
// 卸载关闭
onUnmounted(handlerClear);
</script>
<style lang="less" scoped>
.selected-host {
width: 100%;
height: 32px;
padding: 0 8px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-fill-2);
transition: all 0.3s;
&-count {
font-size: 16px;
font-weight: 600;
display: inline-block;
margin: 0 6px;
}
&:hover {
background: var(--color-fill-3);
}
}
.command-editor {
width: 100%;
height: 62vh;
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<a-drawer v-model:visible="visible"
width="66%"
:title="title"
:esc-to-close="false"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@cancel="handleClose">
<a-spin class="full modal-form-small" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<a-row :gutter="16">
<!-- 模板名称 -->
<a-col :span="16">
<a-form-item field="name"
label="模板名称"
:hide-asterisk="true">
<a-input v-model="formModel.name"
placeholder="请输入模板名称"
allow-clear />
</a-form-item>
</a-col>
<!-- 超时时间 -->
<a-col :span="8">
<a-form-item field="timeout"
label="超时时间"
:hide-asterisk="true">
<a-input-number v-model="formModel.timeout"
placeholder="为0则不超时"
:min="0"
:max="100000"
hide-button>
<template #suffix>
</template>
</a-input-number>
</a-form-item>
</a-col>
<!-- 默认主机 -->
<a-col :span="16">
<a-form-item field="hostIdList"
label="默认主机"
:hide-asterisk="true">
<div class="selected-host">
<!-- 已选择数量 -->
<span class="usn" v-if="formModel.hostIdList?.length">
已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机
</span>
<span class="usn pointer span-blue" @click="openSelectHost">
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
</span>
</div>
</a-form-item>
</a-col>
<!-- 脚本执行 -->
<a-col :span="8">
<a-form-item field="scriptExec"
label="脚本执行"
:hide-asterisk="true">
<div class="flex-center">
<a-switch v-model="formModel.scriptExec"
type="round"
:checked-value="EnabledStatus.ENABLED"
:unchecked-value="EnabledStatus.DISABLED" />
<div class="question-right ml8">
<a-tooltip position="tr" content="启用后会将命令写入脚本文件 传输到主机后执行">
<icon-question-circle />
</a-tooltip>
</div>
</div>
</a-form-item>
</a-col>
<!-- 模板命令 -->
<a-col :span="24">
<a-form-item field="command"
label="模板命令"
:hide-label="true"
:help="'使用 @{{ xxx }} 来替换参数, 输入_可以获取全部变量'">
<exec-editor v-model="formModel.command"
container-class="command-editor"
theme="vs-dark"
:parameter="parameter" />
</a-form-item>
</a-col>
<!-- 命令参数 -->
<a-col :span="24">
<a-form-item field="parameter"
class="parameter-form-item"
label="命令参数">
<!-- label -->
<template #label>
<span class="span-blue pointer" @click="addParameter">添加参数</span>
</template>
<!-- 参数 -->
<template v-if="parameter.length">
<a-input-group v-for="(item, i) in parameter"
:key="i"
class="parameter-item"
:class="[ i === parameter.length - 1 ? 'parameter-item-last' : '' ]">
<a-input class="parameter-item-name"
v-model="item.name"
placeholder="参数名称 (必填)"
allow-clear />
<a-input class="parameter-item-default"
v-model="item.defaultValue"
placeholder="默认值 (非必填)"
allow-clear />
<a-input class="parameter-item-description"
v-model="item.desc"
placeholder="描述 (非必填)"
allow-clear />
<span class="parameter-item-close click-icon-wrapper"
title="移除"
@click="removeParameter(i)">
<icon-close />
</span>
</a-input-group>
</template>
<!-- 无参数 -->
<template v-else>
<span class="no-parameter">
<icon-empty class="mr4" />无参数
</span>
</template>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'execTemplateFormDrawer'
};
</script>
<script lang="ts" setup>
import type { TemplateParam } from '@/components/view/exec-editor/const';
import type { ExecTemplateUpdateRequest } from '@/api/exec/exec-template';
import { onUnmounted, ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
import { createExecTemplate, getExecTemplateWithAuthorized, updateExecTemplate } from '@/api/exec/exec-template';
import { Message } from '@arco-design/web-vue';
import { EnabledStatus } from '@/types/const';
import ExecEditor from '@/components/view/exec-editor/index.vue';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>();
const isAddHandle = ref<boolean>(true);
const defaultForm = (): ExecTemplateUpdateRequest => {
return {
id: undefined,
name: undefined,
command: undefined,
timeout: 0,
scriptExec: EnabledStatus.DISABLED,
parameterSchema: undefined,
hostIdList: [],
};
};
const formRef = ref<any>();
const formModel = ref<ExecTemplateUpdateRequest>({});
const parameter = ref<Array<TemplateParam>>([]);
const emits = defineEmits(['added', 'updated', 'openHost']);
// 打开新增
const openAdd = () => {
title.value = '添加执行模板';
isAddHandle.value = true;
renderForm({ ...defaultForm() });
setVisible(true);
};
// 打开修改
const openUpdate = async (id: number) => {
title.value = '修改执行模板';
isAddHandle.value = false;
renderForm({ ...defaultForm() });
setVisible(true);
setLoading(true);
try {
// 查询模板信息
const { data } = await getExecTemplateWithAuthorized(id);
renderForm({ ...defaultForm(), ...data });
} catch (e) {
} finally {
setLoading(false);
}
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
if (record.parameterSchema) {
parameter.value = JSON.parse(record.parameterSchema);
} else {
parameter.value = [];
}
};
// 设置选中主机
const setSelectedHost = (hosts: Array<number>) => {
formModel.value.hostIdList = hosts;
};
defineExpose({ openAdd, openUpdate, setSelectedHost });
// 打开选择主机
const openSelectHost = () => {
emits('openHost', formModel.value.hostIdList || []);
};
// 添加参数
const addParameter = () => {
parameter.value.push({});
};
// 移除参数
const removeParameter = (index: number) => {
parameter.value.splice(index, 1);
};
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
// 验证并设置命令参数
for (const p of parameter.value) {
if (!p.name) {
Message.warning('请补全命令参数');
return false;
}
}
formModel.value.parameterSchema = JSON.stringify(parameter.value);
if (isAddHandle.value) {
// 新增
await createExecTemplate(formModel.value);
Message.success('创建成功');
emits('added');
} else {
// 修改
await updateExecTemplate(formModel.value);
Message.success('修改成功');
emits('updated');
}
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
// 卸载关闭
onUnmounted(handlerClear);
</script>
<style lang="less" scoped>
.selected-host {
width: 100%;
height: 32px;
padding: 0 8px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-fill-2);
transition: all 0.3s;
&-count {
font-size: 16px;
font-weight: 600;
display: inline-block;
margin: 0 6px;
}
&:hover {
background: var(--color-fill-3);
}
}
.parameter-form-item {
user-select: none;
margin-top: 4px;
:deep(.arco-form-item-content) {
flex-direction: column;
}
.parameter-item-last {
margin-bottom: 0 !important;
}
.parameter-item {
width: 100%;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
& > span {
border-radius: 2px;
border-right-color: transparent;
}
&-name {
width: 29%;
}
&-default {
width: 29%;
}
&-description {
width: calc(39% - 44px);
}
&-close {
cursor: pointer;
width: 32px;
height: 32px;
font-size: 16px;
background: var(--color-fill-2);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-fill-3);
}
}
}
.no-parameter {
background: var(--color-fill-2);
width: 100%;
height: 32px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-2);
}
}
.command-editor {
width: 100%;
height: 55vh;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 模板名称 -->
<a-form-item field="name" label="模板名称">
<a-input v-model="formModel.name"
placeholder="请输入模板名称"
allow-clear />
</a-form-item>
<!-- 模板命令 -->
<a-form-item field="command" label="模板命令">
<a-input v-model="formModel.command"
placeholder="请输入模板命令"
allow-clear />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
执行模板列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 新增 -->
<a-button v-permission="['asset:exec-template:create']"
type="primary"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 模板命令 -->
<template #command="{ record }">
<span class="copy-left" @click="copy(record.command, '已复制')">
<icon-copy />
</span>
<span :title="record.command">{{ record.command }}</span>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<a-button v-permission="['asset:exec-command:exec']"
type="text"
size="mini"
@click="emits('openExec', record.id)">
执行
</a-button>
<!-- 修改 -->
<a-button v-permission="['asset:exec-template:update']"
type="text"
size="mini"
@click="emits('openUpdate', record.id)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:exec-template:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'execTemplateTable'
};
</script>
<script lang="ts" setup>
import type { ExecTemplateQueryRequest, ExecTemplateQueryResponse } from '@/api/exec/exec-template';
import { reactive, ref, onMounted } from 'vue';
import { deleteExecTemplate, getExecTemplatePage } from '@/api/exec/exec-template';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { usePagination } from '@/types/table';
import { copy } from '@/hooks/copy';
const emits = defineEmits(['openAdd', 'openUpdate', 'openExec']);
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const tableRenderData = ref<ExecTemplateQueryResponse[]>([]);
const formModel = reactive<ExecTemplateQueryRequest>({
id: undefined,
name: undefined,
command: undefined,
});
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteExecTemplate(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 添加后回调
const addedCallback = () => {
fetchTableData();
};
// 更新后回调
const updatedCallback = () => {
fetchTableData();
};
defineExpose({
addedCallback, updatedCallback
});
// 加载数据
const doFetchTableData = async (request: ExecTemplateQueryRequest) => {
try {
setLoading(true);
const { data } = await getExecTemplatePage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<exec-template-table ref="table"
@open-add="() => drawer.openAdd()"
@open-update="(e) => drawer.openUpdate(e)"
@open-exec="(e) => execModal.open(e)" />
<!-- 添加修改模态框 -->
<exec-template-form-drawer ref="drawer"
@added="modalAddCallback"
@updated="modalUpdateCallback"
@open-host="(e) => openHostModal('drawer', e)" />
<!-- 执行模态框 -->
<exec-template-exec-drawer ref="execModal"
@open-host="(e) => openHostModal('exec', e)" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
@selected="hostSelected" />
</div>
</template>
<script lang="ts">
export default {
name: 'execTemplate'
};
</script>
<script lang="ts" setup>
import { ref, onBeforeMount } from 'vue';
import ExecTemplateTable from './components/exec-template-table.vue';
import ExecTemplateFormDrawer from './components/exec-template-form-drawer.vue';
import ExecTemplateExecDrawer from './components/exec-template-exec-drawer.vue';
import AuthorizedHostModal from '@/components/asset/host/authorized-host-modal/index.vue';
const render = ref(false);
const table = ref();
const drawer = ref();
const execModal = ref();
const hostModal = ref();
const lastOpenHostRef = ref();
// 添加回调
const modalAddCallback = () => {
table.value.addedCallback();
};
// 修改回调
const modalUpdateCallback = () => {
table.value.updatedCallback();
};
// 打开主机模态框
const openHostModal = (openRef: string, data: any) => {
lastOpenHostRef.value = openRef;
hostModal.value.open(data);
};
// 选中主机
const hostSelected = (data: any) => {
if (lastOpenHostRef.value === 'drawer') {
// 设置选中的主机
drawer.value.setSelectedHost(data);
} else if (lastOpenHostRef.value === 'exec') {
// 设置选中的主机
execModal.value.setSelectedHost(data);
}
};
onBeforeMount(async () => {
render.value = true;
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,36 @@
import type { FieldRule } from '@arco-design/web-vue';
export const name = [{
required: true,
message: '请输入模板名称'
}, {
maxLength: 64,
message: '模板名称长度不能大于64位'
}] as FieldRule[];
export const command = [{
required: true,
message: '请输入模板命令'
}] as FieldRule[];
export const timeout = [{
required: true,
message: '请输入超时时间'
}, {
type: 'number',
min: 0,
max: 100000,
message: '超时时间需要在 0 - 100000 之间'
}] as FieldRule[];
export const scriptExec = [{
required: true,
message: '请选择是否使用脚本执行'
}] as FieldRule[];
export default {
name,
command,
timeout,
scriptExec,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,43 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '模板名称',
dataIndex: 'name',
slotName: 'name',
align: 'left',
width: 200,
ellipsis: true,
}, {
title: '模板命令',
dataIndex: 'command',
slotName: 'command',
align: 'left',
ellipsis: true,
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 180,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,162 @@
<template>
<a-modal v-model:visible="visible"
body-class="modal-form-large"
title-align="start"
title="清理上传任务"
:top="80"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true">
<!-- 上传时间 -->
<a-form-item field="createTimeRange" label="上传时间">
<a-range-picker v-model="formModel.createTimeRange"
:time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }"
show-time
format="YYYY-MM-DD HH:mm:ss" />
</a-form-item>
<!-- 上传用户 -->
<a-form-item field="userId" label="上传用户">
<user-selector v-model="formModel.userId"
placeholder="请选择上传用户"
allow-clear />
</a-form-item>
<!-- 远程路径 -->
<a-form-item field="remotePath" label="远程路径">
<a-input v-model="formModel.remotePath"
placeholder="请输入远程路径"
allow-clear />
</a-form-item>
<!-- 上传描述 -->
<a-form-item field="description" label="上传描述">
<a-input v-model="formModel.description"
placeholder="请输入上传描述"
allow-clear />
</a-form-item>
<!-- 上传状态 -->
<a-form-item field="status" label="上传状态">
<a-select v-model="formModel.status"
:options="toOptions(uploadTaskStatusKey)"
placeholder="请选择状态"
allow-clear />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'uploadTaskFormModal'
};
</script>
<script lang="ts" setup>
import type { UploadTaskQueryRequest } from '@/api/exec/upload-task';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import { uploadTaskStatusKey } from '../types/const';
import { getUploadTaskCount, clearUploadTask } from '@/api/exec/upload-task';
import { Message, Modal } from '@arco-design/web-vue';
import { useDictStore } from '@/store';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['clear']);
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const { toOptions } = useDictStore();
const formRef = ref<any>();
const formModel = ref<UploadTaskQueryRequest>({});
const defaultForm = (): UploadTaskQueryRequest => {
return {
userId: undefined,
remotePath: undefined,
description: undefined,
status: undefined,
createTimeRange: undefined,
};
};
// 打开
const open = (record: any) => {
renderForm({ ...defaultForm(), ...record });
setVisible(true);
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ open });
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 获取总数量
const { data } = await getUploadTaskCount(formModel.value);
if (data) {
// 清空
doClear(data);
} else {
// 无数据
Message.warning('当前条件未查询到数据');
}
} catch (e) {
} finally {
setLoading(false);
}
return false;
};
// 执行删除
const doClear = (count: number) => {
Modal.confirm({
title: '删除清空',
content: `确定要删除 ${count} 条数据吗? 确定后将立即删除且无法恢复!`,
onOk: async () => {
setLoading(true);
try {
// 调用删除
await clearUploadTask(formModel.value);
emits('clear');
// 清空
setVisible(false);
handlerClear();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,325 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
:itemOptions="{ 5: { span: 2 } }"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 上传用户 -->
<a-form-item field="userId" label="上传用户">
<user-selector v-model="formModel.userId"
placeholder="请选择上传用户"
allow-clear />
</a-form-item>
<!-- 远程路径 -->
<a-form-item field="remotePath" label="远程路径">
<a-input v-model="formModel.remotePath"
placeholder="请输入远程路径"
allow-clear />
</a-form-item>
<!-- 上传描述 -->
<a-form-item field="description" label="上传描述">
<a-input v-model="formModel.description"
placeholder="请输入上传描述"
allow-clear />
</a-form-item>
<!-- 上传状态 -->
<a-form-item field="status" label="上传状态">
<a-select v-model="formModel.status"
:options="toOptions(uploadTaskStatusKey)"
placeholder="请选择状态"
allow-clear />
</a-form-item>
<!-- 上传时间 -->
<a-form-item field="createTimeRange" label="上传时间">
<a-range-picker v-model="formModel.createTimeRange"
:time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }"
show-time
format="YYYY-MM-DD HH:mm:ss" />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
上传任务列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 上传 -->
<a-button v-permission="['asset:upload-task:upload']"
type="primary"
@click="$router.push({ name: 'batchUpload' })">
上传
<template #icon>
<icon-upload />
</template>
</a-button>
<!-- 清空 -->
<a-button v-permission="['asset:upload-task:management:clear']"
status="danger"
@click="openClear">
清空
<template #icon>
<icon-close />
</template>
</a-button>
<!-- 删除 -->
<a-popconfirm :content="`确认删除选中的 ${selectedKeys.length} 条记录吗?`"
position="br"
type="warning"
@ok="deleteSelectRows">
<a-button v-permission="['asset:upload-task:delete']"
type="secondary"
status="danger"
:disabled="selectedKeys.length === 0">
删除
<template #icon>
<icon-delete />
</template>
</a-button>
</a-popconfirm>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
v-model:selected-keys="selectedKeys"
:row-selection="rowSelection"
:data="tableRenderData"
:pagination="pagination"
:bordered="false"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)">
<!-- 上传路径 -->
<template #remotePath="{ record }">
<span class="text-copy span-blue" @click="copy(record.remotePath)">
{{ record.remotePath }}
</span>
</template>
<!-- 文件数量 -->
<template #fileCount="{ record }">
<span class="span-blue">
{{ record.fileCount }}
</span>
</template>
<!-- 主机数量 -->
<template #hostCount="{ record }">
<span class="span-blue">
{{ record.hostCount }}
</span>
</template>
<!-- 上传状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(uploadTaskStatusKey, record.status, 'color')">
{{ getDictValue(uploadTaskStatusKey, record.status) }}
</a-tag>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 详情 -->
<a-button v-permission="['asset:upload-task:query']"
type="text"
size="mini"
@click="$router.push({ name: 'batchUpload', query: { id: record.id } })">
详情
</a-button>
<!-- 取消 -->
<a-popconfirm v-if="record.status === UploadTaskStatus.WAITING || record.status === UploadTaskStatus.UPLOADING"
content="确定要取消上传吗?"
position="left"
type="warning"
@ok="doCancel(record)">
<a-button v-permission="['asset:upload-task:upload']"
type="text"
size="mini">
取消
</a-button>
</a-popconfirm>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:upload-task:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'uploadTaskTable'
};
</script>
<script lang="ts" setup>
import type { UploadTaskQueryRequest, UploadTaskQueryResponse } from '@/api/exec/upload-task';
import { reactive, ref, onMounted, onUnmounted } from 'vue';
import { batchDeleteUploadTask, deleteUploadTask, getUploadTaskPage, cancelUploadTask, getUploadTaskStatus } from '@/api/exec/upload-task';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { UploadTaskStatus, uploadTaskStatusKey } from '../types/const';
import { usePagination, useRowSelection } from '@/types/table';
import { useDictStore } from '@/store';
import { copy } from '@/hooks/copy';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['openClear']);
const pagination = usePagination();
const rowSelection = useRowSelection();
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue } = useDictStore();
const pullIntervalId = ref();
const selectedKeys = ref<number[]>([]);
const tableRenderData = ref<UploadTaskQueryResponse[]>([]);
const formModel = reactive<UploadTaskQueryRequest>({
id: undefined,
userId: undefined,
remotePath: undefined,
description: undefined,
status: undefined,
createTimeRange: undefined,
});
// 打开清理
const openClear = () => {
emits('openClear', { ...formModel, id: undefined });
};
// 执行取消
const doCancel = async (record: UploadTaskQueryResponse) => {
try {
setLoading(true);
// 取消
await cancelUploadTask(record.id, false);
// 设置状态
record.status = UploadTaskStatus.CANCELED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 删除选中行
const deleteSelectRows = async () => {
try {
setLoading(true);
// 调用删除接口
await batchDeleteUploadTask(selectedKeys.value);
Message.success(`成功删除 ${selectedKeys.value.length} 条数据`);
selectedKeys.value = [];
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteUploadTask(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 加载状态
const pullTaskStatus = async () => {
const unCompleteIdList = tableRenderData.value
.filter(s => s.status === UploadTaskStatus.WAITING || s.status === UploadTaskStatus.UPLOADING)
.map(s => s.id);
if (!unCompleteIdList.length) {
return;
}
// 加载未完成的状态
const { data } = await getUploadTaskStatus(unCompleteIdList, false);
data.forEach(s => {
const tableRow = tableRenderData.value.find(r => r.id === s.id);
if (!tableRow) {
return;
}
tableRow.status = s.status;
});
};
// 加载数据
const doFetchTableData = async (request: UploadTaskQueryRequest) => {
try {
setLoading(true);
const { data } = await getUploadTaskPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
selectedKeys.value = [];
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
defineExpose({
fetchTableData
});
onMounted(() => {
// 加载数据
fetchTableData();
// 注册状态轮询
pullIntervalId.value = setInterval(pullTaskStatus, 10000);
});
onUnmounted(() => {
// 卸载状态轮询
clearInterval(pullIntervalId.value);
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<upload-task-table ref="table"
@open-clear="(e) => clear.open(e)" />
<!-- 清理模态框 -->
<upload-task-clear-modal ref="clear"
@clear="clearCallback" />
</div>
</template>
<script lang="ts">
export default {
name: 'uploadTask'
};
</script>
<script lang="ts" setup>
import { ref, onBeforeMount } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from './types/const';
import UploadTaskTable from './components/upload-task-table.vue';
import UploadTaskClearModal from './components/upload-task-clear-modal.vue';
const render = ref(false);
const table = ref();
const clear = ref();
// 清理回调
const clearCallback = () => {
table.value.fetchTableData();
};
// 加载字典值
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,33 @@
// 上传任务状态
export const UploadTaskStatus = {
// 等待中
WAITING: 'WAITING',
// 上传中
UPLOADING: 'UPLOADING',
// 已完成
FINISHED: 'FINISHED',
// 已完成
FAILED: 'FAILED',
// 已取消
CANCELED: 'CANCELED',
};
// 上传任务文件状态
export const UploadTaskFileStatus = {
// 等待中
WAITING: 'WAITING',
// 上传中
UPLOADING: 'UPLOADING',
// 已完成
FINISHED: 'FINISHED',
// 已完成
FAILED: 'FAILED',
// 已取消
CANCELED: 'CANCELED',
};
// 上传任务状态 字典项
export const uploadTaskStatusKey = 'uploadTaskStatus';
// 加载的字典值
export const dictKeys = [uploadTaskStatusKey];

View File

@@ -0,0 +1,70 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '上传用户',
dataIndex: 'username',
slotName: 'username',
align: 'left',
width: 118,
ellipsis: true,
tooltip: true,
}, {
title: '上传描述',
dataIndex: 'description',
slotName: 'description',
align: 'left',
ellipsis: true,
tooltip: true,
}, {
title: '远程路径',
dataIndex: 'remotePath',
slotName: 'remotePath',
align: 'left',
ellipsis: true,
tooltip: true,
}, {
title: '上传状态',
dataIndex: 'status',
slotName: 'status',
align: 'center',
width: 138,
}, {
title: '文件数量',
dataIndex: 'fileCount',
slotName: 'fileCount',
width: 98,
align: 'center',
}, {
title: '主机数量',
dataIndex: 'hostCount',
slotName: 'hostCount',
width: 98,
align: 'center',
}, {
title: '上传时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 180,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;