优化Upload组件目录结构,以及图片上传组件 (#3241)

* fix(vxe-table): theme dark is not work

* perf(ImageUpload): 优化Upload组件目录结构,以及图片上传组件
This commit is contained in:
zhang 2023-11-06 18:24:54 +08:00 committed by GitHub
parent 031d613b18
commit dccc8f625d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 212 additions and 280 deletions

View File

@ -13,6 +13,5 @@ export { default as ApiTree } from './src/components/ApiTree.vue';
export { default as ApiRadioGroup } from './src/components/ApiRadioGroup.vue'; export { default as ApiRadioGroup } from './src/components/ApiRadioGroup.vue';
export { default as ApiCascader } from './src/components/ApiCascader.vue'; export { default as ApiCascader } from './src/components/ApiCascader.vue';
export { default as ApiTransfer } from './src/components/ApiTransfer.vue'; export { default as ApiTransfer } from './src/components/ApiTransfer.vue';
export { default as ImageUpload } from './src/components/ImageUpload.vue';
export { BasicForm }; export { BasicForm };

View File

@ -27,8 +27,7 @@ import ApiTree from './components/ApiTree.vue';
import ApiTreeSelect from './components/ApiTreeSelect.vue'; import ApiTreeSelect from './components/ApiTreeSelect.vue';
import ApiCascader from './components/ApiCascader.vue'; import ApiCascader from './components/ApiCascader.vue';
import ApiTransfer from './components/ApiTransfer.vue'; import ApiTransfer from './components/ApiTransfer.vue';
import ImageUpload from './components/ImageUpload.vue'; import { BasicUpload, ImageUpload } from '/@/components/Upload';
import { BasicUpload } from '/@/components/Upload';
import { StrengthMeter } from '/@/components/StrengthMeter'; import { StrengthMeter } from '/@/components/StrengthMeter';
import { IconPicker } from '/@/components/Icon'; import { IconPicker } from '/@/components/Icon';
import { CountdownInput } from '/@/components/CountDown'; import { CountdownInput } from '/@/components/CountDown';

View File

@ -1,253 +0,0 @@
<template>
<div class="clearfix">
<a-upload
v-model:file-list="fileList"
:list-type="listType"
:multiple="multiple"
:max-count="maxCount"
:customRequest="handleCustomRequest"
:before-upload="handleBeforeUpload"
v-bind="$attrs"
@preview="handlePreview"
v-model:value="state"
>
<div v-if="fileList.length < maxCount">
<plus-outlined />
<div style="margin-top: 8px">
{{ t('component.upload.upload') }}
</div>
</div>
</a-upload>
<a-modal :open="previewOpen" :footer="null" @cancel="handleCancel">
<img alt="example" style="width: 100%" :src="previewImage" />
</a-modal>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, reactive, ref, watch } from 'vue';
import { message, Modal, Upload, UploadProps } from 'ant-design-vue';
import { UploadFile } from 'ant-design-vue/lib/upload/interface';
import { useI18n } from '@/hooks/web/useI18n';
import { join } from 'lodash-es';
import { buildShortUUID } from '@/utils/uuid';
import { isArray, isNotEmpty, isUrl } from '@/utils/is';
import { useRuleFormItem } from '@/hooks/component/useFormItem';
import { useAttrs } from '@vben/hooks';
import { PlusOutlined } from '@ant-design/icons-vue';
type ImageUploadType = 'text' | 'picture' | 'picture-card';
export default defineComponent({
name: 'ImageUpload',
components: {
PlusOutlined,
AUpload: Upload,
AModal: Modal,
},
inheritAttrs: false,
props: {
value: [Array, String],
api: {
type: Function as PropType<(file: UploadFile) => Promise<string>>,
default: null,
},
listType: {
type: String as PropType<ImageUploadType>,
default: () => 'picture-card',
},
//
fileType: {
type: Array,
default: () => ['image/png', 'image/jpeg'],
},
multiple: {
type: Boolean,
default: () => false,
},
//
maxCount: {
type: Number,
default: () => 1,
},
//
minCount: {
type: Number,
default: () => 0,
},
// MB
maxSize: {
type: Number,
default: () => 2,
},
},
emits: ['change', 'update:value'],
setup(props, { emit }) {
const attrs = useAttrs();
const { t } = useI18n();
const previewOpen = ref(false);
const previewImage = ref('');
const emitData = ref<any[] | any | undefined>();
const fileList = ref<UploadFile[]>([]);
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
const fileState = reactive<{
newList: any[];
newStr: string;
oldStr: string;
}>({
newList: [],
newStr: '',
oldStr: '',
});
watch(
() => fileList.value,
(v) => {
fileState.newList = v
.filter((item: any) => {
return item?.url && item.status === 'done' && isUrl(item?.url);
})
.map((item: any) => item?.url);
fileState.newStr = join(fileState.newList);
//
if (fileState.newStr !== fileState.oldStr) {
fileState.oldStr = fileState.newStr;
emitData.value = props.multiple ? fileState.newList : fileState.newStr;
state.value = props.multiple ? fileState.newList : fileState.newStr;
}
},
{
deep: true,
},
);
watch(
() => state.value,
(v) => {
changeFileValue(v);
emit('update:value', v);
},
);
function changeFileValue(value: any) {
const stateStr = props.multiple ? join((value as string[]) || []) : value || '';
if (stateStr !== fileState.oldStr) {
fileState.oldStr = stateStr;
let list: string[] = [];
if (props.multiple) {
if (isNotEmpty(value)) {
if (isArray(value)) {
list = value as string[];
} else {
list.push(value as string);
}
}
} else {
if (isNotEmpty(value)) {
list.push(value as string);
}
}
fileList.value = list.map((item) => {
const uuid = buildShortUUID();
return {
uid: uuid,
name: uuid,
status: 'done',
url: item,
};
});
}
}
/** 关闭查看 */
const handleCancel = () => {
previewOpen.value = false;
};
/** 查看图片 */
// @ts-ignore
const handlePreview = async (file: UploadProps['fileList'][number]) => {
if (!file.url && !file.preview) {
file.preview = (await getBase64(file.originFileObj)) as string;
}
previewImage.value = file.url || file.preview;
previewOpen.value = true;
};
/** 上传前校验 */
const handleBeforeUpload: UploadProps['beforeUpload'] = (file) => {
if (fileList.value.length > props.maxCount) {
fileList.value.splice(props.maxCount, fileList.value.length - props.maxCount);
message.error(t('component.upload.maxNumber', [props.maxCount]));
return Upload.LIST_IGNORE;
}
const isPNG = props.fileType.includes(file.type);
if (!isPNG) {
message.error(t('component.upload.acceptUpload', [props.fileType.toString()]));
}
const isLt2M = file.size / 1024 / 1024 < props.maxSize;
if (!isLt2M) {
message.error(t('component.upload.maxSizeMultiple', [props.maxSize]));
}
if (!(isPNG && isLt2M)) {
fileList.value.pop();
}
return (isPNG && isLt2M) || Upload.LIST_IGNORE;
};
/** 自定义上传 */
const handleCustomRequest = async (option: any) => {
const { file } = option;
await props
.api(option)
.then((url) => {
file.url = url;
file.status = 'done';
fileList.value.pop();
fileList.value.push(file);
})
.catch(() => {
fileList.value.pop();
});
};
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
}
return {
previewOpen,
fileList,
state,
attrs,
t,
handlePreview,
handleBeforeUpload,
handleCustomRequest,
handleCancel,
previewImage,
};
},
});
</script>
<style scoped>
/* you can make up upload button and sample style by using stylesheets */
.ant-upload-select-picture-card i {
color: #999;
font-size: 32px;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@ -82,7 +82,7 @@
display: inline-block; display: inline-block;
width: 96px; width: 96px;
height: 56px; height: 56px;
line-height: 56px; line-height: 56px !important;
} }
&-confirm-body { &-confirm-body {

View File

@ -1,4 +1,6 @@
import { withInstall } from '/@/utils'; import { withInstall } from '/@/utils';
import basicUpload from './src/BasicUpload.vue'; import basicUpload from './src/BasicUpload.vue';
import uploadImage from './src/components/ImageUpload.vue';
export const ImageUpload = withInstall(uploadImage);
export const BasicUpload = withInstall(basicUpload); export const BasicUpload = withInstall(basicUpload);

View File

@ -47,8 +47,8 @@
import { omit } from 'lodash-es'; import { omit } from 'lodash-es';
import { useI18n } from '/@/hooks/web/useI18n'; import { useI18n } from '/@/hooks/web/useI18n';
import { isArray } from '/@/utils/is'; import { isArray } from '/@/utils/is';
import UploadModal from './UploadModal.vue'; import UploadModal from './components/UploadModal.vue';
import UploadPreviewModal from './UploadPreviewModal.vue'; import UploadPreviewModal from './components/UploadPreviewModal.vue';
export default defineComponent({ export default defineComponent({
name: 'BasicUpload', name: 'BasicUpload',

View File

@ -1,5 +1,5 @@
<script lang="tsx"> <script lang="tsx">
import { fileListProps } from './props'; import { fileListProps } from '../props';
import { isFunction, isDef } from '/@/utils/is'; import { isFunction, isDef } from '/@/utils/is';
import { useSortable } from '/@/hooks/web/useSortable'; import { useSortable } from '/@/hooks/web/useSortable';
import { useModalContext } from '/@/components/Modal/src/hooks/useModalContext'; import { useModalContext } from '/@/components/Modal/src/hooks/useModalContext';

View File

@ -0,0 +1,161 @@
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:list-type="listType"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
@preview="handlePreview"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<plus-outlined />
<div style="margin-top: 8px">{{ t('component.upload.upload') }}</div>
</div>
</Upload>
<Modal :open="previewOpen" :title="previewTitle" :footer="null" @cancel="handleCancel">
<img alt="" style="width: 100%" :src="previewImage" />
</Modal>
</div>
</template>
<script lang="ts" setup name="ImageUpload">
import { ref, toRefs, watch } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { Upload, Modal } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import { useMessage } from '@/hooks/web/useMessage';
import { isArray, isFunction } from '@/utils/is';
import { warn } from '@/utils/log';
import { useI18n } from '@/hooks/web/useI18n';
import { useUploadType } from '../hooks/useUpload';
import { uploadContainerProps } from '../props';
import { isImgTypeByName } from '../helper';
const emit = defineEmits(['change', 'update:value', 'delete']);
const props = defineProps({
...uploadContainerProps,
});
const { t } = useI18n();
const { createMessage } = useMessage();
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
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']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
watch(
() => props.value,
(v) => {
if (isArray(v)) {
fileList.value = v.map((url, i) => ({
uid: String(-i),
name: url ? url.substring(url.lastIndexOf('/') + 1) : 'image.png',
status: 'done',
url,
}));
}
},
{
immediate: true,
deep: true,
},
);
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
}
const handlePreview = async (file: UploadProps['fileList'][number]) => {
if (!file.url && !file.preview) {
file.preview = (await getBase64(file.originFileObj)) as string;
}
previewImage.value = file.url || file.preview;
previewOpen.value = true;
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1);
};
const handleRemove = async (file: UploadProps['fileList'][number]) => {
if (fileList.value) {
const index = fileList.value.findIndex((item: any) => item.uuid === file.uuid);
index !== -1 && fileList.value.splice(index, 1);
emit('change', fileList.value);
emit('delete', file);
}
};
const handleCancel = () => {
previewOpen.value = false;
previewTitle.value = '';
};
const beforeUpload = (file: File) => {
const { maxSize, accept } = props;
const { name } = file;
isActMsg.value = isImgTypeByName(name);
if (!isActMsg.value) {
createMessage.error(t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
//
setTimeout(() => (isActMsg.value = true), 1000);
}
isLtMsg.value = file.size / 1024 / 1024 > maxSize;
if (isLtMsg.value) {
createMessage.error(t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
//
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isActMsg.value && !isLtMsg.value) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
return warn('upload api must exist and be a function');
}
try {
const res = await props.api?.({
data: {
...(props.uploadParams || {}),
},
file: info.file,
name: props.name,
filename: props.filename,
});
info.onSuccess!(res.data);
emit('change', fileList.value);
} catch (e: any) {
info.onError!(e);
}
}
</script>
<style lang="less">
.ant-upload-select-picture-card i {
color: #999;
font-size: 32px;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@ -53,14 +53,14 @@
import { Upload, Alert } from 'ant-design-vue'; import { Upload, Alert } from 'ant-design-vue';
import { BasicModal, useModalInner } from '/@/components/Modal'; import { BasicModal, useModalInner } from '/@/components/Modal';
// hooks // hooks
import { useUploadType } from './useUpload'; import { useUploadType } from '../hooks/useUpload';
import { useMessage } from '/@/hooks/web/useMessage'; import { useMessage } from '/@/hooks/web/useMessage';
// types // types
import { FileItem, UploadResultStatus } from './typing'; import { FileItem, UploadResultStatus } from '../types/typing';
import { basicProps } from './props'; import { basicProps } from '../props';
import { createTableColumns, createActionColumn } from './data'; import { createTableColumns, createActionColumn } from './data';
// utils // utils
import { checkImgType, getBase64WithFile } from './helper'; import { checkImgType, getBase64WithFile } from '../helper';
import { buildUUID } from '/@/utils/uuid'; import { buildUUID } from '/@/utils/uuid';
import { isFunction } from '/@/utils/is'; import { isFunction } from '/@/utils/is';
import { warn } from '/@/utils/log'; import { warn } from '/@/utils/log';
@ -193,7 +193,7 @@
); );
const { data } = ret; const { data } = ret;
item.status = UploadResultStatus.SUCCESS; item.status = UploadResultStatus.SUCCESS;
item.responseData = data; item.response = data;
return { return {
success: true, success: true,
error: null, error: null,
@ -247,9 +247,9 @@
const fileList: string[] = []; const fileList: string[] = [];
for (const item of fileListRef.value) { for (const item of fileListRef.value) {
const { status, responseData } = item; const { status, response } = item;
if (status === UploadResultStatus.SUCCESS && responseData) { if (status === UploadResultStatus.SUCCESS && response) {
fileList.push(responseData.url); fileList.push(response.url);
} }
} }
// //

View File

@ -14,8 +14,8 @@
import { defineComponent, watch, ref } from 'vue'; import { defineComponent, watch, ref } from 'vue';
import FileList from './FileList.vue'; import FileList from './FileList.vue';
import { BasicModal, useModalInner } from '/@/components/Modal'; import { BasicModal, useModalInner } from '/@/components/Modal';
import { previewProps } from './props'; import { previewProps } from '../props';
import { PreviewFileItem } from './typing'; import { PreviewFileItem } from '../types/typing';
import { downloadByUrl } from '/@/utils/file/download'; import { downloadByUrl } from '/@/utils/file/download';
import { createPreviewColumns, createPreviewActionColumn } from './data'; import { createPreviewColumns, createPreviewActionColumn } from './data';
import { useI18n } from '/@/hooks/web/useI18n'; import { useI18n } from '/@/hooks/web/useI18n';

View File

@ -1,6 +1,6 @@
import type { BasicColumn, ActionItem } from '/@/components/Table'; import type { BasicColumn, ActionItem } from '/@/components/Table';
import { FileBasicColumn, FileItem, PreviewFileItem, UploadResultStatus } from './typing'; import { FileBasicColumn, FileItem, PreviewFileItem, UploadResultStatus } from '../types/typing';
import { isImgTypeByName } from './helper'; import { isImgTypeByName } from '../helper';
import { Progress, Tag } from 'ant-design-vue'; import { Progress, Tag } from 'ant-design-vue';
import TableAction from '/@/components/Table/src/components/TableAction.vue'; import TableAction from '/@/components/Table/src/components/TableAction.vue';
import ThumbUrl from './ThumbUrl.vue'; import ThumbUrl from './ThumbUrl.vue';

View File

@ -1,5 +1,5 @@
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { FileBasicColumn } from './typing'; import { FileBasicColumn } from './types/typing';
import type { Options } from 'sortablejs'; import type { Options } from 'sortablejs';
@ -13,7 +13,13 @@ type SortableOptions = Merge<
} }
>; >;
type ListType = 'text' | 'picture' | 'picture-card';
export const basicProps = { export const basicProps = {
listType: {
type: String as PropType<ListType>,
default: 'picture-card',
},
helpText: { helpText: {
type: String as PropType<string>, type: String as PropType<string>,
default: '', default: '',

View File

@ -1,4 +1,4 @@
import { BasicColumn } from '../../Table'; import { BasicColumn } from '/@/components/Table';
import { UploadApiResult } from '/@/api/sys/model/uploadModel'; import { UploadApiResult } from '/@/api/sys/model/uploadModel';
export enum UploadResultStatus { export enum UploadResultStatus {
@ -15,7 +15,7 @@ export interface FileItem {
percent: number; percent: number;
file: File; file: File;
status?: UploadResultStatus; status?: UploadResultStatus;
responseData?: UploadApiResult; response?: UploadApiResult;
uuid: string; uuid: string;
} }

View File

@ -70,6 +70,7 @@
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { areaRecord } from '/@/api/demo/cascader'; import { areaRecord } from '/@/api/demo/cascader';
import { uploadApi } from '/@/api/sys/upload'; import { uploadApi } from '/@/api/sys/upload';
// import { isArray } from '/@/utils/is';
const valueSelectA = ref<string[]>([]); const valueSelectA = ref<string[]>([]);
const valueSelectB = ref<string[]>([]); const valueSelectB = ref<string[]>([]);
@ -743,13 +744,30 @@
{ {
field: 'field23', field: 'field23',
component: 'ImageUpload', component: 'ImageUpload',
label: '字段23', label: '上传图片',
colProps: { required: true,
span: 8, defaultValue: [
}, 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
],
componentProps: { componentProps: {
api: () => Promise.resolve('https://via.placeholder.com/600/92c952'), api: uploadApi,
accept: ['png', 'jpeg', 'jpg'],
maxSize: 2,
maxNumber: 1,
}, },
// rules: [
// {
// required: true,
// trigger: 'change',
// validator(_, value) {
// if (isArray(value) && value.length > 0) {
// return Promise.resolve();
// } else {
// return Promise.reject('');
// }
// },
// },
// ],
}, },
]; ];