🔨 批量上传.
This commit is contained in:
@@ -55,8 +55,8 @@ public class CodeGenerators {
|
||||
.enableRowSelection()
|
||||
.dict("uploadTaskStatus", "status")
|
||||
.comment("上传任务状态")
|
||||
.fields("PREPARATION", "UPLOADING", "FINISHED", "CANCELED")
|
||||
.labels("准备中", "上传中", "已完成", "已取消")
|
||||
.fields("WAITING", "UPLOADING", "FINISHED", "FAILED", "CANCELED")
|
||||
.labels("等待中", "上传中", "已完成", "已失败", "已取消")
|
||||
.valueUseFields()
|
||||
.build(),
|
||||
Template.create("upload_task_file", "上传任务文件", "upload")
|
||||
@@ -65,8 +65,8 @@ public class CodeGenerators {
|
||||
.enableRowSelection()
|
||||
.dict("uploadTaskFileStatus", "status")
|
||||
.comment("上传任务文件状态")
|
||||
.fields("WAITING", "UPLOADING", "FINISHED", "CANCELED")
|
||||
.labels("等待中", "上传中", "已完成", "已取消")
|
||||
.fields("WAITING", "UPLOADING", "FINISHED", "FAILED", "CANCELED")
|
||||
.labels("等待中", "上传中", "已完成", "已失败", "已取消")
|
||||
.valueUseFields()
|
||||
.build(),
|
||||
};
|
||||
|
||||
@@ -40,6 +40,11 @@ import java.util.List;
|
||||
@SuppressWarnings({"ELValidationInJSP", "SpringElInspection"})
|
||||
public class UploadTaskController {
|
||||
|
||||
// todo create 返回 host, STATUS
|
||||
// 修改状态元数据
|
||||
// 上船前检查size, size不对则直接cancel
|
||||
// cancel 需要设置子元素为 cancel
|
||||
|
||||
@Resource
|
||||
private UploadTaskService uploadTaskService;
|
||||
|
||||
@@ -64,7 +69,7 @@ public class UploadTaskController {
|
||||
@Operation(summary = "取消上传")
|
||||
@PreAuthorize("@ss.hasPermission('asset:upload-task:upload')")
|
||||
public Boolean cancelUploadTask(@Validated @RequestBody UploadTaskRequest request) {
|
||||
uploadTaskService.cancelUploadTask(request.getId());
|
||||
uploadTaskService.cancelUploadTask(request);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,7 @@ public class UploadTaskRequest implements Serializable {
|
||||
@Schema(description = "id")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "是否失败")
|
||||
private Boolean failed;
|
||||
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import lombok.Getter;
|
||||
public enum UploadTaskStatusEnum {
|
||||
|
||||
/**
|
||||
* 准备中
|
||||
* 等待中
|
||||
*/
|
||||
PREPARATION(true),
|
||||
WAITING(true),
|
||||
|
||||
/**
|
||||
* 上传中
|
||||
@@ -29,6 +29,11 @@ public enum UploadTaskStatusEnum {
|
||||
*/
|
||||
FINISHED(false),
|
||||
|
||||
/**
|
||||
* 已失败
|
||||
*/
|
||||
FAILED(false),
|
||||
|
||||
/**
|
||||
* 已取消
|
||||
*/
|
||||
|
||||
@@ -68,7 +68,7 @@ public class FileUploadTask implements IFileUploadTask {
|
||||
return;
|
||||
}
|
||||
// 检查任务状态 非准备中则取消执行
|
||||
if (!UploadTaskStatusEnum.PREPARATION.name().equals(record.getStatus())) {
|
||||
if (!UploadTaskStatusEnum.WAITING.name().equals(record.getStatus())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.orion.ops.module.asset.service;
|
||||
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.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.UploadTaskVO;
|
||||
|
||||
@@ -85,9 +86,9 @@ public interface UploadTaskService {
|
||||
/**
|
||||
* 取消上传
|
||||
*
|
||||
* @param id id
|
||||
* @param request request
|
||||
*/
|
||||
void cancelUploadTask(Long id);
|
||||
void cancelUploadTask(UploadTaskRequest request);
|
||||
|
||||
/**
|
||||
* 删除上传交换区的文件
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.orion.lang.define.wrapper.DataGrid;
|
||||
import com.orion.lang.utils.Arrays1;
|
||||
import com.orion.lang.utils.Booleans;
|
||||
import com.orion.lang.utils.Strings;
|
||||
import com.orion.lang.utils.collect.Lists;
|
||||
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.UploadTaskFileRequest;
|
||||
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.UploadTaskCreateVO;
|
||||
import com.orion.ops.module.asset.entity.vo.UploadTaskFileVO;
|
||||
@@ -110,7 +112,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
|
||||
record.setUserId(user.getId());
|
||||
record.setUsername(user.getUsername());
|
||||
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()
|
||||
.hostIdList(hostIdList)
|
||||
.hosts(hosts)
|
||||
@@ -231,7 +233,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
|
||||
// 查询任务
|
||||
List<UploadTaskDO> records = uploadTaskDAO.selectBatchIds(idList);
|
||||
// 取消任务
|
||||
this.checkCancelTask(records);
|
||||
this.checkCancelTask(records, UploadTaskStatusEnum.CANCELED);
|
||||
// 删除任务
|
||||
int effect = uploadTaskDAO.deleteBatchIds(idList);
|
||||
// 删除任务文件
|
||||
@@ -248,20 +250,24 @@ public class UploadTaskServiceImpl implements UploadTaskService {
|
||||
UploadTaskDO record = uploadTaskDAO.selectById(id);
|
||||
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);
|
||||
}
|
||||
|
||||
@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.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
|
||||
@@ -318,8 +324,9 @@ public class UploadTaskServiceImpl implements UploadTaskService {
|
||||
* 检查需要取消的任务
|
||||
*
|
||||
* @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()
|
||||
.filter(s -> UploadTaskStatusEnum.of(s.getStatus()).isCancelable())
|
||||
@@ -341,7 +348,7 @@ public class UploadTaskServiceImpl implements UploadTaskService {
|
||||
// 更新状态
|
||||
if (!updateIdList.isEmpty()) {
|
||||
UploadTaskDO update = new UploadTaskDO();
|
||||
update.setStatus(UploadTaskStatusEnum.CANCELED.name());
|
||||
update.setStatus(status.name());
|
||||
update.setEndTime(new Date());
|
||||
// 更新
|
||||
uploadTaskDAO.update(update, Conditions.in(UploadTaskDO::getId, updateIdList));
|
||||
|
||||
@@ -53,6 +53,9 @@ public class FileUploadMessageDispatcher extends AbstractWebSocketHandler {
|
||||
} else if (FileUploadOperatorType.FINISH.equals(type)) {
|
||||
// 上传完成
|
||||
handler.finish();
|
||||
} else if (FileUploadOperatorType.ERROR.equals(type)) {
|
||||
// 上传失败
|
||||
handler.error();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ public enum FileUploadOperatorType {
|
||||
*/
|
||||
FINISH("finish"),
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
ERROR("error"),
|
||||
|
||||
;
|
||||
|
||||
private final String type;
|
||||
|
||||
@@ -106,6 +106,18 @@ public class FileUploadHandler implements IFileUploadHandler {
|
||||
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
|
||||
public void close() {
|
||||
if (closed) {
|
||||
|
||||
@@ -30,4 +30,9 @@ public interface IFileUploadHandler extends SafeCloseable {
|
||||
*/
|
||||
void finish();
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
void error();
|
||||
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ export interface UploadTaskCreateResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传任务请求
|
||||
*/
|
||||
export interface UploadTaskRequest {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传任务查询请求
|
||||
*/
|
||||
@@ -92,15 +85,15 @@ export function createUploadTask(request: UploadTaskCreateRequest) {
|
||||
/**
|
||||
* 创建上传任务
|
||||
*/
|
||||
export function startUploadTask(request: UploadTaskRequest) {
|
||||
return axios.post('/asset/upload-task/start', request);
|
||||
export function startUploadTask(id: number) {
|
||||
return axios.post('/asset/upload-task/start', { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传任务
|
||||
*/
|
||||
export function cancelUploadTask(request: UploadTaskRequest) {
|
||||
return axios.post('/asset/upload-task/cancel', request);
|
||||
export function cancelUploadTask(id: number, failed: boolean) {
|
||||
return axios.post('/asset/upload-task/cancel', { id, failed });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,5 +4,5 @@ import { createAppWebSocket } from '@/utils/http';
|
||||
* 打开文件上传 websocket
|
||||
*/
|
||||
export const openFileUploadChannel = (uploadToken: string) => {
|
||||
return createAppWebSocket(`"/file/upload/${uploadToken}`);
|
||||
return createAppWebSocket(`/file/upload/${uploadToken}`);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -37,3 +43,97 @@ export const execHostStatusKey = 'execHostStatus';
|
||||
|
||||
// 加载的字典值
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -25,8 +25,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
|
||||
import type { ILogAppender } from './appender-const';
|
||||
import type { ExecType } from '../const';
|
||||
import type { ExecType, ILogAppender } from '../const';
|
||||
import { onUnmounted, ref, nextTick, onMounted } from 'vue';
|
||||
import { getExecCommandLogStatus } from '@/api/exec/exec-command-log';
|
||||
import { getExecJobLogStatus } from '@/api/job/exec-job-log';
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ILogAppender, LogAddons, LogAppenderConf, LogDomRef } from './appender-const';
|
||||
import { LogAppenderOptions } from './appender-const';
|
||||
import type { ExecType } from '../const';
|
||||
import type { ExecType, ILogAppender, LogAddons, LogAppenderConf, LogDomRef } from '../const';
|
||||
import type { ExecLogTailRequest } from '@/api/exec/exec-log';
|
||||
import { openExecLogChannel } from '@/api/exec/exec-log';
|
||||
import { getExecCommandLogTailToken } from '@/api/exec/exec-command-log';
|
||||
import { getExecJobLogTailToken } from '@/api/job/exec-job-log';
|
||||
import { LogAppenderOptions } from '../const';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { addEventListen, removeEventListen } from '@/utils/event';
|
||||
|
||||
@@ -167,8 +167,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ExecLogQueryResponse, ExecHostLogQueryResponse } from '@/api/exec/exec-log';
|
||||
import type { ILogAppender } from './appender-const';
|
||||
import type { ExecType } from '../const';
|
||||
import type { ExecType, ILogAppender } from '../const';
|
||||
import { ref } from 'vue';
|
||||
import { execHostStatus, execHostStatusKey } from '../const';
|
||||
import { formatDuration } from '@/utils';
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { ExecType } from '../const';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
44
orion-ops-ui/src/components/system/uploader/const.ts
Normal file
44
orion-ops-ui/src/components/system/uploader/const.ts
Normal 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;
|
||||
}
|
||||
146
orion-ops-ui/src/components/system/uploader/file-uploader.ts
Normal file
146
orion-ops-ui/src/components/system/uploader/file-uploader.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="panel-header">
|
||||
<h3>文件列表</h3>
|
||||
<!-- 操作 -->
|
||||
<a-button-group size="small">
|
||||
<a-button-group size="small" :disabled="startStatus">
|
||||
<a-button @click="clear">清空</a-button>
|
||||
<!-- 选择文件 -->
|
||||
<a-upload v-model:file-list="fileList"
|
||||
@@ -29,11 +29,12 @@
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="fileList.length" class="files-container">
|
||||
<a-upload class="files-wrapper"
|
||||
:class="['waiting-files-wrapper']"
|
||||
:class="[ startStatus ? 'uploading-files-wrapper' : 'waiting-files-wrapper' ]"
|
||||
v-model:file-list="fileList"
|
||||
:auto-upload="false"
|
||||
:show-cancel-button="false"
|
||||
:show-remove-button="true"
|
||||
:show-retry-button="false"
|
||||
:show-remove-button="!startStatus"
|
||||
:show-file-list="true">
|
||||
<template #upload-button />
|
||||
<template #file-name="{ fileItem }">
|
||||
@@ -71,15 +72,61 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { 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 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 = () => {
|
||||
fileList.value = [];
|
||||
startStatus.value = false;
|
||||
};
|
||||
|
||||
// 关闭
|
||||
const close = () => {
|
||||
startStatus.value = false;
|
||||
uploader.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ getFiles, startUpload, close });
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@@ -127,11 +174,22 @@
|
||||
}
|
||||
|
||||
:deep(.arco-upload-list) {
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
max-height: 100%;
|
||||
overflow-x: hidden;
|
||||
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) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,24 @@
|
||||
<h3>批量上传</h3>
|
||||
<!-- 操作 -->
|
||||
<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>
|
||||
</div>
|
||||
<!-- 表单 -->
|
||||
@@ -35,7 +51,7 @@
|
||||
<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">
|
||||
<span class="usn pointer span-blue" @click="emits('openHost')">
|
||||
{{ formModel.hostIdList?.length ? '重新选择' : '选择主机' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -52,28 +68,27 @@
|
||||
|
||||
<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 useLoading from '@/hooks/loading';
|
||||
import { UploadTaskStatus } from '../types/const';
|
||||
|
||||
const defaultForm = (): UploadTaskCreateRequest => {
|
||||
return {
|
||||
description: '',
|
||||
remotePath: '',
|
||||
hostIdList: [],
|
||||
files: []
|
||||
};
|
||||
};
|
||||
|
||||
const { loading, setLoading } = useLoading();
|
||||
const emits = defineEmits(['upload', 'openHost', 'cancel', 'clear']);
|
||||
const props = defineProps<{
|
||||
status: UploadTaskStatusType;
|
||||
formModel: UploadTaskCreateRequest;
|
||||
}>();
|
||||
|
||||
const formRef = ref<any>();
|
||||
const formModel = ref<UploadTaskCreateRequest>({ ...defaultForm() });
|
||||
const hostModal = ref<any>();
|
||||
|
||||
// 打开选择主机
|
||||
const openSelectHost = () => {
|
||||
hostModal.value.open(formModel.value.hostIdList);
|
||||
// 提交表单
|
||||
const submit = async () => {
|
||||
// 验证参数
|
||||
let error = await formRef.value.validate();
|
||||
if (error) {
|
||||
return false;
|
||||
}
|
||||
emits('upload');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<template>
|
||||
<div class="panel-container">
|
||||
<a-spin class="panel-container full" :loading="loading">
|
||||
<!-- 上传步骤 -->
|
||||
<batch-upload-step class="panel-item step-panel-container"
|
||||
:step="step"
|
||||
:status="stepStatus" />
|
||||
:status="status" />
|
||||
<!-- 上传表单 -->
|
||||
<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" />
|
||||
</div>
|
||||
<batch-upload-files class="panel-item files-panel-container"
|
||||
ref="filesRef"
|
||||
@end="uploadRequestEnd"
|
||||
@error="uploadRequestError" />
|
||||
<!-- 主机模态框 -->
|
||||
<authorized-host-modal ref="hostModal"
|
||||
@selected="setSelectedHost" />
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -18,14 +29,125 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { UploadTaskCreateRequest } from '@/api/exec/upload-task';
|
||||
import type { UploadTaskStatusType } from '../types/const';
|
||||
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 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 stepStatus = ref(UploadStepStatus.PROCESS);
|
||||
const defaultForm = (): UploadTaskCreateRequest => {
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-steps :current="step"
|
||||
:status="status as any"
|
||||
<a-steps :current="status.step"
|
||||
:status="status.status as any"
|
||||
direction="vertical">
|
||||
<a-step description="创建上传任务">创建任务</a-step>
|
||||
<a-step description="将文件上传到临时分区">上传文件</a-step>
|
||||
<a-step description="将文件分发到目标服务器">分发文件</a-step>
|
||||
<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-steps>
|
||||
</div>
|
||||
@@ -18,13 +30,16 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { UploadTaskStatusType } from '../types/const';
|
||||
|
||||
defineProps<{
|
||||
step: number;
|
||||
status: string;
|
||||
status: UploadTaskStatusType;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
// 上传任务状态定义
|
||||
export interface UploadTaskStatusType {
|
||||
value: string,
|
||||
step: number,
|
||||
status: string,
|
||||
}
|
||||
|
||||
// 上传任务状态
|
||||
export const UploadTaskStatus = {
|
||||
// 准备中
|
||||
PREPARATION: 'PREPARATION',
|
||||
// 上传中
|
||||
UPLOADING: 'UPLOADING',
|
||||
// 已完成
|
||||
FINISHED: 'FINISHED',
|
||||
// 已取消
|
||||
CANCELED: 'CANCELED',
|
||||
};
|
||||
|
||||
// 上传步骤
|
||||
export const UploadStep = {
|
||||
// 准备中
|
||||
PREPARATION: 1,
|
||||
// 等待中
|
||||
WAITING: {
|
||||
value: 'WAITING',
|
||||
step: 1,
|
||||
status: 'process',
|
||||
},
|
||||
// 请求中
|
||||
REQUESTING: 2,
|
||||
// 分发中
|
||||
UPLOADING: 3,
|
||||
REQUESTING: {
|
||||
value: 'REQUESTING',
|
||||
step: 2,
|
||||
status: 'process',
|
||||
},
|
||||
// 上传中
|
||||
UPLOADING: {
|
||||
value: 'UPLOADING',
|
||||
step: 3,
|
||||
status: 'process',
|
||||
},
|
||||
// 已完成
|
||||
FINISHED: 4,
|
||||
};
|
||||
|
||||
// 上传步骤状态
|
||||
export const UploadStepStatus = {
|
||||
// 处理中
|
||||
PROCESS: 'process',
|
||||
// 上传完成
|
||||
FINISH: 'finish',
|
||||
// 上传失败
|
||||
ERROR: 'error',
|
||||
FINISHED: {
|
||||
value: 'FINISHED',
|
||||
step: 4,
|
||||
status: 'finish',
|
||||
},
|
||||
// 已失败
|
||||
FAILED: {
|
||||
value: 'FAILED',
|
||||
step: 4,
|
||||
status: 'error',
|
||||
},
|
||||
// 已取消
|
||||
CANCELED: {
|
||||
value: 'CANCELED',
|
||||
step: 4,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
// 上传任务状态 字典项
|
||||
|
||||
@@ -18,14 +18,8 @@ export const remotePath = [{
|
||||
message: '上传路径长度不能大于1024位'
|
||||
}] as FieldRule[];
|
||||
|
||||
export const files = [{
|
||||
required: true,
|
||||
message: '请选择文件'
|
||||
}] as FieldRule[];
|
||||
|
||||
export default {
|
||||
description,
|
||||
hostIdList,
|
||||
remotePath,
|
||||
files,
|
||||
} as Record<string, FieldRule | FieldRule[]>;
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
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);
|
||||
Message.success('已开始上传, 点击右侧传输列表查看进度');
|
||||
// 清空
|
||||
@@ -158,9 +158,10 @@
|
||||
}
|
||||
|
||||
:deep(.arco-upload-list) {
|
||||
max-height: calc(100vh - 386px);
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 0 0;
|
||||
max-height: calc(100vh - 386px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.arco-upload-list-item-name) {
|
||||
|
||||
Reference in New Issue
Block a user