refactor: 文件上传/图片上传重构(破坏性更新 不兼容之前的api)
This commit is contained in:
parent
456f0e1112
commit
8c1cd617ad
@ -1,8 +1,14 @@
|
|||||||
# 1.2.4
|
# 1.3.0
|
||||||
|
|
||||||
|
**REFACTOR**
|
||||||
|
|
||||||
|
- 文件上传/图片上传重构(破坏性更新 不兼容之前的api)
|
||||||
|
- 文件上传/图片上传**不再支持**url用法 强制使用ossId
|
||||||
|
|
||||||
**BUG FIX**
|
**BUG FIX**
|
||||||
|
|
||||||
- 测试菜单 请假申请 选中删除 需要根据状态判断
|
- 测试菜单 请假申请 选中删除 需要根据状态判断
|
||||||
|
- 修复文件/图片在Safari中无法上传 file-type库与Safari不兼容导致
|
||||||
|
|
||||||
**OTHER**
|
**OTHER**
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@vben/web-antd",
|
"name": "@vben/web-antd",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"homepage": "https://vben.pro",
|
"homepage": "https://vben.pro",
|
||||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -29,7 +29,7 @@ export function ossList(params?: PageQuery) {
|
|||||||
* @param ossIds id数组
|
* @param ossIds id数组
|
||||||
* @returns 信息数组
|
* @returns 信息数组
|
||||||
*/
|
*/
|
||||||
export function ossInfo(ossIds: IDS) {
|
export function ossInfo(ossIds: ID | IDS) {
|
||||||
return requestClient.get<OssFile[]>(`${Api.ossInfo}/${ossIds}`);
|
return requestClient.get<OssFile[]>(`${Api.ossInfo}/${ossIds}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,243 +1,143 @@
|
|||||||
<script lang="ts" setup>
|
<!--
|
||||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
不再支持url 统一使用ossId
|
||||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
去除使用`file-type`库进行文件类型检测 在Safari无法使用
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
import type { AxiosResponse } from '@vben/request';
|
import { $t, I18nT } from '@vben/locales';
|
||||||
|
|
||||||
import type { AxiosProgressEvent } from '#/api';
|
|
||||||
|
|
||||||
import { ref, toRefs, watch } from 'vue';
|
|
||||||
|
|
||||||
import { $t } from '@vben/locales';
|
|
||||||
|
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { message, Upload } from 'ant-design-vue';
|
import { Upload } from 'ant-design-vue';
|
||||||
import { isArray, isFunction, isObject, isString } from 'lodash-es';
|
|
||||||
|
|
||||||
import { uploadApi } from '#/api';
|
import { defaultFileAcceptExts, defaultFilePreview } from './helper';
|
||||||
|
import { useUpload } from './hook';
|
||||||
|
|
||||||
import { checkFileType } from './helper';
|
interface Props {
|
||||||
import { UploadResultStatus } from './typing';
|
|
||||||
import { useUploadType } from './use-upload';
|
|
||||||
|
|
||||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/**
|
/**
|
||||||
* 建议使用拓展名(不带.)
|
* 文件上传失败 是否从展示列表中删除
|
||||||
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
|
* @default true
|
||||||
* 需自行改造 ./helper/checkFileType方法
|
*/
|
||||||
|
removeOnError?: boolean;
|
||||||
|
/**
|
||||||
|
* 上传成功 是否展示提示信息
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSuccessMsg?: boolean;
|
||||||
|
/**
|
||||||
|
* 删除文件前是否需要确认
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
removeConfirm?: boolean;
|
||||||
|
/**
|
||||||
|
* 同antdv参数
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
/**
|
||||||
|
* 附带的请求参数
|
||||||
|
*/
|
||||||
|
data?: any;
|
||||||
|
/**
|
||||||
|
* 最大上传图片数量
|
||||||
|
* maxCount为1时 会被绑定为string而非string[]
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
maxCount?: number;
|
||||||
|
/**
|
||||||
|
* 文件最大 单位M
|
||||||
|
* @default 5
|
||||||
*/
|
*/
|
||||||
accept?: string[];
|
|
||||||
api?: (
|
|
||||||
file: Blob | File,
|
|
||||||
onUploadProgress?: AxiosProgressEvent,
|
|
||||||
) => Promise<AxiosResponse<any>>;
|
|
||||||
disabled?: boolean;
|
|
||||||
helpText?: string;
|
|
||||||
// 最大数量的文件,Infinity不限制
|
|
||||||
maxNumber?: number;
|
|
||||||
// 文件最大多少MB
|
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
// 是否支持多选
|
/**
|
||||||
|
* 是否禁用
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否显示文案 请上传不超过...
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
helpMessage?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持多选文件,ie10+ 支持。开启后按住 ctrl 可选择多个文件。
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
// support xxx.xxx.xx
|
|
||||||
// 返回的字段 默认url
|
|
||||||
resultField?: 'fileName' | 'ossId' | 'url' | string;
|
|
||||||
/**
|
/**
|
||||||
* 是否显示下面的描述
|
* 自定义文件预览逻辑 比如: 你可以改为下载
|
||||||
|
* @param file file
|
||||||
*/
|
*/
|
||||||
showDescription?: boolean;
|
preview?: (file: UploadFile) => Promise<void> | void;
|
||||||
value?: string[];
|
}
|
||||||
}>(),
|
|
||||||
{
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
value: () => [],
|
removeOnError: true,
|
||||||
|
showSuccessMsg: true,
|
||||||
|
removeConfirm: false,
|
||||||
|
accept: defaultFileAcceptExts.join(','),
|
||||||
|
data: () => undefined,
|
||||||
|
maxCount: 1,
|
||||||
|
maxSize: 5,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
helpText: '',
|
helpMessage: true,
|
||||||
maxSize: 2,
|
preview: defaultFilePreview,
|
||||||
maxNumber: 1,
|
|
||||||
accept: () => [],
|
|
||||||
multiple: false,
|
|
||||||
api: uploadApi,
|
|
||||||
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<UploadProps['fileList']>([]);
|
// 双向绑定 ossId
|
||||||
const isLtMsg = ref<boolean>(true);
|
const ossIdList = defineModel<string | string[]>('value', {
|
||||||
const isActMsg = ref<boolean>(true);
|
default: () => [],
|
||||||
const isFirstRender = ref<boolean>(true);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.value,
|
|
||||||
(v) => {
|
|
||||||
if (isInnerOperate.value) {
|
|
||||||
isInnerOperate.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let value: string[] = [];
|
|
||||||
if (v) {
|
|
||||||
if (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: 'done',
|
|
||||||
url: item,
|
|
||||||
};
|
|
||||||
} else if (item && isObject(item)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}) as UploadProps['fileList'];
|
|
||||||
}
|
|
||||||
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 = await checkFileType(file, accept);
|
|
||||||
if (!isAct) {
|
|
||||||
message.error($t('component.upload.acceptUpload', [accept]));
|
|
||||||
isActMsg.value = false;
|
|
||||||
// 防止弹出多个错误提示
|
|
||||||
setTimeout(() => (isActMsg.value = true), 1000);
|
|
||||||
}
|
|
||||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
|
||||||
if (isLt) {
|
|
||||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
|
||||||
isLtMsg.value = false;
|
|
||||||
// 防止弹出多个错误提示
|
|
||||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
|
||||||
}
|
|
||||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function customRequest(info: UploadRequestOption<any>) {
|
|
||||||
const { api } = props;
|
|
||||||
if (!api || !isFunction(api)) {
|
|
||||||
console.warn('upload api must exist and be a function');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 进度条事件
|
|
||||||
const progressEvent: AxiosProgressEvent = (e) => {
|
|
||||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
|
||||||
info.onProgress!({ percent });
|
|
||||||
};
|
|
||||||
const res = await api?.(info.file as File, progressEvent);
|
|
||||||
/**
|
|
||||||
* 由getValue处理 传对象过去
|
|
||||||
* 直接传string(id)会被转为Number
|
|
||||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
|
||||||
*/
|
|
||||||
info.onSuccess!(res);
|
|
||||||
message.success($t('component.upload.uploadSuccess'));
|
|
||||||
// 获取
|
|
||||||
const value = getValue();
|
|
||||||
isInnerOperate.value = true;
|
|
||||||
emit('update:value', value);
|
|
||||||
emit('change', value);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
info.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?.[props.resultField];
|
|
||||||
}
|
|
||||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
|
||||||
if (item?.url) {
|
|
||||||
return item.url;
|
|
||||||
}
|
|
||||||
// 注意这里取的key为 url
|
|
||||||
return item?.response?.url;
|
|
||||||
});
|
});
|
||||||
return list;
|
|
||||||
}
|
const {
|
||||||
|
uploadUrl,
|
||||||
|
headers,
|
||||||
|
acceptFormat,
|
||||||
|
handleChange,
|
||||||
|
handleRemove,
|
||||||
|
beforeUpload,
|
||||||
|
innerFileList,
|
||||||
|
} = useUpload(props, ossIdList);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Upload
|
<Upload
|
||||||
v-bind="$attrs"
|
v-model:file-list="innerFileList"
|
||||||
v-model:file-list="fileList"
|
:action="uploadUrl"
|
||||||
:accept="getStringAccept"
|
:headers="headers"
|
||||||
:before-upload="beforeUpload"
|
:data="data"
|
||||||
:custom-request="customRequest"
|
:accept="accept"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:max-count="maxNumber"
|
:max-count="maxCount"
|
||||||
:multiple="multiple"
|
|
||||||
list-type="text"
|
|
||||||
:progress="{ showInfo: true }"
|
:progress="{ showInfo: true }"
|
||||||
|
:multiple="multiple"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
@preview="preview"
|
||||||
|
@change="handleChange"
|
||||||
@remove="handleRemove"
|
@remove="handleRemove"
|
||||||
>
|
>
|
||||||
<div v-if="fileList && fileList.length < maxNumber">
|
<div v-if="innerFileList?.length < maxCount">
|
||||||
<a-button>
|
<a-button :disabled="disabled">
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
{{ $t('component.upload.upload') }}
|
{{ $t('component.upload.upload') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showDescription" 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>
|
</Upload>
|
||||||
|
<I18nT
|
||||||
|
v-if="helpMessage"
|
||||||
|
scope="global"
|
||||||
|
keypath="component.upload.uploadHelpMessage"
|
||||||
|
tag="div"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<template #size>
|
||||||
|
<span class="text-primary mx-1 font-medium"> {{ maxSize }}MB </span>
|
||||||
|
</template>
|
||||||
|
<template #ext>
|
||||||
|
<span class="text-primary mx-1 font-medium">
|
||||||
|
{{ acceptFormat }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</I18nT>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.ant-upload-select-picture-card i {
|
|
||||||
font-size: 32px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-upload-select-picture-card .ant-upload-text {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,51 +1,28 @@
|
|||||||
import { fileTypeFromBlob } from '@vben/utils';
|
import type { UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不支持txt文件 @see https://github.com/sindresorhus/file-type/issues/55
|
* 默认支持上传的图片文件类型
|
||||||
* 需要自行修改
|
|
||||||
* @param file file对象
|
|
||||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
|
||||||
* @returns 是否通过文件类型校验
|
|
||||||
*/
|
*/
|
||||||
export async function checkFileType(file: File, accepts: string[]) {
|
export const defaultImageAcceptExts = [
|
||||||
if (!accepts || accepts?.length === 0) {
|
'.jpg',
|
||||||
return true;
|
'.jpeg',
|
||||||
}
|
'.png',
|
||||||
console.log(file);
|
'.gif',
|
||||||
const fileType = await fileTypeFromBlob(file);
|
'.webp',
|
||||||
if (!fileType) {
|
];
|
||||||
console.error('无法获取文件类型');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log('文件类型', fileType);
|
|
||||||
// 是否文件拓展名/文件头任意有一个匹配
|
|
||||||
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认图片类型
|
* 默认支持上传的文件类型
|
||||||
*/
|
*/
|
||||||
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
export const defaultFileAcceptExts = ['.xlsx', '.csv', '.docx', '.pdf'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断文件类型是否符合要求
|
* 文件(非图片)的默认预览逻辑
|
||||||
* @param file file对象
|
* 默认: window.open打开 交给浏览器接管
|
||||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
* @param file file
|
||||||
* @returns 是否通过文件类型校验
|
|
||||||
*/
|
*/
|
||||||
export async function checkImageFileType(file: File, accepts: string[]) {
|
export function defaultFilePreview(file: UploadFile) {
|
||||||
// 空的accepts 使用默认规则
|
if (file?.url) {
|
||||||
if (!accepts || accepts.length === 0) {
|
window.open(file.url);
|
||||||
accepts = defaultImageAccept;
|
|
||||||
}
|
}
|
||||||
const fileType = await fileTypeFromBlob(file);
|
|
||||||
if (!fileType) {
|
|
||||||
console.error('无法获取文件类型');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log('文件类型', fileType);
|
|
||||||
// 是否文件拓展名/文件头任意有一个匹配
|
|
||||||
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
255
apps/web-antd/src/components/upload/src/hook.ts
Normal file
255
apps/web-antd/src/components/upload/src/hook.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
|
||||||
|
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
||||||
|
|
||||||
|
import type { ModelRef } from 'vue';
|
||||||
|
|
||||||
|
import type { HttpResponse } from '@vben/request';
|
||||||
|
|
||||||
|
import type { UploadResult } from '#/api';
|
||||||
|
import type { OssFile } from '#/api/system/oss/model';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useAppConfig } from '@vben/hooks';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ossInfo } from '#/api/system/oss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片预览hook
|
||||||
|
* @returns 预览
|
||||||
|
*/
|
||||||
|
export function useImagePreview() {
|
||||||
|
/**
|
||||||
|
* 获取base64字符串
|
||||||
|
* @param file 文件
|
||||||
|
* @returns base64字符串
|
||||||
|
*/
|
||||||
|
function getBase64(file: File) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.addEventListener('load', () => resolve(reader.result));
|
||||||
|
reader.addEventListener('error', (error) => reject(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal可见
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
// 预览的图片 url/base64
|
||||||
|
const previewImage = ref('');
|
||||||
|
// 预览的图片名称
|
||||||
|
const previewTitle = ref('');
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
previewVisible.value = false;
|
||||||
|
previewTitle.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(file: UploadFile) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 文件预览 取base64
|
||||||
|
if (!file.url && !file.preview && file.originFileObj) {
|
||||||
|
file.preview = (await getBase64(file.originFileObj)) as string;
|
||||||
|
}
|
||||||
|
// 这里不可能为空
|
||||||
|
const url = file.url ?? '';
|
||||||
|
previewImage.value = url || file.preview || '';
|
||||||
|
previewVisible.value = true;
|
||||||
|
previewTitle.value =
|
||||||
|
file.name || url.slice(Math.max(0, url.lastIndexOf('/') + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewVisible,
|
||||||
|
previewImage,
|
||||||
|
previewTitle,
|
||||||
|
handleCancel,
|
||||||
|
handlePreview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片上传和文件上传的通用hook
|
||||||
|
* @param props 组件props
|
||||||
|
* @param bindValue 双向绑定的idList
|
||||||
|
* @returns hook
|
||||||
|
*/
|
||||||
|
export function useUpload(
|
||||||
|
props: Readonly<BaseUploadProps>,
|
||||||
|
bindValue: ModelRef<string | string[]>,
|
||||||
|
) {
|
||||||
|
const { apiURL, clientId } = useAppConfig(
|
||||||
|
import.meta.env,
|
||||||
|
import.meta.env.PROD,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 内部维护fileList
|
||||||
|
const innerFileList = ref<UploadFile[]>([]);
|
||||||
|
|
||||||
|
/** oss上传地址 */
|
||||||
|
const uploadUrl = `${apiURL}/resource/oss/upload`;
|
||||||
|
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* header参数 需要带上token和clientId
|
||||||
|
*/
|
||||||
|
const headers = computed(() => {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${accessStore.accessToken}`,
|
||||||
|
clientId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptFormat = computed(() => {
|
||||||
|
return props.accept
|
||||||
|
?.split(',')
|
||||||
|
.map((item) => {
|
||||||
|
if (item.startsWith('.')) {
|
||||||
|
return item.slice(1);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChange(info: UploadChangeParam) {
|
||||||
|
function removeCurrentFile(
|
||||||
|
currentFile: UploadChangeParam['file'],
|
||||||
|
currentFileList: UploadChangeParam['fileList'],
|
||||||
|
) {
|
||||||
|
if (props.removeOnError) {
|
||||||
|
currentFileList.splice(currentFileList.indexOf(currentFile), 1);
|
||||||
|
} else {
|
||||||
|
currentFile.status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file: currentFile, fileList } = info;
|
||||||
|
|
||||||
|
switch (currentFile.status) {
|
||||||
|
// 上传成功 只是判断httpStatus 200 需要手动判断业务code
|
||||||
|
case 'done': {
|
||||||
|
if (!currentFile.response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取返回结果
|
||||||
|
const response = currentFile.response as HttpResponse<UploadResult>;
|
||||||
|
// 上传异常
|
||||||
|
if (response.code !== 200) {
|
||||||
|
message.error(response.msg);
|
||||||
|
removeCurrentFile(currentFile, fileList);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 上传成功 做转换
|
||||||
|
if (props.showSuccessMsg) {
|
||||||
|
message.success($t('component.upload.uploadSuccess'));
|
||||||
|
}
|
||||||
|
const { ossId, fileName, url } = response.data;
|
||||||
|
currentFile.url = url;
|
||||||
|
currentFile.fileName = fileName;
|
||||||
|
currentFile.uid = ossId;
|
||||||
|
// ossID添加 单个文件会被当做string
|
||||||
|
if (props.maxCount === 1) {
|
||||||
|
bindValue.value = ossId;
|
||||||
|
} else {
|
||||||
|
(bindValue.value as string[]).push(ossId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 上传失败 网络原因导致httpStatus 不等于200
|
||||||
|
case 'error': {
|
||||||
|
message.error($t('component.upload.uploadError'));
|
||||||
|
removeCurrentFile(currentFile, fileList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(currentFile: UploadFile) {
|
||||||
|
function remove() {
|
||||||
|
// fileList会自行处理删除 这里只需要处理ossId
|
||||||
|
if (props.maxCount === 1) {
|
||||||
|
bindValue.value = '';
|
||||||
|
} else {
|
||||||
|
(bindValue.value as string[]).splice(
|
||||||
|
bindValue.value.indexOf(currentFile.uid),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.removeConfirm) {
|
||||||
|
remove();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: $t('pages.common.tip'),
|
||||||
|
content: $t('component.upload.confirmDelete', [currentFile.name]),
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
centered: true,
|
||||||
|
onOk() {
|
||||||
|
resolve(true);
|
||||||
|
remove();
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeUpload(file: FileType) {
|
||||||
|
const isLtMax = file.size / 1024 / 1024 < props.maxSize!;
|
||||||
|
if (!isLtMax) {
|
||||||
|
message.error($t('component.upload.maxSize', [props.maxSize]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 大坑 Safari不支持file-type库 去除文件类型的校验
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这里只监听list地址变化 即重新赋值才会触发watch
|
||||||
|
* immediate用于初始化触发
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => bindValue.value,
|
||||||
|
async (value) => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await ossInfo(value);
|
||||||
|
function transformFile(info: OssFile) {
|
||||||
|
const fileitem: UploadFile = {
|
||||||
|
uid: info.ossId,
|
||||||
|
name: info.originalName,
|
||||||
|
fileName: info.originalName,
|
||||||
|
url: info.url,
|
||||||
|
status: 'done',
|
||||||
|
};
|
||||||
|
return fileitem;
|
||||||
|
}
|
||||||
|
innerFileList.value = resp.map((item) => transformFile(item));
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
headers,
|
||||||
|
handleChange,
|
||||||
|
handleRemove,
|
||||||
|
beforeUpload,
|
||||||
|
innerFileList,
|
||||||
|
acceptFormat,
|
||||||
|
};
|
||||||
|
}
|
@ -1,326 +1,170 @@
|
|||||||
<script lang="ts" setup>
|
<!--
|
||||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
不再支持url 统一使用ossId
|
||||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
去除使用`file-type`库进行文件类型检测 在Safari无法使用
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadListType } from 'ant-design-vue/es/upload/interface';
|
||||||
|
|
||||||
import type { AxiosResponse } from '@vben/request';
|
import { $t, I18nT } from '@vben/locales';
|
||||||
|
|
||||||
import type { AxiosProgressEvent } from '#/api';
|
|
||||||
|
|
||||||
import { ref, toRefs, watch } from 'vue';
|
|
||||||
|
|
||||||
import { $t } from '@vben/locales';
|
|
||||||
|
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
import { message, Modal, Upload } from 'ant-design-vue';
|
import { Image, ImagePreviewGroup, Upload } from 'ant-design-vue';
|
||||||
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
|
|
||||||
|
|
||||||
import { uploadApi } from '#/api';
|
import { defaultImageAcceptExts } from './helper';
|
||||||
import { ossInfo } from '#/api/system/oss';
|
import { useImagePreview, useUpload } from './hook';
|
||||||
|
|
||||||
import { checkImageFileType, defaultImageAccept } from './helper';
|
interface Props {
|
||||||
import { UploadResultStatus } from './typing';
|
|
||||||
import { useUploadType } from './use-upload';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/**
|
/**
|
||||||
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
* 文件上传失败 是否从展示列表中删除
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
removeOnError?: boolean;
|
||||||
|
/**
|
||||||
|
* 上传成功 是否展示提示信息
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSuccessMsg?: boolean;
|
||||||
|
/**
|
||||||
|
* 删除文件前是否需要确认
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
removeConfirm?: boolean;
|
||||||
|
/**
|
||||||
|
* 同antdv参数
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
/**
|
||||||
|
* 附带的请求参数
|
||||||
|
*/
|
||||||
|
data?: any;
|
||||||
|
/**
|
||||||
|
* 最大上传图片数量
|
||||||
|
* maxCount为1时 会被绑定为string而非string[]
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
maxCount?: number;
|
||||||
|
/**
|
||||||
|
* 文件最大 单位M
|
||||||
|
* @default 5
|
||||||
*/
|
*/
|
||||||
accept?: string[];
|
|
||||||
api?: (
|
|
||||||
file: Blob | File,
|
|
||||||
onUploadProgress?: AxiosProgressEvent,
|
|
||||||
) => Promise<AxiosResponse<any>>;
|
|
||||||
disabled?: boolean;
|
|
||||||
helpText?: string;
|
|
||||||
// eslint-disable-next-line no-use-before-define
|
|
||||||
listType?: ListType;
|
|
||||||
// 最大数量的文件,Infinity不限制
|
|
||||||
maxNumber?: number;
|
|
||||||
// 文件最大多少MB
|
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
// 是否支持多选
|
|
||||||
multiple?: boolean;
|
|
||||||
// support xxx.xxx.xx
|
|
||||||
// 返回的字段 默认url
|
|
||||||
resultField?: 'fileName' | 'ossId' | 'url';
|
|
||||||
/**
|
/**
|
||||||
* 是否显示下面的描述
|
* 是否禁用
|
||||||
|
* @default false
|
||||||
*/
|
*/
|
||||||
showDescription?: boolean;
|
disabled?: boolean;
|
||||||
value?: string | string[];
|
/**
|
||||||
}>(),
|
* 同antdv的listType
|
||||||
{
|
* @default picture-card
|
||||||
value: () => [],
|
*/
|
||||||
|
listType?: UploadListType;
|
||||||
|
/**
|
||||||
|
* 是否显示文案 请上传不超过...
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
helpMessage?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持多选文件,ie10+ 支持。开启后按住 ctrl 可选择多个文件。
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
removeOnError: true,
|
||||||
|
showSuccessMsg: true,
|
||||||
|
removeConfirm: false,
|
||||||
|
accept: defaultImageAcceptExts.join(','),
|
||||||
|
data: () => undefined,
|
||||||
|
maxCount: 1,
|
||||||
|
maxSize: 5,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
listType: 'picture-card',
|
listType: 'picture-card',
|
||||||
helpText: '',
|
helpMessage: true,
|
||||||
maxSize: 2,
|
|
||||||
maxNumber: 1,
|
|
||||||
accept: () => defaultImageAccept,
|
|
||||||
multiple: false,
|
|
||||||
api: uploadApi,
|
|
||||||
resultField: 'url',
|
|
||||||
showDescription: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const emit = defineEmits(['change', 'update:value', 'delete']);
|
|
||||||
type ListType = 'picture' | 'picture-card' | 'text';
|
|
||||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
|
||||||
const isInnerOperate = ref<boolean>(false);
|
|
||||||
const { getStringAccept } = useUploadType({
|
|
||||||
acceptRef: accept,
|
|
||||||
helpTextRef: helpText,
|
|
||||||
maxNumberRef: maxNumber,
|
|
||||||
maxSizeRef: maxSize,
|
|
||||||
});
|
});
|
||||||
const previewOpen = ref<boolean>(false);
|
|
||||||
const previewImage = ref<string>('');
|
|
||||||
const previewTitle = ref<string>('');
|
|
||||||
|
|
||||||
const fileList = ref<UploadProps['fileList']>([]);
|
// 双向绑定 ossId
|
||||||
const isLtMsg = ref<boolean>(true);
|
const ossIdList = defineModel<string | string[]>('value', {
|
||||||
const isActMsg = ref<boolean>(true);
|
default: () => [],
|
||||||
const isFirstRender = ref<boolean>(true);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.value,
|
|
||||||
async (v) => {
|
|
||||||
if (isInnerOperate.value) {
|
|
||||||
isInnerOperate.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let value: string | string[] = [];
|
|
||||||
if (v) {
|
|
||||||
const _fileList: string[] = [];
|
|
||||||
if (isString(v)) {
|
|
||||||
_fileList.push(v);
|
|
||||||
}
|
|
||||||
if (isArray(v)) {
|
|
||||||
_fileList.push(...v);
|
|
||||||
}
|
|
||||||
// 直接赋值 可能为string | string[]
|
|
||||||
value = v;
|
|
||||||
const withUrlList: UploadProps['fileList'] = [];
|
|
||||||
for (const item of _fileList) {
|
|
||||||
// ossId情况
|
|
||||||
if (props.resultField === 'ossId') {
|
|
||||||
const resp = await ossInfo([item]);
|
|
||||||
if (item && isString(item)) {
|
|
||||||
withUrlList.push({
|
|
||||||
uid: item, // ossId作为uid 方便getValue获取
|
|
||||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
|
||||||
status: 'done',
|
|
||||||
url: resp?.[0]?.url,
|
|
||||||
});
|
});
|
||||||
} else if (item && isObject(item)) {
|
|
||||||
withUrlList.push({
|
|
||||||
...(item as any),
|
|
||||||
uid: item,
|
|
||||||
url: resp?.[0]?.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 非ossId情况
|
|
||||||
if (item && isString(item)) {
|
|
||||||
withUrlList.push({
|
|
||||||
uid: uniqueId(),
|
|
||||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
|
||||||
status: 'done',
|
|
||||||
url: item,
|
|
||||||
});
|
|
||||||
} else if (item && isObject(item)) {
|
|
||||||
withUrlList.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fileList.value = withUrlList;
|
|
||||||
}
|
|
||||||
if (!isFirstRender.value) {
|
|
||||||
emit('change', value);
|
|
||||||
isFirstRender.value = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
deep: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
|
const {
|
||||||
return new Promise<T>((resolve, reject) => {
|
uploadUrl,
|
||||||
const reader = new FileReader();
|
headers,
|
||||||
reader.readAsDataURL(file);
|
acceptFormat,
|
||||||
reader.addEventListener('load', () => {
|
handleChange,
|
||||||
resolve(reader.result as T);
|
handleRemove,
|
||||||
});
|
beforeUpload,
|
||||||
reader.addEventListener('error', (error) => reject(error));
|
innerFileList,
|
||||||
});
|
} = useUpload(props, ossIdList);
|
||||||
}
|
|
||||||
|
|
||||||
const handlePreview = async (file: UploadFile) => {
|
const { previewVisible, previewImage, handleCancel, handlePreview } =
|
||||||
if (!file.url && !file.preview) {
|
useImagePreview();
|
||||||
file.preview = await getBase64<string>(file.originFileObj!);
|
|
||||||
}
|
|
||||||
previewImage.value = file.url || file.preview || '';
|
|
||||||
previewOpen.value = true;
|
|
||||||
previewTitle.value =
|
|
||||||
file.name ||
|
|
||||||
previewImage.value.slice(
|
|
||||||
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 handleCancel = () => {
|
|
||||||
previewOpen.value = false;
|
|
||||||
previewTitle.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const beforeUpload = async (file: File) => {
|
|
||||||
const { maxSize, accept } = props;
|
|
||||||
const isAct = await checkImageFileType(file, accept);
|
|
||||||
if (!isAct) {
|
|
||||||
message.error($t('component.upload.acceptUpload', [accept]));
|
|
||||||
isActMsg.value = false;
|
|
||||||
// 防止弹出多个错误提示
|
|
||||||
setTimeout(() => (isActMsg.value = true), 1000);
|
|
||||||
}
|
|
||||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
|
||||||
if (isLt) {
|
|
||||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
|
||||||
isLtMsg.value = false;
|
|
||||||
// 防止弹出多个错误提示
|
|
||||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
|
||||||
}
|
|
||||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function customRequest(info: UploadRequestOption<any>) {
|
|
||||||
const { api } = props;
|
|
||||||
if (!api || !isFunction(api)) {
|
|
||||||
console.warn('upload api must exist and be a function');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 进度条事件
|
|
||||||
const progressEvent: AxiosProgressEvent = (e) => {
|
|
||||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
|
||||||
info.onProgress!({ percent });
|
|
||||||
};
|
|
||||||
const res = await api?.(info.file as File, progressEvent);
|
|
||||||
/**
|
|
||||||
* 由getValue处理 传对象过去
|
|
||||||
* 直接传string(id)会被转为Number
|
|
||||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
|
||||||
*/
|
|
||||||
info.onSuccess!(res);
|
|
||||||
message.success($t('component.upload.uploadSuccess'));
|
|
||||||
// 获取
|
|
||||||
const value = getValue();
|
|
||||||
isInnerOperate.value = true;
|
|
||||||
emit('update:value', value);
|
|
||||||
emit('change', value);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
info.onError!(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValue() {
|
|
||||||
console.log(fileList.value);
|
|
||||||
const list = (fileList.value || [])
|
|
||||||
.filter((item) => item?.status === UploadResultStatus.DONE)
|
|
||||||
.map((item: any) => {
|
|
||||||
if (item?.response && props?.resultField) {
|
|
||||||
return item?.response?.[props.resultField];
|
|
||||||
}
|
|
||||||
// ossId兼容 uid为ossId直接返回
|
|
||||||
if (props.resultField === 'ossId' && item.uid) {
|
|
||||||
return item.uid;
|
|
||||||
}
|
|
||||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
|
||||||
if (item?.url) {
|
|
||||||
return item.url;
|
|
||||||
}
|
|
||||||
// 注意这里取的key为 url
|
|
||||||
return item?.response?.url;
|
|
||||||
});
|
|
||||||
// 只有一张图片 默认绑定string而非string[]
|
|
||||||
if (props.maxNumber === 1 && list.length === 1) {
|
|
||||||
return list[0];
|
|
||||||
}
|
|
||||||
// 只有一张图片 && 删除图片时 可自行修改
|
|
||||||
if (props.maxNumber === 1 && list.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Upload
|
<Upload
|
||||||
v-bind="$attrs"
|
v-model:file-list="innerFileList"
|
||||||
v-model:file-list="fileList"
|
|
||||||
:accept="getStringAccept"
|
|
||||||
:before-upload="beforeUpload"
|
|
||||||
:custom-request="customRequest"
|
|
||||||
:disabled="disabled"
|
|
||||||
:list-type="listType"
|
:list-type="listType"
|
||||||
:max-count="maxNumber"
|
:action="uploadUrl"
|
||||||
:multiple="multiple"
|
:headers="headers"
|
||||||
|
:data="data"
|
||||||
|
:accept="accept"
|
||||||
|
:disabled="disabled"
|
||||||
|
:max-count="maxCount"
|
||||||
:progress="{ showInfo: true }"
|
:progress="{ showInfo: true }"
|
||||||
|
:multiple="multiple"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
|
@change="handleChange"
|
||||||
@remove="handleRemove"
|
@remove="handleRemove"
|
||||||
>
|
>
|
||||||
<div v-if="fileList && fileList.length < maxNumber">
|
<div v-if="innerFileList?.length < maxCount">
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
|
<div class="mt-[8px]">{{ $t('component.upload.upload') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</Upload>
|
</Upload>
|
||||||
<div
|
<I18nT
|
||||||
v-if="showDescription"
|
v-if="helpMessage"
|
||||||
class="mt-2 flex flex-wrap items-center text-[14px]"
|
scope="global"
|
||||||
|
keypath="component.upload.uploadHelpMessage"
|
||||||
|
tag="div"
|
||||||
>
|
>
|
||||||
请上传不超过
|
<template #size>
|
||||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
<span class="text-primary mx-1 font-medium"> {{ maxSize }}MB </span>
|
||||||
的
|
</template>
|
||||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
<template #ext>
|
||||||
格式文件
|
<span class="text-primary mx-1 font-medium">
|
||||||
</div>
|
{{ acceptFormat }}
|
||||||
<Modal
|
</span>
|
||||||
:footer="null"
|
</template>
|
||||||
:open="previewOpen"
|
</I18nT>
|
||||||
:title="previewTitle"
|
<ImagePreviewGroup
|
||||||
@cancel="handleCancel"
|
:preview="{
|
||||||
|
visible: previewVisible,
|
||||||
|
onVisibleChange: handleCancel,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<img :src="previewImage" alt="" style="width: 100%" />
|
<Image class="hidden" :src="previewImage" />
|
||||||
</Modal>
|
</ImagePreviewGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
.ant-upload-select-picture-card i {
|
.ant-upload-select-picture-card {
|
||||||
font-size: 32px;
|
i {
|
||||||
color: #999;
|
@apply text-[32px] text-[#999];
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-upload-select-picture-card .ant-upload-text {
|
.ant-upload-text {
|
||||||
margin-top: 8px;
|
@apply mt-[8px] text-[#666];
|
||||||
color: #666;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-list-picture-card {
|
||||||
|
.ant-upload-list-item::before {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
22
apps/web-antd/src/components/upload/src/note.md
Normal file
22
apps/web-antd/src/components/upload/src/note.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
Safari在执行到beforeUpload方法
|
||||||
|
|
||||||
|
有两种情况
|
||||||
|
|
||||||
|
1. 不继续执行 也无法上传(没有调用上传)
|
||||||
|
2. 报错
|
||||||
|
|
||||||
|
Unhandled Promise Rejection: TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController
|
||||||
|
|
||||||
|
https://github.com/oven-sh/bun/issues/12908#issuecomment-2490151231
|
||||||
|
|
||||||
|
刚开始以为是异步的问题 由于`file-type`调用了异步方法 调试也是在这里没有后续打印了
|
||||||
|
|
||||||
|
使用别的异步代码测试结果是正常上传的
|
||||||
|
|
||||||
|
```js
|
||||||
|
return new Promise<FileType>((resolve) =>
|
||||||
|
setTimeout(() => resolve(file), 2000),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
根本原因在于`file-typ`库可能不支持Safari 去掉可以正常上传
|
51
apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal file
51
apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
interface BaseUploadProps {
|
||||||
|
/**
|
||||||
|
* 文件上传失败 是否从展示列表中删除
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
removeOnError?: boolean;
|
||||||
|
/**
|
||||||
|
* 上传成功 是否展示提示信息
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSuccessMsg?: boolean;
|
||||||
|
/**
|
||||||
|
* 删除文件前是否需要确认
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
removeConfirm?: boolean;
|
||||||
|
/**
|
||||||
|
* 同antdv参数
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
/**
|
||||||
|
* 附带的请求参数
|
||||||
|
*/
|
||||||
|
data?: any;
|
||||||
|
/**
|
||||||
|
* 最大上传图片数量
|
||||||
|
* maxCount为1时 会被绑定为string而非string[]
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
maxCount?: number;
|
||||||
|
/**
|
||||||
|
* 文件最大 单位M
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
maxSize?: number;
|
||||||
|
/**
|
||||||
|
* 是否禁用
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否显示文案 请上传不超过...
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
helpMessage?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持多选文件,ie10+ 支持。开启后按住 ctrl 可选择多个文件。
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
}
|
@ -1,37 +0,0 @@
|
|||||||
import type { Recordable } from '@vben/types';
|
|
||||||
|
|
||||||
export enum UploadResultStatus {
|
|
||||||
DONE = 'done',
|
|
||||||
ERROR = 'error',
|
|
||||||
SUCCESS = 'success',
|
|
||||||
UPLOADING = 'uploading',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileItem {
|
|
||||||
thumbUrl?: string;
|
|
||||||
name: string;
|
|
||||||
size: number | string;
|
|
||||||
type?: string;
|
|
||||||
percent: number;
|
|
||||||
file: File;
|
|
||||||
status?: UploadResultStatus;
|
|
||||||
response?: Recordable<any> | { fileName: string; ossId: string; url: string };
|
|
||||||
uuid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Wrapper {
|
|
||||||
record: FileItem;
|
|
||||||
uidKey: string;
|
|
||||||
valueKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseFileItem {
|
|
||||||
uid: number | string;
|
|
||||||
url: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
export interface PreviewFileItem {
|
|
||||||
url: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
import type { Ref } from 'vue';
|
|
||||||
|
|
||||||
import { computed, unref } from 'vue';
|
|
||||||
|
|
||||||
import { $t } from '@vben/locales';
|
|
||||||
|
|
||||||
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('component.upload.accept', [accept.join(',')]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = unref(maxSizeRef);
|
|
||||||
if (maxSize) {
|
|
||||||
helpTexts.push($t('component.upload.maxSize', [maxSize]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxNumber = unref(maxNumberRef);
|
|
||||||
if (maxNumber && maxNumber !== Infinity) {
|
|
||||||
helpTexts.push($t('component.upload.maxNumber', [maxNumber]));
|
|
||||||
}
|
|
||||||
return helpTexts.join(',');
|
|
||||||
});
|
|
||||||
return { getAccept, getStringAccept, getHelpText };
|
|
||||||
}
|
|
@ -50,6 +50,9 @@
|
|||||||
"uploadError": "Upload failed",
|
"uploadError": "Upload failed",
|
||||||
"uploading": "Uploading",
|
"uploading": "Uploading",
|
||||||
"uploadWait": "Please wait for the file upload to finish",
|
"uploadWait": "Please wait for the file upload to finish",
|
||||||
"reUploadFailed": "Re-upload failed files"
|
"reUploadFailed": "Re-upload failed files",
|
||||||
|
"uploadHelpMessage": "Please upload a file in {ext} format that does not exceed {size} MB.",
|
||||||
|
"unknownFileType": "Unknown file type, unable to upload",
|
||||||
|
"confirmDelete": "Confirm file deletion {0}?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
"downloadLoading": "Downloading... Please wait.",
|
"downloadLoading": "Downloading... Please wait.",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"tip": "Tip"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,6 +50,9 @@
|
|||||||
"uploadError": "上传失败",
|
"uploadError": "上传失败",
|
||||||
"uploading": "上传中",
|
"uploading": "上传中",
|
||||||
"uploadWait": "请等待文件上传结束后操作",
|
"uploadWait": "请等待文件上传结束后操作",
|
||||||
"reUploadFailed": "重新上传失败文件"
|
"reUploadFailed": "重新上传失败文件",
|
||||||
|
"uploadHelpMessage": "请上传不超过{size}MB的{ext}格式文件",
|
||||||
|
"unknownFileType": "未知的文件类型, 无法上传",
|
||||||
|
"confirmDelete": "确认删除文件 {0}?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"generate": "生成",
|
"generate": "生成",
|
||||||
"downloadLoading": "下载中, 请稍后...",
|
"downloadLoading": "下载中, 请稍后...",
|
||||||
"preview": "预览"
|
"preview": "预览",
|
||||||
|
"tip": "提示"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,65 +1,70 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
|
||||||
|
|
||||||
|
import { h, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Alert, Card, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import { FileUpload, ImageUpload } from '#/components/upload';
|
import { FileUpload, ImageUpload } from '#/components/upload';
|
||||||
import { JsonPreview, Page } from '@vben/common-ui';
|
|
||||||
import { Alert, RadioGroup } from 'ant-design-vue';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
const resultField = ref<'ossId' | 'url'>('ossId');
|
const singleImageId = ref('1905537674682916865');
|
||||||
|
const singleFileId = ref('1905191167882518529');
|
||||||
|
const multipleImageId = ref<string[]>(['1905537674682916865']);
|
||||||
|
const multipleFileId = ref<string[]>(['1905191167882518529']);
|
||||||
|
|
||||||
const imageList = ref([]);
|
function handlePreview(file: UploadFile) {
|
||||||
const fileList = ref(['111', '2222']);
|
Modal.info({
|
||||||
const fieldOptions = [
|
content: h('div', { class: 'break-all' }, JSON.stringify(file, null, 2)),
|
||||||
{ label: 'ossId', value: 'ossId' },
|
});
|
||||||
{ label: '链接地址', value: 'url' },
|
}
|
||||||
];
|
|
||||||
const fileAccept = ['xlsx', 'word', 'pdf'];
|
|
||||||
|
|
||||||
const signleImage = ref<string>('1745443704356782081');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page content-class="flex flex-col gap-[12px]">
|
<Page>
|
||||||
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Card title="单图片上传, 会绑定为string" size="small">
|
||||||
|
<ImageUpload v-model:value="singleImageId" />
|
||||||
|
当前绑定值: {{ singleImageId }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="单文件上传, 会绑定为string" size="small">
|
||||||
|
<FileUpload v-model:value="singleFileId" />
|
||||||
|
当前绑定值: {{ singleFileId }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="多图片上传, maxCount参数控制" size="small">
|
||||||
|
<ImageUpload v-model:value="multipleImageId" :max-count="3" />
|
||||||
|
当前绑定值: {{ multipleImageId }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="多文件上传, maxCount参数控制" size="small">
|
||||||
|
<FileUpload v-model:value="multipleFileId" :max-count="3" />
|
||||||
|
当前绑定值: {{ multipleFileId }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="文件自定义预览逻辑" size="small">
|
||||||
<Alert
|
<Alert
|
||||||
:show-icon="true"
|
message="你可以自定义预览逻辑, 比如改为下载, 回调参数为文件信息(图片有默认预览逻辑 不支持自定义)"
|
||||||
message="新特性: 设置max-number为1时, 会被绑定为string而非string[]类型 省去手动转换"
|
class="my-2"
|
||||||
/>
|
|
||||||
<ImageUpload
|
|
||||||
v-model:value="signleImage"
|
|
||||||
:max-number="1"
|
|
||||||
result-field="ossId"
|
|
||||||
/>
|
|
||||||
<JsonPreview :data="signleImage" />
|
|
||||||
</div>
|
|
||||||
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
|
|
||||||
<div class="flex gap-[8px]">
|
|
||||||
<span>返回字段: </span>
|
|
||||||
<RadioGroup v-model:value="resultField" :options="fieldOptions" />
|
|
||||||
</div>
|
|
||||||
<ImageUpload
|
|
||||||
v-model:value="imageList"
|
|
||||||
:max-number="3"
|
|
||||||
:result-field="resultField"
|
|
||||||
/>
|
|
||||||
<JsonPreview :data="imageList" />
|
|
||||||
</div>
|
|
||||||
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
|
|
||||||
<div class="flex gap-[8px]">
|
|
||||||
<span>返回字段: </span>
|
|
||||||
<RadioGroup v-model:value="resultField" :options="fieldOptions" />
|
|
||||||
</div>
|
|
||||||
<Alert
|
|
||||||
:message="`支持的文件类型:${fileAccept.join(', ')}`"
|
|
||||||
:show-icon="true"
|
|
||||||
type="info"
|
|
||||||
/>
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
v-model:value="fileList"
|
v-model:value="multipleFileId"
|
||||||
:accept="fileAccept"
|
:max-count="3"
|
||||||
:max-number="3"
|
:preview="handlePreview"
|
||||||
:result-field="resultField"
|
:help-message="false"
|
||||||
/>
|
/>
|
||||||
<JsonPreview :data="fileList" />
|
当前绑定值: {{ multipleFileId }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="图片禁用上传" size="small">
|
||||||
|
<ImageUpload :disabled="true" :max-count="3" :help-message="false" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="文件禁用上传" size="small">
|
||||||
|
<FileUpload :disabled="true" :max-count="3" :help-message="false" />
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
@ -25,6 +25,6 @@ export {
|
|||||||
} from './typing';
|
} from './typing';
|
||||||
export type { CompileError } from '@intlify/core-base';
|
export type { CompileError } from '@intlify/core-base';
|
||||||
|
|
||||||
export { useI18n } from 'vue-i18n';
|
export { I18nT, useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
export type { Locale } from 'vue-i18n';
|
export type { Locale } from 'vue-i18n';
|
||||||
|
Loading…
Reference in New Issue
Block a user