feat: 完善 ele vxe-table 适配,完善文件和图片上传组件、字典组件、文档组件迁移,完善租户和租户套餐管理页面

This commit is contained in:
puhui999
2025-05-11 22:33:15 +08:00
parent 0155198f4e
commit ca6d36b6e2
18 changed files with 2047 additions and 4 deletions

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ElTag } from 'element-plus';
// import { isHexColor } from '@/utils/color' // TODO @芋艿:【可优化】增加 cssClass 的处理 https://gitee.com/yudaocode/yudao-ui-admin-vben/blob/v2.4.1/src/components/DictTag/src/DictTag.vue#L60
import { getDictObj } from '#/utils';
interface DictTagProps {
/**
* 字典类型
*/
type: string;
/**
* 字典值
*/
value: any;
/**
* 图标
*/
icon?: string;
}
const props = defineProps<DictTagProps>();
/** 获取字典标签 */
const dictTag = computed(() => {
const defaultDict = {
label: '',
colorType: 'primary',
};
// 校验参数有效性
if (!props.type || props.value === undefined || props.value === null) {
return defaultDict;
}
// 获取字典对象
const dict = getDictObj(props.type, String(props.value));
if (!dict) {
return defaultDict;
}
// 处理颜色类型
let colorType = dict.colorType;
switch (colorType) {
case 'danger': {
colorType = 'danger';
break;
}
case 'info': {
colorType = 'info';
break;
}
case 'primary': {
colorType = 'primary';
break;
}
case 'success': {
colorType = 'success';
break;
}
case 'warning': {
colorType = 'warning';
break;
}
default: {
if (!colorType) {
colorType = 'primary';
}
}
}
return {
label: dict.label || '',
colorType,
};
});
</script>
<template>
<ElTag v-if="dictTag" :type="dictTag.colorType">
{{ dictTag.label }}
</ElTag>
</template>

View File

@@ -0,0 +1 @@
export { default as DictTag } from './dict-tag.vue';

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
import { isDocAlertEnable } from '@vben/hooks';
import { openWindow } from '@vben/utils';
import { ElAlert, ElLink } from 'element-plus';
export interface DocAlertProps {
/**
* 文档标题
*/
title: string;
/**
* 文档 URL 地址
*/
url: string;
}
const props = defineProps<DocAlertProps>();
/** 跳转 URL 链接 */
const goToUrl = () => {
openWindow(props.url);
};
</script>
<template>
<ElAlert
v-if="isDocAlertEnable()"
type="info"
:closable="false"
class="mb-2 rounded"
>
<ElLink type="primary" @click="goToUrl">
{{ title }}文档地址{{ url }}
</ElLink>
</ElAlert>
</template>

View File

@@ -0,0 +1 @@
export { default as DocAlert } from './doc-alert.vue';

View File

@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type {
UploadFile,
UploadProgressEvent,
UploadRequestOptions,
} from 'element-plus';
import type { AxiosResponse } from '@vben/request';
import type { CustomUploadFile } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { ref, toRefs, watch } from 'vue';
import { CloudUpload } from '@vben/icons';
import { $t } from '@vben/locales';
import { isFunction, isObject, isString } from '@vben/utils';
import { ElButton, ElMessage, ElUpload } from 'element-plus';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
disabled?: boolean;
helpText?: string;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
directory: undefined,
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: undefined,
resultField: '',
showDescription: false,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
const fileList = ref<CustomUploadFile[]>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
watch(
() => props.value,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (Array.isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: -i,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
} as CustomUploadFile;
} else if (item && isObject(item)) {
const file = item as Record<string, any>;
return {
uid: file.uid || -i,
name: file.name || '',
status: UploadResultStatus.DONE,
url: file.url,
response: file.response,
percentage: file.percentage,
size: file.size,
} as CustomUploadFile;
}
return null;
})
.filter(Boolean) as CustomUploadFile[];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = checkFileType(file, accept);
if (!isAct) {
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
return false;
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
return false;
}
return true;
};
async function customRequest(options: UploadRequestOptions) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
}
try {
// 上传文件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
const progressEvent: UploadProgressEvent = {
percent,
total: e.total || 0,
loaded: e.loaded || 0,
lengthComputable: true,
target: e.target as EventTarget,
bubbles: false,
cancelBubble: false,
cancelable: false,
composed: false,
currentTarget: e.target as EventTarget,
defaultPrevented: false,
eventPhase: 0,
isTrusted: true,
returnValue: true,
srcElement: e.target as EventTarget,
timeStamp: Date.now(),
type: 'progress',
composedPath: () => [],
initEvent: () => {},
preventDefault: () => {},
stopImmediatePropagation: () => {},
stopPropagation: () => {},
};
options.onProgress!(progressEvent);
};
const res = await api?.(options.file, progressEvent);
options.onSuccess!(res);
ElMessage.success($t('ui.upload.uploadSuccess'));
// 更新文件
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
options.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response;
}
return item?.url || item?.response?.url || item?.response;
});
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
if (props.maxNumber === 1) {
return list.length > 0 ? list[0] : '';
}
return list;
}
</script>
<template>
<div>
<ElUpload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:http-request="customRequest"
:disabled="disabled"
:limit="maxNumber"
:multiple="multiple"
list-type="text"
:on-remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<ElButton>
<CloudUpload />
{{ $t('ui.upload.upload') }}
</ElButton>
</div>
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
{{ getStringAccept }}
</div>
</ElUpload>
</div>
</template>

View File

@@ -0,0 +1,20 @@
export function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts.length === 0) {
return true;
}
const newTypes = accepts.join('|');
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
return reg.test(file.name);
}
/**
* 默认图片类型
*/
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export function checkImgType(
file: File,
accepts: string[] = defaultImageAccepts,
) {
return checkFileType(file, accepts);
}

View File

@@ -0,0 +1,265 @@
<script lang="ts" setup>
import type {
UploadFile,
UploadProgressEvent,
UploadRequestOptions,
} from 'element-plus';
import type { AxiosResponse } from '@vben/request';
import type { UploadListType } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { ref, toRefs, watch } from 'vue';
import { CloudUpload } from '@vben/icons';
import { $t } from '@vben/locales';
import { isFunction, isObject, isString } from '@vben/utils';
import { ElMessage, ElUpload } from 'element-plus';
import { checkImgType, defaultImageAccepts } from './helper';
import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
disabled?: boolean;
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
directory: undefined,
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccepts,
multiple: false,
api: undefined,
resultField: '',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
const fileList = ref<UploadFile[]>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
watch(
() => props.value,
async (v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string | string[] = [];
if (v) {
if (Array.isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: -i,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
} as UploadFile;
} else if (item && isObject(item)) {
const file = item as Record<string, any>;
return {
uid: file.uid || -i,
name: file.name || '',
status: UploadResultStatus.DONE,
url: file.url,
} as UploadFile;
}
return null;
})
.filter(Boolean) as UploadFile[];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
const handlePreview = async (file: UploadFile) => {
if (!file.url) {
const preview = await getBase64<string>(file.raw!);
window.open(preview || '');
return;
}
window.open(file.url);
};
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = checkImgType(file, accept);
if (!isAct) {
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
return false;
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
return false;
}
return true;
};
async function customRequest(options: UploadRequestOptions) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
}
try {
// 上传文件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
options.onProgress!({
percent,
total: e.total || 0,
loaded: e.loaded || 0,
lengthComputable: true,
} as unknown as UploadProgressEvent);
};
const res = await api?.(options.file, progressEvent);
options.onSuccess!(res);
ElMessage.success($t('ui.upload.uploadSuccess'));
// 更新文件
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
options.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response;
}
return item?.url || item?.response?.url || item?.response;
});
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
if (props.maxNumber === 1) {
return list.length > 0 ? list[0] : '';
}
return list;
}
</script>
<template>
<div>
<ElUpload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:http-request="customRequest"
:disabled="disabled"
:list-type="listType"
:limit="maxNumber"
:multiple="multiple"
:on-preview="handlePreview"
:on-remove="handleRemove"
>
<div
v-if="fileList && fileList.length < maxNumber"
class="flex flex-col items-center justify-center"
>
<CloudUpload />
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
</div>
</ElUpload>
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
{{ getStringAccept }}
</div>
</div>
</template>
<style>
.ant-upload-select-picture-card {
@apply flex items-center justify-center;
}
</style>

View File

@@ -0,0 +1,2 @@
export { default as FileUpload } from './file-upload.vue';
export { default as ImageUpload } from './image-upload.vue';

View File

@@ -0,0 +1,46 @@
import type { UploadStatus } from 'element-plus';
export type UploadListType = 'picture' | 'picture-card' | 'text';
export type UploadStatus = 'error' | 'removed' | 'success' | 'uploading';
export enum UploadResultStatus {
DONE = 'success',
ERROR = 'error',
REMOVED = 'removed',
SUCCESS = 'success',
UPLOADING = 'uploading',
}
export interface CustomUploadFile {
uid: number;
name: string;
status: UploadStatus;
url?: string;
response?: any;
percentage?: number;
size?: number;
raw?: File;
}
export function convertToUploadStatus(
status: UploadResultStatus,
): UploadStatus {
switch (status) {
case UploadResultStatus.DONE: {
return 'success';
}
case UploadResultStatus.ERROR: {
return 'error';
}
case UploadResultStatus.REMOVED: {
return 'removed';
}
case UploadResultStatus.UPLOADING: {
return 'uploading';
}
default: {
return 'success';
}
}
}

View File

@@ -0,0 +1,158 @@
import type { Ref } from 'vue';
import type { AxiosProgressEvent, InfraFileApi } from '#/api/infra/file';
import { computed, unref } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { $t } from '@vben/locales';
// import CryptoJS from 'crypto-js';
import { createFile, getFilePresignedUrl, uploadFile } from '#/api/infra/file';
import { baseRequestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/**
* 上传类型
*/
enum UPLOAD_TYPE {
// 客户端直接上传只支持S3服务
CLIENT = 'client',
// 客户端发送到后端上传
SERVER = 'server',
}
export function useUploadType({
acceptRef,
helpTextRef,
maxNumberRef,
maxSizeRef,
}: {
acceptRef: Ref<string[]>;
helpTextRef: Ref<string>;
maxNumberRef: Ref<number>;
maxSizeRef: Ref<number>;
}) {
// 文件类型限制
const getAccept = computed(() => {
const accept = unref(acceptRef);
if (accept && accept.length > 0) {
return accept;
}
return [];
});
const getStringAccept = computed(() => {
return unref(getAccept)
.map((item) => {
return item.indexOf('/') > 0 || item.startsWith('.')
? item
: `.${item}`;
})
.join(',');
});
// 支持jpg、jpeg、png格式不超过2M最多可选择10张图片
const getHelpText = computed(() => {
const helpText = unref(helpTextRef);
if (helpText) {
return helpText;
}
const helpTexts: string[] = [];
const accept = unref(acceptRef);
if (accept.length > 0) {
helpTexts.push($t('ui.upload.accept', [accept.join(',')]));
}
const maxSize = unref(maxSizeRef);
if (maxSize) {
helpTexts.push($t('ui.upload.maxSize', [maxSize]));
}
const maxNumber = unref(maxNumberRef);
if (maxNumber && maxNumber !== Infinity) {
helpTexts.push($t('ui.upload.maxNumber', [maxNumber]));
}
return helpTexts.join('');
});
return { getAccept, getStringAccept, getHelpText };
}
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
export const useUpload = (directory?: string) => {
// 后端上传地址
const uploadUrl = getUploadUrl();
// 是否使用前端直连上传
const isClientUpload =
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
// 重写ElUpload上传方法
const httpRequest = async (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => {
// 模式一:前端上传
if (isClientUpload) {
// 1.1 生成文件名称
const fileName = await generateFileName(file);
// 1.2 获取文件预签名地址
const presignedInfo = await getFilePresignedUrl(fileName, directory);
// 1.3 上传文件
return baseRequestClient
.put(presignedInfo.uploadUrl, file, {
headers: {
'Content-Type': file.type,
},
})
.then(() => {
// 1.4. 记录文件信息到后端(异步)
createFile0(presignedInfo, file);
// 通知成功,数据格式保持与后端上传的返回结果一致
return { url: presignedInfo.url };
});
} else {
// 模式二:后端上传
return uploadFile({ file, directory }, onUploadProgress);
}
};
return {
uploadUrl,
httpRequest,
};
};
/**
* 获得上传 URL
*/
export const getUploadUrl = (): string => {
return `${apiURL}/infra/file/upload`;
};
/**
* 创建文件信息
*
* @param vo 文件预签名信息
* @param file 文件
*/
function createFile0(vo: InfraFileApi.FilePresignedUrlRespVO, file: File) {
const fileVO = {
configId: vo.configId,
url: vo.url,
path: vo.path,
name: file.name,
type: file.type,
size: file.size,
};
createFile(fileVO);
return fileVO;
}
/**
* 生成文件名称
*
* @param file 要上传的文件
*/
async function generateFileName(file: File) {
return file.name;
}