diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4489bb538d..de9856197c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,9 @@ diff --git a/frontend/src/assets/style/arco-reset.less b/frontend/src/assets/style/arco-reset.less index 621c4a71ab..346ecc0f9e 100644 --- a/frontend/src/assets/style/arco-reset.less +++ b/frontend/src/assets/style/arco-reset.less @@ -675,3 +675,14 @@ .arco-trigger-arrow { border-bottom-right-radius: var(--border-radius-mini) !important; } + +/** 消息通知 **/ +.arco-notification-left { + padding-right: 8px; + .arco-notification-icon { + margin-top: 2px; + .arco-icon { + font-size: 20px; + } + } +} diff --git a/frontend/src/components/pure/ms-button/index.vue b/frontend/src/components/pure/ms-button/index.vue index cf5899b745..65664a38c1 100644 --- a/frontend/src/components/pure/ms-button/index.vue +++ b/frontend/src/components/pure/ms-button/index.vue @@ -41,6 +41,7 @@ color: rgb(var(--primary-5)); } .ms-button-icon { + padding: 4px; color: var(--color-text-4); &:hover { color: rgb(var(--primary-5)); @@ -52,19 +53,19 @@ } .ms-button--secondary { color: var(--color-text-2); - &:hover { + &:not(.ms-button-text):hover { background-color: var(--color-text-n8); } } .ms-button--primary { color: rgb(var(--primary-5)); - &:hover { + &:not(.ms-button-text):hover { background-color: rgb(var(--primary-9)); } } .ms-button--danger { color: rgb(var(--danger-6)); - &:hover { + &:not(.ms-button-text):hover { color: rgb(var(--danger-6)); background-color: rgb(var(--danger-1)); } diff --git a/frontend/src/components/pure/ms-upload/backstageMsg.vue b/frontend/src/components/pure/ms-upload/backstageMsg.vue new file mode 100644 index 0000000000..aa5505e81e --- /dev/null +++ b/frontend/src/components/pure/ms-upload/backstageMsg.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/pure/ms-upload/fileList.vue b/frontend/src/components/pure/ms-upload/fileList.vue index f06aeff5c6..8fd97359b8 100644 --- a/frontend/src/components/pure/ms-upload/fileList.vue +++ b/frontend/src/components/pure/ms-upload/fileList.vue @@ -2,9 +2,9 @@
{{ `${t('ms.upload.all')} (${innerFileList.length})` }} - {{ `${t('ms.upload.uploading')} (${waitingList.length})` }} - {{ `${t('ms.upload.success')} (${successList.length})` }} - {{ `${t('ms.upload.fail')} (${failList.length})` }} + {{ `${t('ms.upload.uploading')} (${totalWaitingFileList.length})` }} + {{ `${t('ms.upload.success')} (${totalSuccessFileList.length})` }} + {{ `${t('ms.upload.fail')} (${totalFailFileList.length})` }}
@@ -47,7 +47,7 @@ ; // 用于后台上传文件时,查看详情跳转的路由参数 handleDelete?: (item: MsFileItem) => void; handleReupload?: (item: MsFileItem) => void; }>(); @@ -119,6 +122,7 @@ (e: 'start'): void; }>(); + const asyncTaskStore = useAsyncTaskStore(); const { t } = useI18n(); const fileListTab = ref('all'); @@ -146,102 +150,59 @@ } ); - const waitingList = computed(() => { + const totalWaitingFileList = computed(() => { return innerFileList.value.filter( (e) => e.status && (e.status === UploadStatus.init || e.status === UploadStatus.uploading) ); }); - const successList = computed(() => { + const totalSuccessFileList = computed(() => { return innerFileList.value.filter((e) => e.status && e.status === UploadStatus.done); }); - const failList = computed(() => { + const totalFailFileList = computed(() => { return innerFileList.value.filter((e) => e.status && e.status === UploadStatus.error); }); const filterFileList = computed(() => { switch (fileListTab.value) { case 'waiting': - return waitingList.value; + return totalWaitingFileList.value; case 'success': - return successList.value; + return totalSuccessFileList.value; case 'error': - return failList.value; + return totalFailFileList.value; default: return innerFileList.value; } }); - const uploadQueue = ref([]); - const progress = ref(0); - let timer: any = null; - - /** - * 开始上传队列中的文件 - * @param fileItem 文件项 - */ - async function uploadFileFromQueue(fileItem?: MsFileItem) { - if (fileItem) { - fileItem.status = UploadStatus.uploading; // 设置文件状态为上传中 - } - if (timer === null) { - // 模拟上传进度 - timer = setInterval(() => { - if (progress.value < 50) { - // 进度在0-50%之间较快 - const randomIncrement = Math.floor(Math.random() * 10) + 1; // 随机增加 5-10 的百分比 - progress.value += randomIncrement; - } else if (progress.value < 100) { - // 进度在50%-100%之间较慢 - const randomIncrement = Math.floor(Math.random() * 10) + 1; // 随机增加 1-5 的百分比 - progress.value = Math.min(progress.value + randomIncrement, 99); - } else { - clearInterval(timer); - timer = null; - } - }, 100); // 定时器间隔为 100 毫秒 - } - try { - await new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 3000); - }); - if (fileItem?.file?.type.includes('jpeg')) { - throw new Error('上传失败'); - } - if (fileItem) { - fileItem.status = UploadStatus.done; - fileItem.uploadedTime = Date.now(); - } - } catch (error) { - console.log(error); - if (fileItem) { - fileItem.status = UploadStatus.error; - } - } finally { - // 上传完成/失败,重置进度和定时器 - progress.value = 0; - clearInterval(timer); - timer = null; - if (uploadQueue.value.length > 0) { - // 如果待上传队列中还有文件,继续上传 - uploadFileFromQueue(uploadQueue.value.shift()); - } else { - emit('finish'); - } - } - } - /** * 开始上传 */ function startUpload() { emit('start'); - // 正式开始上传任务之前,同步一次文件列表,取出所有状态为 init 的文件 - uploadQueue.value = innerFileList.value.filter((item) => item.status === UploadStatus.init); - uploadFileFromQueue(uploadQueue.value.shift()); + asyncTaskStore.startUpload(innerFileList.value, props.route, props.routeQuery); } + /** + * 后台上传 + */ + function backstageUpload() { + asyncTaskStore.uploadFileTask.isBackstageUpload = true; + if (asyncTaskStore.uploadFileTask.uploadQueue.length === 0) { + // 开启后台上传时,如果队列为空,则说明是直接触发后台上传,并不是先startUpload然后再进行后台上传,需要触发一下上传任务开启 + startUpload(); + } + } + + watch( + () => asyncTaskStore.uploadFileTask.finishedTime, + (val) => { + if (val) { + emit('finish'); + } + } + ); + const previewVisible = ref(false); const previewCurrent = ref(0); @@ -271,9 +232,9 @@ props.handleReupload(item); } else { item.status = UploadStatus.init; - if (uploadQueue.value.length > 0) { + if (asyncTaskStore.uploadFileTask.uploadQueue.length > 0) { // 此时队列中还有任务,则 push 入队列末尾 - uploadQueue.value.push(item); + asyncTaskStore.uploadFileTask.uploadQueue.push(item); } else { // 此时队列任务已清空 startUpload(); @@ -283,14 +244,15 @@ // 在组件销毁时清除定时器 onBeforeUnmount(() => { - if (timer !== null) { - clearInterval(timer); - timer = null; + if (asyncTaskStore.uploadFileTask.timer !== null) { + clearInterval(asyncTaskStore.uploadFileTask.timer); + asyncTaskStore.uploadFileTask.timer = null; } }); defineExpose({ startUpload, + backstageUpload, }); diff --git a/frontend/src/components/pure/ms-upload/locale/en-US.ts b/frontend/src/components/pure/ms-upload/locale/en-US.ts index da612084d2..f351cce989 100644 --- a/frontend/src/components/pure/ms-upload/locale/en-US.ts +++ b/frontend/src/components/pure/ms-upload/locale/en-US.ts @@ -7,6 +7,11 @@ export default { 'ms.upload.reUpload': 'Reupload', 'ms.upload.preview': 'Preview', 'ms.upload.uploadAt': 'Uploaded at', - 'ms.upload.fail': 'Upload failed', + 'ms.upload.fail': 'Failed', 'ms.upload.delete': 'Delete', + 'ms.upload.uploadFail': 'Upload failed', + 'ms.upload.all': 'All', + 'ms.upload.uploading': 'Waiting/Uploading', + 'ms.upload.success': 'Success', + 'ms.upload.detail': 'Detail', }; diff --git a/frontend/src/components/pure/ms-upload/locale/zh-CN.ts b/frontend/src/components/pure/ms-upload/locale/zh-CN.ts index e9ae8e5354..55fee8e23e 100644 --- a/frontend/src/components/pure/ms-upload/locale/zh-CN.ts +++ b/frontend/src/components/pure/ms-upload/locale/zh-CN.ts @@ -13,4 +13,5 @@ export default { 'ms.upload.uploading': '等待/上传中', 'ms.upload.success': '成功', 'ms.upload.fail': '失败', + 'ms.upload.detail': '查看详情', }; diff --git a/frontend/src/locale/en-US/sys.ts b/frontend/src/locale/en-US/sys.ts index fc969a1c96..5f53176342 100644 --- a/frontend/src/locale/en-US/sys.ts +++ b/frontend/src/locale/en-US/sys.ts @@ -26,4 +26,8 @@ export default { 'api.errMsg503': 'The service is unavailable, the server is temporarily overloaded or maintained!', 'api.errMsg504': 'Network timeout!', 'api.errMsg505': 'The http version does not support the request!', + // 异步任务提示 + 'asyncTask.uploadFileProgress': 'File upload progress {percent}; {done} successful, {fail} failed', + 'asyncTask.uploadFileSuccess': 'File upload completed: {done} successfully, {fail} failed', + 'asyncTask.uploadFileSuccessTitle': 'Upload completed', }; diff --git a/frontend/src/locale/zh-CN/sys.ts b/frontend/src/locale/zh-CN/sys.ts index f2cf80442f..7bfe25e8ee 100644 --- a/frontend/src/locale/zh-CN/sys.ts +++ b/frontend/src/locale/zh-CN/sys.ts @@ -16,7 +16,7 @@ export default { 'api.responseError': '接口返回错误,请重试', 'api.requestError': '请求错误', 'api.errMsg401': '用户没有权限(令牌、用户名、密码错误)!', - 'api.errMsg403': '用户得到授权,但是访问是被禁止的。!', + 'api.errMsg403': '用户得到授权,但是访问是被禁止的!', 'api.errMsg404': '网络请求错误,未找到该资源!', 'api.errMsg405': '网络请求错误,请求方法未允许!', 'api.errMsg408': '网络请求超时!', @@ -26,4 +26,8 @@ export default { 'api.errMsg503': '服务不可用,服务器暂时过载或维护!', 'api.errMsg504': '网络超时!', 'api.errMsg505': 'http版本不支持该请求!', + // 异步任务提示 + 'asyncTask.uploadFileProgress': '文件上传进度 {percent};成功 {done} 个,失败 {fail} 个', + 'asyncTask.uploadFileSuccess': '文件上传完成:成功 {done} 个,失败 {fail} 个', + 'asyncTask.uploadFileSuccessTitle': '上传完成', }; diff --git a/frontend/src/store/modules/app/asyncTask.ts b/frontend/src/store/modules/app/asyncTask.ts new file mode 100644 index 0000000000..4dcc02c88a --- /dev/null +++ b/frontend/src/store/modules/app/asyncTask.ts @@ -0,0 +1,201 @@ +import { h } from 'vue'; +import { defineStore } from 'pinia'; +import { Message, Notification } from '@arco-design/web-vue'; +import { UploadStatus } from '@/enums/uploadEnum'; +import { useI18n } from '@/hooks/useI18n'; +import BackstageMsg from '@/components/pure/ms-upload/backstageMsg.vue'; +import router from '@/router'; + +import type { AsyncTaskState } from './types'; +import type { MsFileItem } from '@/components/pure/ms-upload/types'; + +// 全局异步任务 store,用于一些后台运行或者耗时任务展示任务的进度提示,例如:文件上传的后台上传任务、耗时的导出操作等。注意:每次只能执行一个任务。TODO: 后续可以考虑支持多个任务 +const useAsyncTaskStore = defineStore('asyncTask', { + state: (): AsyncTaskState => ({ + uploadFileTask: { + // 上传文件的任务 + isBackstageUpload: false, // 是否后台上传,后台上传会展示全局提示类型的进度提示 + isHideMessage: false, // 后台上传时展示的消息提示在点击关闭后无需再弹 + fileList: [], // 文件总队列,包含已经上传的历史记录(不做持久化存储,刷新丢失) + uploadQueue: [], // 每次添加的上传队列,用于展示每次任务的进度使用 + eachTaskQueue: [], // 上传队列,每个文件上传完成后会从队列中移除,初始值为上传队列的副本 + singleProgress: 0, // 单个上传文件的上传进度,非总进度,是每个文件在上传时的模拟进度 + timer: null, // 上传进度定时器 + finishedTime: null, // 任务完成时间 + }, + }), + getters: { + // totalXXX 为文件总队列计算出的数量,包含历史记录 + totalWaitingFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.fileList.filter( + (e) => e.status && (e.status === UploadStatus.init || e.status === UploadStatus.uploading) + ); + }, + totalSuccessFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.fileList.filter((e) => e.status && e.status === UploadStatus.done); + }, + totalFailFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.fileList.filter((e) => e.status && e.status === UploadStatus.error); + }, + // 下面的是每次上传任务计算的数量 + eachWaitingFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.eachTaskQueue.filter( + (e) => e.status && (e.status === UploadStatus.init || e.status === UploadStatus.uploading) + ); + }, + eachSuccessFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.eachTaskQueue.filter((e) => e.status && e.status === UploadStatus.done); + }, + eachFailFileList: (state: AsyncTaskState) => { + return state.uploadFileTask.eachTaskQueue.filter((e) => e.status && e.status === UploadStatus.error); + }, + eachUploadTaskProgress(): number { + // 每次上传任务的总进度 + const { uploadFileTask } = this; + const { eachTaskQueue } = uploadFileTask; + const total = eachTaskQueue.length; + if (total === 0) { + return 0; + } + return Math.floor((this.totalSuccessFileList.length / total) * 100); + }, + }, + actions: { + beforeEachUpload(fileItem?: MsFileItem, route?: string, routeQuery?: Record) { + const { t } = useI18n(); + const { uploadFileTask } = this; + if (uploadFileTask.isBackstageUpload && !uploadFileTask.isHideMessage) { + // 开启了后台下载模式,展示全局的进度提示,不模拟进度条 + Message.info({ + id: 'asyncTaskUploadFile', + content: () => + h(BackstageMsg, { + content: t('asyncTask.uploadFileProgress', { + percent: `${uploadFileTask.eachTaskQueue.length - this.eachWaitingFileList.length} / ${ + uploadFileTask.eachTaskQueue.length + }`, + done: this.eachSuccessFileList.length, + fail: this.eachFailFileList.length, + }), + onGoDetail() { + router.push({ + name: route, + query: routeQuery, + }); + Message.clear(); + uploadFileTask.isBackstageUpload = false; + }, + }), + duration: 999999999, // 一直展示,除非手动关闭 + closable: true, + onClose() { + uploadFileTask.isHideMessage = true; + }, + }); + } else if (uploadFileTask.timer === null) { + // 模拟上传进度 + uploadFileTask.timer = setInterval(() => { + if (uploadFileTask.singleProgress < 50) { + // 进度在0-50%之间较快 + const randomIncrement = Math.floor(Math.random() * 10) + 1; // 随机增加 5-10 的百分比 + uploadFileTask.singleProgress += randomIncrement; + } else if (uploadFileTask.singleProgress < 100) { + // 进度在50%-100%之间较慢 + const randomIncrement = Math.floor(Math.random() * 10) + 1; // 随机增加 1-5 的百分比 + uploadFileTask.singleProgress = Math.min(uploadFileTask.singleProgress + randomIncrement, 99); + } else { + clearInterval(uploadFileTask.timer as unknown as number); + uploadFileTask.timer = null; + } + }, 100); // 定时器间隔为 100 毫秒 + } + if (fileItem) { + fileItem.status = UploadStatus.uploading; // 设置文件状态为上传中 + } + uploadFileTask.finishedTime = null; // 重置任务完成时间 + }, + afterEachUploadTask(route?: string, routeQuery?: Record) { + const { t } = useI18n(); + const { uploadFileTask } = this; + if (uploadFileTask.timer) { + uploadFileTask.singleProgress = 0; + clearInterval(uploadFileTask.timer); + uploadFileTask.timer = null; + } + if (uploadFileTask.uploadQueue.length > 0) { + // 如果待上传队列中还有文件,继续上传 + this.uploadFileFromQueue(uploadFileTask.uploadQueue.shift(), route, routeQuery); + } else { + uploadFileTask.finishedTime = Date.now(); + Message.clear(); // 清除全局提示 + if (uploadFileTask.isBackstageUpload) { + Notification.success({ + title: t('asyncTask.uploadFileSuccessTitle'), + content: () => + h(BackstageMsg, { + content: t('asyncTask.uploadFileSuccess', { + done: this.eachSuccessFileList.length, + fail: this.eachFailFileList.length, + }), + onGoDetail() { + router.push({ + name: route, + query: routeQuery, + }); + Notification.clear(); + uploadFileTask.isBackstageUpload = false; + }, + }), + style: { width: 'auto' }, + closable: true, + duration: 3000, + }); + } + this.$patch({ + uploadFileTask: { + isBackstageUpload: false, + isHideMessage: false, + }, + }); + } + }, + async uploadFileFromQueue(fileItem?: MsFileItem, route?: string, routeQuery?: Record) { + this.beforeEachUpload(fileItem, route, routeQuery); + try { + // TODO: 模拟上传,待接口联调后替换为真实上传逻辑 + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 3000); + }); + if (fileItem) { + fileItem.status = UploadStatus.done; + fileItem.uploadedTime = Date.now(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + if (fileItem) { + fileItem.status = UploadStatus.error; + } + } finally { + // 上传完成/失败,重置进度和定时器 + this.afterEachUploadTask(route, routeQuery); + } + }, + startUpload(fileList: MsFileItem[], route?: string, routeQuery?: Record) { + // 正式开始上传任务之前,同步一次文件列表,取出所有状态为 init 的文件 + const totalWaitingFileList = fileList.filter((item) => item.status === UploadStatus.init); + this.$patch({ + uploadFileTask: { + fileList, + uploadQueue: totalWaitingFileList, + eachTaskQueue: [...totalWaitingFileList], + }, + }); + this.uploadFileFromQueue(this.uploadFileTask.uploadQueue.shift(), route, routeQuery); + }, + }, +}); + +export default useAsyncTaskStore; diff --git a/frontend/src/store/modules/app/types.ts b/frontend/src/store/modules/app/types.ts index 9cbba149ea..e12dfe9794 100644 --- a/frontend/src/store/modules/app/types.ts +++ b/frontend/src/store/modules/app/types.ts @@ -1,6 +1,7 @@ import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router'; import type { BreadcrumbItem } from '@/components/business/ms-breadcrumb/types'; import type { PageConfig, ThemeConfig, LoginConfig, PlatformConfig } from '@/models/setting/config'; +import type { MsFileItem } from '@/components/pure/ms-upload/types'; export interface AppState { colorWeak: boolean; @@ -33,4 +34,17 @@ export interface AppState { pageConfig: PageConfig; } -export type CustomTheme = 'theme-default' | 'theme-green'; +export interface UploadFileTaskState { + isBackstageUpload: boolean; // 是否后台上传,后台上传会展示全局提示类型的进度提示 + isHideMessage: boolean; // 后台上传时展示的消息提示在点击关闭后无需再弹 + fileList: MsFileItem[]; // 文件总队列,包含已经上传的历史记录(不做持久化存储,刷新丢失) + eachTaskQueue: MsFileItem[]; // 每次添加的上传队列,用于展示每次任务的进度使用 + uploadQueue: MsFileItem[]; // 上传队列,每个文件上传完成后会从队列中移除,初始值为上传队列的副本 + singleProgress: number; // 单个上传文件的上传进度,非总进度,是每个文件在上传时的模拟进度 + timer: NodeJS.Timer | null; // 上传进度定时器 + finishedTime: number | null; // 任务完成时间 +} + +export interface AsyncTaskState { + uploadFileTask: UploadFileTaskState; +} diff --git a/frontend/src/utils/validate.ts b/frontend/src/utils/validate.ts index 4776f122b9..aaab181f9f 100644 --- a/frontend/src/utils/validate.ts +++ b/frontend/src/utils/validate.ts @@ -5,7 +5,7 @@ export const phoneRegex = /^\d{11}$/; // 密码校验,8-32位 export const passwordLengthRegex = /^.{8,32}$/; // 密码校验,必须包含数字和字母 -export const passwordWordRegex = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]+$/; +export const passwordWordRegex = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9!@#$%^&*]+$/; // Git地址校验 export const gitRepositoryUrlRegex = /^(?:(?:git:\/\/|https?:\/\/)(?:www\.)?)?(github\.com|gitee\.com)\/([^/]+)\/([^/]+)\.git$/; diff --git a/frontend/src/views/project-management/fileManagement/components/rightBox.vue b/frontend/src/views/project-management/fileManagement/components/rightBox.vue index 5b51b9d2cf..3a47fbebc4 100644 --- a/frontend/src/views/project-management/fileManagement/components/rightBox.vue +++ b/frontend/src/views/project-management/fileManagement/components/rightBox.vue @@ -86,7 +86,14 @@ class="mb-[16px] w-full" @change="handleFileChange" /> - + @@ -115,7 +122,8 @@