feat: add views
This commit is contained in:
381
apps/web-tdesign/src/components/upload/file-upload.vue
Normal file
381
apps/web-tdesign/src/components/upload/file-upload.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
RequestMethodResponse,
|
||||
UploadFile,
|
||||
UploadProps,
|
||||
} from 'tdesign-vue-next';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isNumber, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { Button, Upload } from 'tdesign-vue-next';
|
||||
|
||||
import { message } from '#/adapter/tdesign';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<FileUploadProps>(), {
|
||||
value: () => [],
|
||||
modelValue: undefined,
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
drag: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: false,
|
||||
});
|
||||
const emit = defineEmits([
|
||||
'change',
|
||||
'update:value',
|
||||
'update:modelValue',
|
||||
'delete',
|
||||
'returnText',
|
||||
'preview',
|
||||
]);
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
// 计算当前绑定的值,优先使用 modelValue
|
||||
const currentValue = computed(() => {
|
||||
return props.modelValue === undefined ? props.value : props.modelValue;
|
||||
});
|
||||
|
||||
// 判断是否使用 modelValue
|
||||
const isUsingModelValue = computed(() => {
|
||||
return props.modelValue !== undefined;
|
||||
});
|
||||
|
||||
const fileList = ref<UploadProps['files']>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
const uploadNumber = ref<number>(0); // 上传文件计数器
|
||||
const uploadList = ref<any[]>([]); // 临时上传列表
|
||||
|
||||
watch(
|
||||
currentValue,
|
||||
(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.SUCCESS,
|
||||
url: item,
|
||||
};
|
||||
} else if (item && isObject(item)) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}) as UploadProps['files'];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
async function handleRemove(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('update:modelValue', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件预览
|
||||
function handlePreview(file: UploadFile) {
|
||||
emit('preview', file);
|
||||
}
|
||||
|
||||
// 处理文件数量超限
|
||||
function handleExceed() {
|
||||
message.error($t('ui.upload.maxNumber', [maxNumber.value]));
|
||||
}
|
||||
|
||||
// 处理上传错误
|
||||
function handleUploadError(error: any) {
|
||||
console.error('上传错误:', error);
|
||||
message.error('上传失败');
|
||||
// 上传失败时减少计数器
|
||||
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
|
||||
}
|
||||
|
||||
async function beforeUpload(file: UploadFile) {
|
||||
const fileContent = await file.text();
|
||||
emit('returnText', fileContent);
|
||||
|
||||
// 检查文件数量限制
|
||||
if (
|
||||
isNumber(fileList.value!.length) &&
|
||||
fileList.value!.length >= props.maxNumber
|
||||
) {
|
||||
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkFileType(file.raw!, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
const isLt = file.size! / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function customRequest(
|
||||
info: UploadFile | UploadFile[],
|
||||
): Promise<RequestMethodResponse> {
|
||||
// 处理单个文件上传
|
||||
const uploadFile = Array.isArray(info) ? info[0] : info;
|
||||
|
||||
if (!uploadFile) {
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: 'No file provided',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
uploadFile.onProgress?.({ percent });
|
||||
};
|
||||
const res = await api?.(uploadFile.raw!, progressEvent);
|
||||
|
||||
// 处理上传成功后的逻辑
|
||||
handleUploadSuccess(res!, uploadFile.raw as File);
|
||||
|
||||
uploadFile.onSuccess!(res);
|
||||
message.success($t('ui.upload.uploadSuccess'));
|
||||
|
||||
// 提取 URL,兼容不同的返回格式
|
||||
const fileUrl = (res as any)?.url || (res as any)?.data || res;
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
response: {
|
||||
url: fileUrl,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
uploadFile.onError!(error);
|
||||
handleUploadError(error);
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: error instanceof Error ? error.message : 'Upload failed',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上传成功
|
||||
function handleUploadSuccess(res: any, file: File) {
|
||||
// 删除临时文件
|
||||
const index = fileList.value?.findIndex((item) => item.name === file.name);
|
||||
if (index !== -1) {
|
||||
fileList.value?.splice(index!, 1);
|
||||
}
|
||||
|
||||
// 添加到临时上传列表
|
||||
const fileUrl = res?.url || res?.data || res;
|
||||
uploadList.value.push({
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
status: UploadResultStatus.SUCCESS,
|
||||
uid: file.name + Date.now(),
|
||||
});
|
||||
|
||||
// 检查是否所有文件都上传完成
|
||||
if (uploadList.value.length >= uploadNumber.value) {
|
||||
fileList.value?.push(...uploadList.value);
|
||||
uploadList.value = [];
|
||||
uploadNumber.value = 0;
|
||||
|
||||
// 更新值
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
|
||||
// 单个文件的情况,根据输入参数类型决定返回格式
|
||||
if (props.maxNumber === 1) {
|
||||
const singleValue = list.length > 0 ? list[0] : '';
|
||||
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
|
||||
if (
|
||||
isString(props.value) ||
|
||||
(isUsingModelValue.value && isString(props.modelValue))
|
||||
) {
|
||||
return singleValue;
|
||||
}
|
||||
return singleValue;
|
||||
}
|
||||
|
||||
// 多文件情况,根据输入参数类型决定返回格式
|
||||
if (isUsingModelValue.value) {
|
||||
return Array.isArray(props.modelValue) ? list : list.join(',');
|
||||
}
|
||||
|
||||
return Array.isArray(props.value) ? list : list.join(',');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:request-method="customRequest"
|
||||
:disabled="disabled"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
list-type="text"
|
||||
:progress="{ showInfo: true }"
|
||||
:show-upload-list="{
|
||||
showPreviewIcon: true,
|
||||
showRemoveIcon: true,
|
||||
showDownloadIcon: true,
|
||||
}"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
@reject="handleExceed"
|
||||
>
|
||||
<div v-if="drag" class="upload-drag-area">
|
||||
<p class="upload-drag-icon">
|
||||
<IconifyIcon icon="lucide:cloud-upload" />
|
||||
</p>
|
||||
<p class="upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p class="upload-hint">
|
||||
支持{{ accept.join('/') }}格式文件,不超过{{ maxSize }}MB
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="fileList && fileList.length < maxNumber">
|
||||
<Button>
|
||||
<IconifyIcon icon="lucide:cloud-upload" />
|
||||
{{ $t('ui.upload.upload') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDescription && !drag"
|
||||
class="mt-2 flex flex-wrap items-center"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-drag-area {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background-color: var(--td-bg-color-container, #fafafa);
|
||||
border: 2px dashed var(--td-border-level-2-color, #d9d9d9);
|
||||
border-radius: var(--td-radius-default, 8px);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-drag-area:hover {
|
||||
border-color: var(--td-brand-color, #0052d9);
|
||||
}
|
||||
|
||||
.upload-drag-icon {
|
||||
margin-bottom: 16px;
|
||||
font-size: 48px;
|
||||
color: var(--td-text-color-placeholder, #d9d9d9);
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
}
|
||||
</style>
|
||||
20
apps/web-tdesign/src/components/upload/helper.ts
Normal file
20
apps/web-tdesign/src/components/upload/helper.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
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 function checkImgType(
|
||||
file: File,
|
||||
accepts: string[] = defaultImageAccepts,
|
||||
) {
|
||||
return checkFileType(file, accepts);
|
||||
}
|
||||
362
apps/web-tdesign/src/components/upload/image-upload.vue
Normal file
362
apps/web-tdesign/src/components/upload/image-upload.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'tdesign-vue-next';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isNumber, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { Dialog, Upload } from 'tdesign-vue-next';
|
||||
|
||||
import { message } from '#/adapter/tdesign';
|
||||
|
||||
import { checkImgType, defaultImageAccepts } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<FileUploadProps>(), {
|
||||
value: () => [],
|
||||
modelValue: undefined,
|
||||
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',
|
||||
'update:modelValue',
|
||||
'delete',
|
||||
]);
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
// 计算当前绑定的值,优先使用 modelValue
|
||||
const currentValue = computed(() => {
|
||||
return props.modelValue === undefined ? props.value : props.modelValue;
|
||||
});
|
||||
|
||||
// 判断是否使用 modelValue
|
||||
const isUsingModelValue = computed(() => {
|
||||
return props.modelValue !== undefined;
|
||||
});
|
||||
const previewOpen = ref<boolean>(false); // 是否展示预览
|
||||
const previewImage = ref<string>(''); // 预览图片
|
||||
const previewTitle = ref<string>(''); // 预览标题
|
||||
|
||||
const fileList = ref<UploadProps['files']>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
const uploadNumber = ref<number>(0); // 上传文件计数器
|
||||
const uploadList = ref<any[]>([]); // 临时上传列表
|
||||
|
||||
watch(
|
||||
currentValue,
|
||||
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.SUCCESS,
|
||||
url: item,
|
||||
};
|
||||
} else if (item && isObject(item)) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}) as UploadProps['files'];
|
||||
}
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePreview(file: UploadFile) {
|
||||
if (!file.url && !file.preview) {
|
||||
// TDesign 使用 raw 而不是 originFileObj
|
||||
file.preview = await getBase64<string>(file.raw!);
|
||||
}
|
||||
previewImage.value = file.url || file.preview || '';
|
||||
previewOpen.value = true;
|
||||
previewTitle.value =
|
||||
file.name ||
|
||||
previewImage.value.slice(
|
||||
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRemove(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('update:modelValue', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
previewOpen.value = false;
|
||||
previewTitle.value = '';
|
||||
}
|
||||
|
||||
async function beforeUpload(file: UploadFile) {
|
||||
// 检查文件数量限制
|
||||
if (
|
||||
isNumber(fileList.value!.length) &&
|
||||
fileList.value!.length >= props.maxNumber
|
||||
) {
|
||||
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkImgType(file.raw!, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
const isLt = file.size! / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function customRequest(info: UploadFile | UploadFile[]) {
|
||||
// 处理单个文件上传
|
||||
const uploadFile = Array.isArray(info) ? info[0] : info;
|
||||
|
||||
if (!uploadFile) {
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: 'No file provided',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
uploadFile.onProgress!({ percent });
|
||||
};
|
||||
// TDesign 使用 raw 而不是 file
|
||||
const res = await api?.(uploadFile.raw as File, progressEvent);
|
||||
|
||||
// 处理上传成功后的逻辑
|
||||
handleUploadSuccess(res, uploadFile.raw as File);
|
||||
|
||||
uploadFile.onSuccess!(res);
|
||||
message.success($t('ui.upload.uploadSuccess'));
|
||||
|
||||
// 提取 URL,兼容不同的返回格式
|
||||
const fileUrl = (res as any)?.url || (res as any)?.data || res;
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
response: {
|
||||
url: fileUrl,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
uploadFile.onError!(error);
|
||||
handleUploadError(error);
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: error instanceof Error ? error.message : 'Upload failed',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上传成功
|
||||
function handleUploadSuccess(res: any, file: File) {
|
||||
// 删除临时文件
|
||||
const index = fileList.value?.findIndex((item) => item.name === file.name);
|
||||
if (index !== -1) {
|
||||
fileList.value?.splice(index!, 1);
|
||||
}
|
||||
|
||||
// 添加到临时上传列表
|
||||
const fileUrl = res?.url || res?.data || res;
|
||||
uploadList.value.push({
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
status: UploadResultStatus.SUCCESS,
|
||||
uid: file.name + Date.now(),
|
||||
});
|
||||
|
||||
// 检查是否所有文件都上传完成
|
||||
if (uploadList.value.length >= uploadNumber.value) {
|
||||
fileList.value?.push(...uploadList.value);
|
||||
uploadList.value = [];
|
||||
uploadNumber.value = 0;
|
||||
|
||||
// 更新值
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上传错误
|
||||
function handleUploadError(error: any) {
|
||||
console.error('上传错误:', error);
|
||||
// 上传失败时减少计数器
|
||||
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
|
||||
// 单个文件的情况,根据输入参数类型决定返回格式
|
||||
if (props.maxNumber === 1) {
|
||||
const singleValue = list.length > 0 ? list[0] : '';
|
||||
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
|
||||
if (
|
||||
isString(props.value) ||
|
||||
(isUsingModelValue.value && isString(props.modelValue))
|
||||
) {
|
||||
return singleValue;
|
||||
}
|
||||
return singleValue;
|
||||
}
|
||||
|
||||
// 多文件情况,根据输入参数类型决定返回格式
|
||||
if (isUsingModelValue.value) {
|
||||
return Array.isArray(props.modelValue) ? list : list.join(',');
|
||||
}
|
||||
|
||||
return Array.isArray(props.value) ? list : list.join(',');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:request-method="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
:progress="{ showInfo: true }"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div
|
||||
v-if="fileList && fileList.length < maxNumber"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<IconifyIcon icon="lucide:cloud-upload" />
|
||||
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="mt-2 flex flex-wrap items-center text-sm"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
<Dialog
|
||||
:footer="false"
|
||||
:visible="previewOpen"
|
||||
:header="previewTitle"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<img :src="previewImage" alt="" class="w-full" />
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* TDesign 上传组件样式 */
|
||||
:deep(.t-upload__card-item) {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
3
apps/web-tdesign/src/components/upload/index.ts
Normal file
3
apps/web-tdesign/src/components/upload/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FileUpload } from './file-upload.vue';
|
||||
export { default as ImageUpload } from './image-upload.vue';
|
||||
export { default as InputUpload } from './input-upload.vue';
|
||||
74
apps/web-tdesign/src/components/upload/input-upload.vue
Normal file
74
apps/web-tdesign/src/components/upload/input-upload.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { InputProps, TextareaProps } from 'tdesign-vue-next';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Col, Input, Row, Textarea } from 'tdesign-vue-next';
|
||||
|
||||
import FileUpload from './file-upload.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: number | string;
|
||||
fileUploadProps?: FileUploadProps;
|
||||
inputProps?: InputProps;
|
||||
inputType?: 'input' | 'textarea';
|
||||
modelValue?: number | string;
|
||||
textareaProps?: TextareaProps;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', payload: number | string): void;
|
||||
(e: 'update:value', payload: number | string): void;
|
||||
(e: 'update:modelValue', payload: number | string): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
defaultValue: props.defaultValue,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
function handleReturnText(text: string) {
|
||||
modelValue.value = text;
|
||||
emits('change', modelValue.value);
|
||||
emits('update:value', modelValue.value);
|
||||
emits('update:modelValue', modelValue.value);
|
||||
}
|
||||
|
||||
const inputProps = computed(() => {
|
||||
return {
|
||||
...props.inputProps,
|
||||
value: modelValue.value,
|
||||
};
|
||||
});
|
||||
|
||||
const textareaProps = computed(() => {
|
||||
return {
|
||||
...props.textareaProps,
|
||||
value: modelValue.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fileUploadProps = computed(() => {
|
||||
return {
|
||||
...props.fileUploadProps,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Row>
|
||||
<Col :span="18">
|
||||
<Input readonly v-if="inputType === 'input'" v-bind="inputProps" />
|
||||
<Textarea readonly v-else :row="4" v-bind="textareaProps" />
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<FileUpload
|
||||
class="ml-4"
|
||||
v-bind="fileUploadProps"
|
||||
@return-text="handleReturnText"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</template>
|
||||
39
apps/web-tdesign/src/components/upload/typing.ts
Normal file
39
apps/web-tdesign/src/components/upload/typing.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
export enum UploadResultStatus {
|
||||
ERROR = 'error',
|
||||
PROGRESS = 'progress',
|
||||
SUCCESS = 'success',
|
||||
WAITING = 'waiting',
|
||||
}
|
||||
|
||||
export type UploadListType = 'picture' | 'picture-card' | 'text';
|
||||
|
||||
export interface FileUploadProps {
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
drag?: boolean; // 是否支持拖拽上传
|
||||
helpText?: string;
|
||||
listType?: UploadListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
modelValue?: string | string[]; // v-model 支持
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}
|
||||
168
apps/web-tdesign/src/components/upload/use-upload.ts
Normal file
168
apps/web-tdesign/src/components/upload/use-upload.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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 { 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 function useUpload(directory?: string) {
|
||||
// 后端上传地址
|
||||
const uploadUrl = getUploadUrl();
|
||||
// 是否使用前端直连上传
|
||||
const isClientUpload =
|
||||
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
|
||||
// 重写ElUpload上传方法
|
||||
async function httpRequest(
|
||||
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 function getUploadUrl(): string {
|
||||
return `${apiURL}/infra/file/upload`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
*
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile0(
|
||||
vo: InfraFileApi.FilePresignedUrlRespVO,
|
||||
file: File,
|
||||
): InfraFileApi.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名称(使用算法SHA256)
|
||||
*
|
||||
* @param file 要上传的文件
|
||||
*/
|
||||
async function generateFileName(file: File) {
|
||||
// // 读取文件内容
|
||||
// const data = await file.arrayBuffer();
|
||||
// const wordArray = CryptoJS.lib.WordArray.create(data);
|
||||
// // 计算SHA256
|
||||
// const sha256 = CryptoJS.SHA256(wordArray).toString();
|
||||
// // 拼接后缀
|
||||
// const ext = file.name.slice(Math.max(0, file.name.lastIndexOf('.')));
|
||||
// return `${sha256}${ext}`;
|
||||
return file.name;
|
||||
}
|
||||
Reference in New Issue
Block a user