🔨 批量上传.

This commit is contained in:
lijiahang
2024-05-10 11:23:22 +08:00
parent cf17cf93b0
commit cd312ef5c8
28 changed files with 658 additions and 213 deletions

View File

@@ -55,8 +55,8 @@ public class CodeGenerators {
.enableRowSelection() .enableRowSelection()
.dict("uploadTaskStatus", "status") .dict("uploadTaskStatus", "status")
.comment("上传任务状态") .comment("上传任务状态")
.fields("PREPARATION", "UPLOADING", "FINISHED", "CANCELED") .fields("WAITING", "UPLOADING", "FINISHED", "FAILED", "CANCELED")
.labels("准备", "上传中", "已完成", "已取消") .labels("等待", "上传中", "已完成", "已失败", "已取消")
.valueUseFields() .valueUseFields()
.build(), .build(),
Template.create("upload_task_file", "上传任务文件", "upload") Template.create("upload_task_file", "上传任务文件", "upload")
@@ -65,8 +65,8 @@ public class CodeGenerators {
.enableRowSelection() .enableRowSelection()
.dict("uploadTaskFileStatus", "status") .dict("uploadTaskFileStatus", "status")
.comment("上传任务文件状态") .comment("上传任务文件状态")
.fields("WAITING", "UPLOADING", "FINISHED", "CANCELED") .fields("WAITING", "UPLOADING", "FINISHED", "FAILED", "CANCELED")
.labels("等待中", "上传中", "已完成", "已取消") .labels("等待中", "上传中", "已完成", "失败", "取消")
.valueUseFields() .valueUseFields()
.build(), .build(),
}; };

View File

@@ -40,6 +40,11 @@ import java.util.List;
@SuppressWarnings({"ELValidationInJSP", "SpringElInspection"}) @SuppressWarnings({"ELValidationInJSP", "SpringElInspection"})
public class UploadTaskController { public class UploadTaskController {
// todo create 返回 host, STATUS
// 修改状态元数据
// 上船前检查size, size不对则直接cancel
// cancel 需要设置子元素为 cancel
@Resource @Resource
private UploadTaskService uploadTaskService; private UploadTaskService uploadTaskService;
@@ -64,7 +69,7 @@ public class UploadTaskController {
@Operation(summary = "取消上传") @Operation(summary = "取消上传")
@PreAuthorize("@ss.hasPermission('asset:upload-task:upload')") @PreAuthorize("@ss.hasPermission('asset:upload-task:upload')")
public Boolean cancelUploadTask(@Validated @RequestBody UploadTaskRequest request) { public Boolean cancelUploadTask(@Validated @RequestBody UploadTaskRequest request) {
uploadTaskService.cancelUploadTask(request.getId()); uploadTaskService.cancelUploadTask(request);
return true; return true;
} }

View File

@@ -29,4 +29,7 @@ public class UploadTaskRequest implements Serializable {
@Schema(description = "id") @Schema(description = "id")
private Long id; private Long id;
@Schema(description = "是否失败")
private Boolean failed;
} }

View File

@@ -15,9 +15,9 @@ import lombok.Getter;
public enum UploadTaskStatusEnum { public enum UploadTaskStatusEnum {
/** /**
* 准备 * 等待
*/ */
PREPARATION(true), WAITING(true),
/** /**
* 上传中 * 上传中
@@ -29,6 +29,11 @@ public enum UploadTaskStatusEnum {
*/ */
FINISHED(false), FINISHED(false),
/**
* 已失败
*/
FAILED(false),
/** /**
* 已取消 * 已取消
*/ */

View File

@@ -68,7 +68,7 @@ public class FileUploadTask implements IFileUploadTask {
return; return;
} }
// 检查任务状态 非准备中则取消执行 // 检查任务状态 非准备中则取消执行
if (!UploadTaskStatusEnum.PREPARATION.name().equals(record.getStatus())) { if (!UploadTaskStatusEnum.WAITING.name().equals(record.getStatus())) {
return; return;
} }
try { try {

View File

@@ -3,6 +3,7 @@ package com.orion.ops.module.asset.service;
import com.orion.lang.define.wrapper.DataGrid; import com.orion.lang.define.wrapper.DataGrid;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskCreateRequest; import com.orion.ops.module.asset.entity.request.upload.UploadTaskCreateRequest;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskQueryRequest; import com.orion.ops.module.asset.entity.request.upload.UploadTaskQueryRequest;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskRequest;
import com.orion.ops.module.asset.entity.vo.UploadTaskCreateVO; import com.orion.ops.module.asset.entity.vo.UploadTaskCreateVO;
import com.orion.ops.module.asset.entity.vo.UploadTaskVO; import com.orion.ops.module.asset.entity.vo.UploadTaskVO;
@@ -85,9 +86,9 @@ public interface UploadTaskService {
/** /**
* 取消上传 * 取消上传
* *
* @param id id * @param request request
*/ */
void cancelUploadTask(Long id); void cancelUploadTask(UploadTaskRequest request);
/** /**
* 删除上传交换区的文件 * 删除上传交换区的文件

View File

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.orion.lang.define.wrapper.DataGrid; import com.orion.lang.define.wrapper.DataGrid;
import com.orion.lang.utils.Arrays1; import com.orion.lang.utils.Arrays1;
import com.orion.lang.utils.Booleans;
import com.orion.lang.utils.Strings; import com.orion.lang.utils.Strings;
import com.orion.lang.utils.collect.Lists; import com.orion.lang.utils.collect.Lists;
import com.orion.lang.utils.collect.Maps; import com.orion.lang.utils.collect.Maps;
@@ -27,6 +28,7 @@ import com.orion.ops.module.asset.entity.dto.UploadTaskExtraDTO;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskCreateRequest; import com.orion.ops.module.asset.entity.request.upload.UploadTaskCreateRequest;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskFileRequest; import com.orion.ops.module.asset.entity.request.upload.UploadTaskFileRequest;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskQueryRequest; import com.orion.ops.module.asset.entity.request.upload.UploadTaskQueryRequest;
import com.orion.ops.module.asset.entity.request.upload.UploadTaskRequest;
import com.orion.ops.module.asset.entity.vo.HostBaseVO; import com.orion.ops.module.asset.entity.vo.HostBaseVO;
import com.orion.ops.module.asset.entity.vo.UploadTaskCreateVO; import com.orion.ops.module.asset.entity.vo.UploadTaskCreateVO;
import com.orion.ops.module.asset.entity.vo.UploadTaskFileVO; import com.orion.ops.module.asset.entity.vo.UploadTaskFileVO;
@@ -110,7 +112,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
record.setUserId(user.getId()); record.setUserId(user.getId());
record.setUsername(user.getUsername()); record.setUsername(user.getUsername());
record.setDescription(Strings.def(record.getDescription(), () -> Strings.format(DEFAULT_DESC, Dates.current()))); record.setDescription(Strings.def(record.getDescription(), () -> Strings.format(DEFAULT_DESC, Dates.current())));
record.setStatus(UploadTaskStatusEnum.PREPARATION.name()); record.setStatus(UploadTaskStatusEnum.WAITING.name());
UploadTaskExtraDTO extra = UploadTaskExtraDTO.builder() UploadTaskExtraDTO extra = UploadTaskExtraDTO.builder()
.hostIdList(hostIdList) .hostIdList(hostIdList)
.hosts(hosts) .hosts(hosts)
@@ -231,7 +233,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
// 查询任务 // 查询任务
List<UploadTaskDO> records = uploadTaskDAO.selectBatchIds(idList); List<UploadTaskDO> records = uploadTaskDAO.selectBatchIds(idList);
// 取消任务 // 取消任务
this.checkCancelTask(records); this.checkCancelTask(records, UploadTaskStatusEnum.CANCELED);
// 删除任务 // 删除任务
int effect = uploadTaskDAO.deleteBatchIds(idList); int effect = uploadTaskDAO.deleteBatchIds(idList);
// 删除任务文件 // 删除任务文件
@@ -248,20 +250,24 @@ public class UploadTaskServiceImpl implements UploadTaskService {
UploadTaskDO record = uploadTaskDAO.selectById(id); UploadTaskDO record = uploadTaskDAO.selectById(id);
Valid.notNull(record, ErrorMessage.TASK_ABSENT); Valid.notNull(record, ErrorMessage.TASK_ABSENT);
// 检查状态 // 检查状态
Valid.eq(record.getStatus(), UploadTaskStatusEnum.PREPARATION.name(), ErrorMessage.ILLEGAL_STATUS); Valid.eq(record.getStatus(), UploadTaskStatusEnum.WAITING.name(), ErrorMessage.ILLEGAL_STATUS);
// 执行任务 // 执行任务
FileUploadTasks.start(id); FileUploadTasks.start(id);
} }
@Override @Override
public void cancelUploadTask(Long id) { public void cancelUploadTask(UploadTaskRequest request) {
// 查询任务 // 查询任务
UploadTaskDO record = uploadTaskDAO.selectById(id); UploadTaskDO record = uploadTaskDAO.selectById(request.getId());
Valid.notNull(record, ErrorMessage.TASK_ABSENT); Valid.notNull(record, ErrorMessage.TASK_ABSENT);
// 检查状态 // 检查状态
Valid.isTrue(UploadTaskStatusEnum.of(record.getStatus()).isCancelable(), ErrorMessage.ILLEGAL_STATUS); Valid.isTrue(UploadTaskStatusEnum.of(record.getStatus()).isCancelable(), ErrorMessage.ILLEGAL_STATUS);
// 取消任务 // 取消任务
this.checkCancelTask(Lists.singleton(record)); if (Booleans.isTrue(request.getFailed())) {
this.checkCancelTask(Lists.singleton(record), UploadTaskStatusEnum.FAILED);
} else {
this.checkCancelTask(Lists.singleton(record), UploadTaskStatusEnum.CANCELED);
}
} }
@Override @Override
@@ -318,8 +324,9 @@ public class UploadTaskServiceImpl implements UploadTaskService {
* 检查需要取消的任务 * 检查需要取消的任务
* *
* @param records records * @param records records
* @param status status
*/ */
private void checkCancelTask(List<UploadTaskDO> records) { private void checkCancelTask(List<UploadTaskDO> records, UploadTaskStatusEnum status) {
// 需要取消的记录 // 需要取消的记录
List<UploadTaskDO> cancelableRecords = records.stream() List<UploadTaskDO> cancelableRecords = records.stream()
.filter(s -> UploadTaskStatusEnum.of(s.getStatus()).isCancelable()) .filter(s -> UploadTaskStatusEnum.of(s.getStatus()).isCancelable())
@@ -341,7 +348,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
// 更新状态 // 更新状态
if (!updateIdList.isEmpty()) { if (!updateIdList.isEmpty()) {
UploadTaskDO update = new UploadTaskDO(); UploadTaskDO update = new UploadTaskDO();
update.setStatus(UploadTaskStatusEnum.CANCELED.name()); update.setStatus(status.name());
update.setEndTime(new Date()); update.setEndTime(new Date());
// 更新 // 更新
uploadTaskDAO.update(update, Conditions.in(UploadTaskDO::getId, updateIdList)); uploadTaskDAO.update(update, Conditions.in(UploadTaskDO::getId, updateIdList));

View File

@@ -53,6 +53,9 @@ public class FileUploadMessageDispatcher extends AbstractWebSocketHandler {
} else if (FileUploadOperatorType.FINISH.equals(type)) { } else if (FileUploadOperatorType.FINISH.equals(type)) {
// 上传完成 // 上传完成
handler.finish(); handler.finish();
} else if (FileUploadOperatorType.ERROR.equals(type)) {
// 上传失败
handler.error();
} }
} }

View File

@@ -24,6 +24,11 @@ public enum FileUploadOperatorType {
*/ */
FINISH("finish"), FINISH("finish"),
/**
* 上传失败
*/
ERROR("error"),
; ;
private final String type; private final String type;

View File

@@ -106,6 +106,18 @@ public class FileUploadHandler implements IFileUploadHandler {
this.send(resp); this.send(resp);
} }
@Override
public void error() {
// 释放资源
this.close();
// 返回上传路径
FileUploadResponse resp = FileUploadResponse.builder()
.type(FileUploadReceiverType.ERROR.getType())
.fileId(this.fileId)
.build();
this.send(resp);
}
@Override @Override
public void close() { public void close() {
if (closed) { if (closed) {

View File

@@ -30,4 +30,9 @@ public interface IFileUploadHandler extends SafeCloseable {
*/ */
void finish(); void finish();
/**
* 上传失败
*/
void error();
} }

View File

@@ -30,13 +30,6 @@ export interface UploadTaskCreateResponse {
token: string; token: string;
} }
/**
* 上传任务请求
*/
export interface UploadTaskRequest {
id?: number;
}
/** /**
* 上传任务查询请求 * 上传任务查询请求
*/ */
@@ -92,15 +85,15 @@ export function createUploadTask(request: UploadTaskCreateRequest) {
/** /**
* 创建上传任务 * 创建上传任务
*/ */
export function startUploadTask(request: UploadTaskRequest) { export function startUploadTask(id: number) {
return axios.post('/asset/upload-task/start', request); return axios.post('/asset/upload-task/start', { id });
} }
/** /**
* 创建上传任务 * 创建上传任务
*/ */
export function cancelUploadTask(request: UploadTaskRequest) { export function cancelUploadTask(id: number, failed: boolean) {
return axios.post('/asset/upload-task/cancel', request); return axios.post('/asset/upload-task/cancel', { id, failed });
} }
/** /**

View File

@@ -4,5 +4,5 @@ import { createAppWebSocket } from '@/utils/http';
* 打开文件上传 websocket * 打开文件上传 websocket
*/ */
export const openFileUploadChannel = (uploadToken: string) => { export const openFileUploadChannel = (uploadToken: string) => {
return createAppWebSocket(`"/file/upload/${uploadToken}`); return createAppWebSocket(`/file/upload/${uploadToken}`);
}; };

View File

@@ -1,3 +1,9 @@
import type { IDisposable, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import type { SearchAddon } from 'xterm-addon-search';
import type { WebLinksAddon } from 'xterm-addon-web-links';
import type { WebglAddon } from 'xterm-addon-webgl';
// 执行类型 // 执行类型
export type ExecType = 'BATCH' | 'JOB'; export type ExecType = 'BATCH' | 'JOB';
@@ -37,3 +43,97 @@ export const execHostStatusKey = 'execHostStatus';
// 加载的字典值 // 加载的字典值
export const dictKeys = [execStatusKey, execHostStatusKey]; export const dictKeys = [execStatusKey, execHostStatusKey];
// appender 配置
export const LogAppenderOptions: ITerminalOptions & ITerminalInitOnlyOptions = {
theme: {
foreground: '#FFFFFF',
background: '#1C1C1C',
selectionBackground: '#444444',
},
cols: 30,
rows: 8,
rightClickSelectsWord: true,
disableStdin: true,
cursorStyle: 'bar',
cursorBlink: false,
fastScrollModifier: 'alt',
fontSize: 13,
lineHeight: 1.12,
convertEol: true,
};
// dom 引用
export interface LogDomRef {
id: number;
el: HTMLElement;
openSearch: () => {};
}
// appender 配置
export interface LogAppenderConf {
id: number;
el: HTMLElement;
openSearch: () => {};
terminal: Terminal;
addons: LogAddons;
}
// appender 插件
export interface LogAddons extends Record<string, IDisposable> {
fit: FitAddon;
webgl: WebglAddon;
search: SearchAddon;
weblink: WebLinksAddon;
}
// 执行日志 appender 定义
export interface ILogAppender {
// 初始化
init(refs: Array<LogDomRef>): Promise<void>;
// 设置当前元素
setCurrent(id: number): void;
// 打开搜索
openSearch(): void;
// 查找关键字
find(word: string, next: boolean, options: any): void;
// 聚焦
focus(): void;
// 自适应
fitAll(): void;
// 去顶部
toTop(): void;
// 去底部
toBottom(): void;
// 添加字体大小
addFontSize(addSize: number): void;
// 复制
copy(): void;
// 复制全部
copyAll(): void;
// 选中全部
selectAll(): void;
// 清空
clear(): void;
// 关闭 client
closeClient(): void;
// 关闭 view
closeView(): void;
// 关闭
close(): void;
}

View File

@@ -1,99 +0,0 @@
import type { IDisposable, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import type { SearchAddon } from 'xterm-addon-search';
import type { WebLinksAddon } from 'xterm-addon-web-links';
import type { WebglAddon } from 'xterm-addon-webgl';
// appender 配置
export const LogAppenderOptions: ITerminalOptions & ITerminalInitOnlyOptions = {
theme: {
foreground: '#FFFFFF',
background: '#1C1C1C',
selectionBackground: '#444444',
},
cols: 30,
rows: 8,
rightClickSelectsWord: true,
disableStdin: true,
cursorStyle: 'bar',
cursorBlink: false,
fastScrollModifier: 'alt',
fontSize: 13,
lineHeight: 1.12,
convertEol: true,
};
// dom 引用
export interface LogDomRef {
id: number;
el: HTMLElement;
openSearch: () => {};
}
// appender 配置
export interface LogAppenderConf {
id: number;
el: HTMLElement;
openSearch: () => {};
terminal: Terminal;
addons: LogAddons;
}
// appender 插件
export interface LogAddons extends Record<string, IDisposable> {
fit: FitAddon;
webgl: WebglAddon;
search: SearchAddon;
weblink: WebLinksAddon;
}
// 执行日志 appender 定义
export interface ILogAppender {
// 初始化
init(refs: Array<LogDomRef>): Promise<void>;
// 设置当前元素
setCurrent(id: number): void;
// 打开搜索
openSearch(): void;
// 查找关键字
find(word: string, next: boolean, options: any): void;
// 聚焦
focus(): void;
// 自适应
fitAll(): void;
// 去顶部
toTop(): void;
// 去底部
toBottom(): void;
// 添加字体大小
addFontSize(addSize: number): void;
// 复制
copy(): void;
// 复制全部
copyAll(): void;
// 选中全部
selectAll(): void;
// 清空
clear(): void;
// 关闭 client
closeClient(): void;
// 关闭 view
closeView(): void;
// 关闭
close(): void;
}

View File

@@ -25,8 +25,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ExecLogQueryResponse } from '@/api/exec/exec-log'; import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import type { ILogAppender } from './appender-const'; import type { ExecType, ILogAppender } from '../const';
import type { ExecType } from '../const';
import { onUnmounted, ref, nextTick, onMounted } from 'vue'; import { onUnmounted, ref, nextTick, onMounted } from 'vue';
import { getExecCommandLogStatus } from '@/api/exec/exec-command-log'; import { getExecCommandLogStatus } from '@/api/exec/exec-command-log';
import { getExecJobLogStatus } from '@/api/job/exec-job-log'; import { getExecJobLogStatus } from '@/api/job/exec-job-log';

View File

@@ -1,10 +1,9 @@
import type { ILogAppender, LogAddons, LogAppenderConf, LogDomRef } from './appender-const'; import type { ExecType, ILogAppender, LogAddons, LogAppenderConf, LogDomRef } from '../const';
import { LogAppenderOptions } from './appender-const';
import type { ExecType } from '../const';
import type { ExecLogTailRequest } from '@/api/exec/exec-log'; import type { ExecLogTailRequest } from '@/api/exec/exec-log';
import { openExecLogChannel } from '@/api/exec/exec-log'; import { openExecLogChannel } from '@/api/exec/exec-log';
import { getExecCommandLogTailToken } from '@/api/exec/exec-command-log'; import { getExecCommandLogTailToken } from '@/api/exec/exec-command-log';
import { getExecJobLogTailToken } from '@/api/job/exec-job-log'; import { getExecJobLogTailToken } from '@/api/job/exec-job-log';
import { LogAppenderOptions } from '../const';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { addEventListen, removeEventListen } from '@/utils/event'; import { addEventListen, removeEventListen } from '@/utils/event';

View File

@@ -167,8 +167,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ExecLogQueryResponse, ExecHostLogQueryResponse } from '@/api/exec/exec-log'; import type { ExecLogQueryResponse, ExecHostLogQueryResponse } from '@/api/exec/exec-log';
import type { ILogAppender } from './appender-const'; import type { ExecType, ILogAppender } from '../const';
import type { ExecType } from '../const';
import { ref } from 'vue'; import { ref } from 'vue';
import { execHostStatus, execHostStatusKey } from '../const'; import { execHostStatus, execHostStatusKey } from '../const';
import { formatDuration } from '@/utils'; import { formatDuration } from '@/utils';

View File

@@ -20,7 +20,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VNodeRef } from 'vue'; import type { VNodeRef } from 'vue';
import type { LogDomRef, ILogAppender } from './appender-const'; import type { LogDomRef, ILogAppender } from '../const';
import type { ExecLogQueryResponse } from '@/api/exec/exec-log'; import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import type { ExecType } from '../const'; import type { ExecType } from '../const';
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';

View File

@@ -0,0 +1,44 @@
// 上传操作类型
export const UploadOperatorType = {
// 开始上传
START: 'start',
// 上传完成
FINISH: 'finish',
// 上传失败
ERROR: 'error',
};
// 上传响应类型
export const UploadReceiverType = {
// 请求下一块数据
NEXT: 'next',
// 上传完成
FINISH: 'finish',
// 上传失败
ERROR: 'error',
};
// 请求消息体
export interface RequestMessageBody {
type: string;
fileId: string;
}
// 响应消息体
export interface ResponseMessageBody {
type: string;
fileId: string;
path: string;
}
// 文件上传器 定义
export interface IFileUploader {
// 开始
start(): Promise<void>;
// 设置 hook
setHook(hook: Function): void;
// 关闭
close(): void;
}

View File

@@ -0,0 +1,146 @@
import type { IFileUploader, ResponseMessageBody } from './const';
import type { FileItem } from '@arco-design/web-vue';
import { openFileUploadChannel } from '@/api/system/upload';
import { UploadOperatorType, UploadReceiverType } from './const';
// 512 KB
export const PART_SIZE = 512 * 1024;
// 文件上传器 实现
export default class FileUploader implements IFileUploader {
private readonly token: string;
private readonly fileList: Array<FileItem>;
private currentIndex: number;
private currentFileItem: FileItem;
private currentFile: File;
private currentFileSize: number;
private currentPart: number;
private totalPart: number;
private client?: WebSocket;
private hook?: Function;
constructor(token: string, fileList: Array<FileItem>) {
this.token = token;
this.fileList = fileList;
this.currentIndex = 0;
this.currentFileItem = undefined as unknown as FileItem;
this.currentFile = undefined as unknown as File;
this.currentFileSize = 0;
this.currentPart = 0;
this.totalPart = 0;
}
// 开始
async start(): Promise<void> {
try {
// 打开管道
this.client = await openFileUploadChannel(this.token);
this.client.onclose = () => {
this.hook && this.hook();
};
} catch (e) {
// 修改状态
this.fileList.forEach(s => s.status = 'error');
throw e;
}
// 处理消息
this.client.onmessage = this.resolveMessage.bind(this);
// 打开后自动上传下一个文件
this.uploadNextFile();
}
// 上传下一个文件
private uploadNextFile() {
// 获取文件
if (this.fileList.length > this.currentIndex) {
this.currentFileItem = this.fileList[this.currentIndex++];
this.currentFile = this.currentFileItem.file as File;
this.currentFileSize = 0;
this.currentPart = 0;
this.totalPart = Math.ceil(this.currentFile.size / PART_SIZE);
// 开始上传 发送开始上传信息
this.client?.send(JSON.stringify({
type: UploadOperatorType.START,
fileId: this.currentFileItem.uid,
}));
} else {
// 无文件关闭会话
this.client?.close();
}
}
// 上传下一块数据
private async uploadNextPart() {
try {
if (this.currentPart < this.totalPart) {
// 有下一个分片则上传
const start = this.currentPart++ * PART_SIZE;
const end = Math.min(this.currentFile.size, start + PART_SIZE);
const chunk = this.currentFile.slice(start, end);
const reader = new FileReader();
// 读取数据
const arrayBuffer = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(chunk);
});
// 发送数据
this.client?.send(arrayBuffer as ArrayBuffer);
// 计算进度
this.currentFileSize += (end - start);
this.currentFileItem.percent = (this.currentFileSize / this.currentFile.size);
} else {
// 没有下一个分片则发送完成
this.client?.send(JSON.stringify({
type: UploadOperatorType.FINISH,
fileId: this.currentFileItem.uid,
}));
}
} catch (e) {
// 读取文件失败
this.client?.send(JSON.stringify({
type: UploadOperatorType.ERROR,
fileId: this.currentFileItem.uid,
}));
}
}
// 接收消息
private async resolveMessage(message: MessageEvent) {
// 文本消息
const data = JSON.parse(message.data) as ResponseMessageBody;
if (data.type === UploadReceiverType.NEXT) {
// 上传下一块数据
await this.uploadNextPart();
} else if (data.type === UploadReceiverType.FINISH) {
this.currentFileItem.status = 'done';
// 上传下一个文件
this.uploadNextFile();
} else if (data.type === UploadReceiverType.ERROR) {
this.currentFileItem.status = 'error';
// 上传下一个文件
this.uploadNextFile();
}
}
// 设置 hook
setHook(hook: Function): void {
this.hook = hook;
}
// 关闭
close(): void {
this.client?.close();
}
}

View File

@@ -4,7 +4,7 @@
<div class="panel-header"> <div class="panel-header">
<h3>文件列表</h3> <h3>文件列表</h3>
<!-- 操作 --> <!-- 操作 -->
<a-button-group size="small"> <a-button-group size="small" :disabled="startStatus">
<a-button @click="clear">清空</a-button> <a-button @click="clear">清空</a-button>
<!-- 选择文件 --> <!-- 选择文件 -->
<a-upload v-model:file-list="fileList" <a-upload v-model:file-list="fileList"
@@ -29,11 +29,12 @@
<!-- 文件列表 --> <!-- 文件列表 -->
<div v-if="fileList.length" class="files-container"> <div v-if="fileList.length" class="files-container">
<a-upload class="files-wrapper" <a-upload class="files-wrapper"
:class="['waiting-files-wrapper']" :class="[ startStatus ? 'uploading-files-wrapper' : 'waiting-files-wrapper' ]"
v-model:file-list="fileList" v-model:file-list="fileList"
:auto-upload="false" :auto-upload="false"
:show-cancel-button="false" :show-cancel-button="false"
:show-remove-button="true" :show-retry-button="false"
:show-remove-button="!startStatus"
:show-file-list="true"> :show-file-list="true">
<template #upload-button /> <template #upload-button />
<template #file-name="{ fileItem }"> <template #file-name="{ fileItem }">
@@ -71,15 +72,61 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { FileItem } from '@arco-design/web-vue'; import type { FileItem } from '@arco-design/web-vue';
import type { UploadTaskFileCreateRequest } from '@/api/exec/upload-task';
import type { IFileUploader } from '@/components/system/uploader/const';
import { ref } from 'vue'; import { ref } from 'vue';
import { getFileSize } from '@/utils/file'; import { getFileSize } from '@/utils/file';
import FileUploader from '@/components/system/uploader/file-uploader';
const emits = defineEmits(['end', 'error']);
const startStatus = ref(false);
const fileList = ref<FileItem[]>([]); const fileList = ref<FileItem[]>([]);
const uploader = ref<IFileUploader>();
// 获取上传的文件
const getFiles = (): Array<UploadTaskFileCreateRequest> => {
return fileList.value
.map(s => {
return {
fileId: s.uid,
filePath: s.file?.webkitRelativePath || s.file?.name,
fileSize: s.file?.size,
};
});
};
// 开始上传
const startUpload = async (token: string) => {
// 修改状态
startStatus.value = true;
fileList.value.forEach(s => s.status = 'uploading');
// 开始上传
try {
uploader.value = new FileUploader(token, fileList.value);
uploader.value?.setHook(() => {
emits('end');
});
await uploader.value?.start();
} catch (e) {
emits('error');
}
};
// 清空 // 清空
const clear = () => { const clear = () => {
fileList.value = [];
startStatus.value = false;
}; };
// 关闭
const close = () => {
startStatus.value = false;
uploader.value?.close();
};
defineExpose({ getFiles, startUpload, close });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@@ -127,11 +174,22 @@
} }
:deep(.arco-upload-list) { :deep(.arco-upload-list) {
max-height: 100%;
padding: 0; padding: 0;
max-height: 100%;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
: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) { :deep(.arco-upload-list-item-name-text) {
width: 100%; width: 100%;
} }

View File

@@ -5,8 +5,24 @@
<h3>批量上传</h3> <h3>批量上传</h3>
<!-- 操作 --> <!-- 操作 -->
<a-button-group size="small"> <a-button-group size="small">
<a-button>重置</a-button> <!-- 重置 -->
<a-button type="primary">上传</a-button> <a-button v-if="status.value !== UploadTaskStatus.REQUESTING.value"
@click="emits('clear')">
重置
</a-button>
<!-- 取消上传 -->
<a-button v-if="status.value === UploadTaskStatus.REQUESTING.value
|| status.value === UploadTaskStatus.UPLOADING.value"
@click="emits('cancel')">
取消上传
</a-button>
<!-- 开始上传 -->
<a-button v-if="status.value !== UploadTaskStatus.REQUESTING.value
&& status.value !== UploadTaskStatus.UPLOADING.value"
type="primary"
@click="submit">
开始上传
</a-button>
</a-button-group> </a-button-group>
</div> </div>
<!-- 表单 --> <!-- 表单 -->
@@ -35,7 +51,7 @@
<span class="usn" v-if="formModel.hostIdList?.length"> <span class="usn" v-if="formModel.hostIdList?.length">
已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机 已选择<span class="selected-host-count span-blue">{{ formModel.hostIdList?.length }}</span>台主机
</span> </span>
<span class="usn pointer span-blue" @click="openSelectHost"> <span class="usn pointer span-blue" @click="emits('openHost')">
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }} {{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
</span> </span>
</div> </div>
@@ -52,28 +68,27 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadTaskCreateRequest } from '@/api/exec/upload-task'; import type { UploadTaskCreateRequest } from '@/api/exec/upload-task';
import type { UploadTaskStatusType } from '../types/const';
import { ref } from 'vue'; import { ref } from 'vue';
import formRules from '../types/form.rules'; import formRules from '../types/form.rules';
import useLoading from '@/hooks/loading'; import { UploadTaskStatus } from '../types/const';
const defaultForm = (): UploadTaskCreateRequest => { const emits = defineEmits(['upload', 'openHost', 'cancel', 'clear']);
return { const props = defineProps<{
description: '', status: UploadTaskStatusType;
remotePath: '', formModel: UploadTaskCreateRequest;
hostIdList: [], }>();
files: []
};
};
const { loading, setLoading } = useLoading();
const formRef = ref<any>(); const formRef = ref<any>();
const formModel = ref<UploadTaskCreateRequest>({ ...defaultForm() });
const hostModal = ref<any>();
// 打开选择主机 // 提交表单
const openSelectHost = () => { const submit = async () => {
hostModal.value.open(formModel.value.hostIdList); // 验证参数
let error = await formRef.value.validate();
if (error) {
return false;
}
emits('upload');
}; };
</script> </script>

View File

@@ -1,14 +1,25 @@
<template> <template>
<div class="panel-container"> <a-spin class="panel-container full" :loading="loading">
<!-- 上传步骤 --> <!-- 上传步骤 -->
<batch-upload-step class="panel-item step-panel-container" <batch-upload-step class="panel-item step-panel-container"
:step="step" :status="status" />
:status="stepStatus" />
<!-- 上传表单 --> <!-- 上传表单 -->
<batch-upload-form class="panel-item form-panel-container" /> <batch-upload-form class="panel-item form-panel-container"
:form-model="formModel"
:status="status"
@upload="doCreateUploadTask"
@cancel="doCancelUploadTask"
@open-host="openHostModal"
@clear="clear" />
<!-- 上传文件 --> <!-- 上传文件 -->
<batch-upload-files class="panel-item files-panel-container" /> <batch-upload-files class="panel-item files-panel-container"
</div> ref="filesRef"
@end="uploadRequestEnd"
@error="uploadRequestError" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
@selected="setSelectedHost" />
</a-spin>
</template> </template>
<script lang="ts"> <script lang="ts">
@@ -18,14 +29,125 @@
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadTaskCreateRequest } from '@/api/exec/upload-task';
import type { UploadTaskStatusType } from '../types/const';
import { ref } from 'vue'; import { ref } from 'vue';
import { UploadStep, UploadStepStatus } from '../types/const'; import { UploadTaskStatus } from '../types/const';
import { cancelUploadTask, createUploadTask, startUploadTask } 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 BatchUploadStep from './batch-upload-step.vue';
import BatchUploadForm from './batch-upload-form.vue'; import BatchUploadForm from './batch-upload-form.vue';
import BatchUploadFiles from '@/views/exec/batch-upload/components/batch-upload-files.vue'; import BatchUploadFiles from './batch-upload-files.vue';
import AuthorizedHostModal from '@/components/asset/host/authorized-host-modal/index.vue';
const step = ref(UploadStep.PREPARATION); const defaultForm = (): UploadTaskCreateRequest => {
const stepStatus = ref(UploadStepStatus.PROCESS); return {
description: '',
remotePath: '/root/batch',
hostIdList: [1],
files: []
};
};
const { loading, setLoading } = useLoading();
const taskId = ref();
const formModel = ref<UploadTaskCreateRequest>({ ...defaultForm() });
const status = ref<UploadTaskStatusType>(UploadTaskStatus.WAITING);
const filesRef = ref();
const hostModal = ref<any>();
// TODO pullstatus
// TODO 测试 error 情况
// 设置选中主机
const setSelectedHost = (hosts: Array<number>) => {
formModel.value.hostIdList = hosts;
};
// 创建上传任务
const doCreateUploadTask = async () => {
// 获取文件
const files = filesRef.value?.getFiles();
if (!files || !files.length) {
Message.error('请先选择需要上传的文件');
return;
}
// 创建任务
setLoading(true);
status.value = UploadTaskStatus.WAITING;
try {
formModel.value.files = files;
const { data } = await createUploadTask(formModel.value);
taskId.value = data.id;
status.value = UploadTaskStatus.REQUESTING;
// 上传文件
await filesRef.value.startUpload(data.token);
} catch (e) {
status.value = UploadTaskStatus.FAILED;
} finally {
setLoading(false);
}
};
// 取消上传任务
const doCancelUploadTask = async () => {
setLoading(true);
filesRef.value?.close();
try {
// 取消上传
await cancelUploadTask(taskId.value, false);
status.value = UploadTaskStatus.CANCELED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 上传请求结束
const uploadRequestEnd = async () => {
if (status.value.value !== UploadTaskStatus.REQUESTING.value) {
// 手动停止或者其他原因
return;
}
// 如果结束后还是请求中则代表请求完毕
setLoading(true);
try {
// 开始上传
await startUploadTask(taskId.value);
status.value = UploadTaskStatus.UPLOADING;
} catch (e) {
// 设置失败
await uploadRequestError();
} finally {
setLoading(false);
}
};
// 上传请求失败
const uploadRequestError = async () => {
setLoading(true);
try {
// 开始上传
await cancelUploadTask(taskId.value, true);
status.value = UploadTaskStatus.FAILED;
} catch (e) {
} finally {
setLoading(false);
}
};
// 打开主机模态框
const openHostModal = () => {
hostModal.value.open(formModel.value.hostIdList);
};
// 清空
const clear = () => {
status.value = UploadTaskStatus.WAITING;
formModel.value = { ...defaultForm() };
};
</script> </script>

View File

@@ -1,11 +1,23 @@
<template> <template>
<div class="container"> <div class="container">
<a-steps :current="step" <a-steps :current="status.step"
:status="status as any" :status="status.status as any"
direction="vertical"> direction="vertical">
<a-step description="创建上传任务">创建任务</a-step> <a-step description="创建上传任务">创建任务</a-step>
<a-step description="将文件上传到临时分区">上传文件</a-step> <a-step>
<a-step description="将文件分发到目标服务器">分发文件</a-step> 上传文件
<template #description>
<span>将文件上传到临时分区</span><br>
<span class="span-red">在此期间请不要关闭页面</span>
</template>
</a-step>
<a-step>
分发文件
<template #description>
<span>将文件分发到目标服务器</span><br>
<span>可以关闭页面</span>
</template>
</a-step>
<a-step description="上传完成并释放资源">上传完成</a-step> <a-step description="上传完成并释放资源">上传完成</a-step>
</a-steps> </a-steps>
</div> </div>
@@ -18,13 +30,16 @@
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadTaskStatusType } from '../types/const';
defineProps<{ defineProps<{
step: number; status: UploadTaskStatusType;
status: string;
}>(); }>();
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.container {
user-select: none;
}
</style> </style>

View File

@@ -1,35 +1,48 @@
// 上传任务状态定义
export interface UploadTaskStatusType {
value: string,
step: number,
status: string,
}
// 上传任务状态 // 上传任务状态
export const UploadTaskStatus = { export const UploadTaskStatus = {
// 准备 // 等待
PREPARATION: 'PREPARATION', WAITING: {
// 上传中 value: 'WAITING',
UPLOADING: 'UPLOADING', step: 1,
// 已完成 status: 'process',
FINISHED: 'FINISHED', },
// 已取消
CANCELED: 'CANCELED',
};
// 上传步骤
export const UploadStep = {
// 准备中
PREPARATION: 1,
// 请求中 // 请求中
REQUESTING: 2, REQUESTING: {
// 分发中 value: 'REQUESTING',
UPLOADING: 3, step: 2,
status: 'process',
},
// 上传中
UPLOADING: {
value: 'UPLOADING',
step: 3,
status: 'process',
},
// 已完成 // 已完成
FINISHED: 4, FINISHED: {
}; value: 'FINISHED',
step: 4,
// 上传步骤状态 status: 'finish',
export const UploadStepStatus = { },
// 处理中 // 已失败
PROCESS: 'process', FAILED: {
// 上传完成 value: 'FAILED',
FINISH: 'finish', step: 4,
// 上传失败 status: 'error',
ERROR: 'error', },
// 已取消
CANCELED: {
value: 'CANCELED',
step: 4,
status: 'error',
},
}; };
// 上传任务状态 字典项 // 上传任务状态 字典项

View File

@@ -18,14 +18,8 @@ export const remotePath = [{
message: '上传路径长度不能大于1024位' message: '上传路径长度不能大于1024位'
}] as FieldRule[]; }] as FieldRule[];
export const files = [{
required: true,
message: '请选择文件'
}] as FieldRule[];
export default { export default {
description, description,
hostIdList, hostIdList,
remotePath, remotePath,
files,
} as Record<string, FieldRule | FieldRule[]>; } as Record<string, FieldRule | FieldRule[]>;

View File

@@ -109,7 +109,7 @@
return true; return true;
} }
// 添加到上传列表 // 添加到上传列表
const files = fileList.value.map(s => s.file); const files = fileList.value.map(s => s.file as File);
transferManager.addUpload(hostId.value, parentPath.value, files); transferManager.addUpload(hostId.value, parentPath.value, files);
Message.success('已开始上传, 点击右侧传输列表查看进度'); Message.success('已开始上传, 点击右侧传输列表查看进度');
// 清空 // 清空
@@ -158,9 +158,10 @@
} }
:deep(.arco-upload-list) { :deep(.arco-upload-list) {
max-height: calc(100vh - 386px);
overflow-y: auto;
padding: 0 12px 0 0; padding: 0 12px 0 0;
max-height: calc(100vh - 386px);
overflow-x: hidden;
overflow-y: auto;
} }
:deep(.arco-upload-list-item-name) { :deep(.arco-upload-list-item-name) {