提交 746d4a74 编写于 作者: J jq

wip: add upload component

上级 2b95be80
......@@ -5,7 +5,7 @@ VITE_USE_MOCK = true
VITE_PUBLIC_PATH = /
# Cross-domain proxy, you can configure multiple
VITE_PROXY=[["/api","http://localhost:3000"]]
VITE_PROXY=[["/api","http://localhost:3000"],["/upload","http://localhost:3001/upload"]]
# VITE_PROXY=[["/api","https://vvbin.cn/test"]]
# Delete console
......
export interface UploadApiResult {
message: string;
code: number;
url: string;
}
import { UploadApiResult } from './model/uploadModel';
import { defHttp } from '/@/utils/http/axios';
import { UploadFileParams } from '/@/utils/http/axios/types';
enum Api {
UPLOAD_URL = '/upload',
}
/**
* @description: 上传接口
*/
export function uploadApi(
params: UploadFileParams,
onUploadProgress: (progressEvent: ProgressEvent) => void
) {
return defHttp.uploadFile<UploadApiResult>(
{
url: Api.UPLOAD_URL,
onUploadProgress,
},
params
);
}
export interface ActionItem {
on?: any;
onClick?: any;
label: string;
disabled?: boolean;
color?: 'success' | 'error' | 'warning';
......
export { default as UploadContainer } from './src/UploadContainer.vue';
// export * from './src/types';
<template>
<span>
<img v-if="fileUrl" :src="fileUrl" />
<span v-else>{{ fileType }}</span>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
fileUrl: {
type: String,
default: '',
},
fileType: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
},
setup() {
return {};
},
});
</script>
<template>
<div>
<a-button-group>
<a-button type="primary" @click="openUploadModal">上传</a-button>
<a-button @click="openPreviewModal">
<Icon icon="ant-design:eye-outlined" />
</a-button>
</a-button-group>
<UploadModal v-bind="$props" @register="registerUploadModal" @change="handleChange" />
<UploadPreviewModal
:value="fileListRef"
@register="registerPreviewModal"
@change="handlePreviewChange"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, unref } from 'vue';
import { useModal } from '/@/components/Modal';
import UploadModal from './UploadModal.vue';
import { uploadContainerProps } from './props';
import UploadPreviewModal from './UploadPreviewModal.vue';
import Icon from '/@/components/Icon/index';
export default defineComponent({
components: { UploadModal, UploadPreviewModal, Icon },
props: uploadContainerProps,
setup(props, { emit }) {
// 上传modal
const [registerUploadModal, { openModal: openUploadModal }] = useModal();
// 预览modal
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
const fileListRef = ref<string[]>([]);
watch(
() => props.value,
(value) => {
fileListRef.value = [...(value || [])];
},
{ immediate: true }
);
// 上传modal保存操作
function handleChange(urls: string[]) {
fileListRef.value = [...unref(fileListRef), ...(urls || [])];
emit('change', fileListRef.value);
}
// 预览modal保存操作
function handlePreviewChange(urls: string[]) {
fileListRef.value = [...(urls || [])];
emit('change', fileListRef.value);
}
return {
registerUploadModal,
openUploadModal,
handleChange,
handlePreviewChange,
registerPreviewModal,
openPreviewModal,
fileListRef,
};
},
});
</script>
<template>
<BasicModal
v-bind="$attrs"
@register="register"
@ok="handleOk"
:closeFunc="handleCloseFunc"
:maskClosable="false"
width="800px"
title="上传组件"
wrapClassName="upload-modal"
:okButtonProps="{ disabled: isUploadingRef }"
:cancelButtonProps="{ disabled: isUploadingRef }"
>
<template #centerdFooter>
<a-button @click="handleStartUpload" color="success" :loading="isUploadingRef">
{{ isUploadingRef ? '上传中' : '开始上传' }}
</a-button>
</template>
<Upload :accept="getStringAccept" :multiple="multiple" :before-upload="beforeUpload">
<a-button type="primary"> 选择文件 </a-button>
<span class="px-2">{{ getHelpText }}</span>
</Upload>
<BasicTable @register="registerTable" :dataSource="fileListRef" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRef, unref } from 'vue';
import { Upload } from 'ant-design-vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicTable, useTable } from '/@/components/Table';
// hooks
import { useUploadType } from './useUpload';
import { useMessage } from '/@/hooks/web/useMessage';
// types
import { FileItem, UploadResultStatus } from './types';
import { basicProps } from './props';
import { createTableColumns, createActionColumn } from './data';
// utils
import { checkFileType, checkImgType, getBase64WithFile } from './utils';
import { buildUUID } from '/@/utils/uuid';
import { createImgPreview } from '/@/components/Preview/index';
import { uploadApi } from '/@/api/demo/upload';
export default defineComponent({
components: { BasicModal, Upload, BasicTable },
props: basicProps,
setup(props, { emit }) {
const [register, { closeModal }] = useModalInner();
const { getAccept, getStringAccept, getHelpText } = useUploadType({
acceptRef: toRef(props, 'accept'),
helpTextRef: toRef(props, 'helpText'),
maxNumberRef: toRef(props, 'maxNumber'),
maxSizeRef: toRef(props, 'maxSize'),
});
const fileListRef = ref<FileItem[]>([]);
const state = reactive<{ fileList: FileItem[] }>({ fileList: [] });
const { createMessage } = useMessage();
// 上传前校验
function beforeUpload(file: File) {
const { size, name } = file;
const { maxSize } = props;
const accept = unref(getAccept);
// 设置最大值,则判断
if (maxSize && file.size / 1024 / 1024 >= maxSize) {
createMessage.error(`只能上传不超过${maxSize}MB的文件!`);
return false;
}
// 设置类型,则判断
if (accept.length > 0 && !checkFileType(file, accept)) {
createMessage.error!(`只能上传${accept.join(',')}格式文件`);
return false;
}
// 生成图片缩略图
if (checkImgType(file)) {
// beforeUpload,如果异步会调用自带上传方法
// file.thumbUrl = await getBase64(file);
getBase64WithFile(file).then(({ result: thumbUrl }) => {
fileListRef.value = [
...unref(fileListRef),
{
uuid: buildUUID(),
file,
thumbUrl,
size,
name,
percent: 0,
type: name.split('.').pop(),
},
];
});
} else {
fileListRef.value = [
...unref(fileListRef),
{
uuid: buildUUID(),
file,
size,
name,
percent: 0,
type: name.split('.').pop(),
},
];
}
return false;
}
// 删除
function handleRemove(record: FileItem) {
const index = fileListRef.value.findIndex((item) => item.uuid === record.uuid);
index !== -1 && fileListRef.value.splice(index, 1);
}
// 预览
function handlePreview(record: FileItem) {
const { thumbUrl = '' } = record;
createImgPreview({
imageList: [thumbUrl],
});
}
const [registerTable] = useTable({
columns: createTableColumns(),
actionColumn: createActionColumn(handleRemove, handlePreview),
pagination: false,
});
// 是否正在上传
const isUploadingRef = ref(false);
async function uploadApiByItem(item: FileItem) {
try {
item.status = UploadResultStatus.UPLOADING;
const { data } = await uploadApi(
{
file: item.file,
},
function onUploadProgress(progressEvent: ProgressEvent) {
const complete = ((progressEvent.loaded / progressEvent.total) * 100) | 0;
item.percent = complete;
}
);
item.status = UploadResultStatus.SUCCESS;
item.responseData = data;
return {
success: true,
error: null,
};
} catch (e) {
console.log(e);
item.status = UploadResultStatus.ERROR;
return {
success: false,
error: e,
};
}
}
// 点击开始上传
async function handleStartUpload() {
try {
isUploadingRef.value = true;
const data = await Promise.all(
unref(fileListRef).map((item) => {
return uploadApiByItem(item);
})
);
isUploadingRef.value = false;
// 生产环境:抛出错误
const errorList = data.filter((item) => !item.success);
if (errorList.length > 0) {
throw errorList;
}
} catch (e) {
isUploadingRef.value = false;
throw e;
}
}
// 点击保存
function handleOk() {
// TODO: 没起作用:okButtonProps={{ disabled: state.isUploading }}
if (isUploadingRef.value) {
createMessage.warning('请等待文件上传后,保存');
return;
}
const fileList: string[] = [];
for (const item of fileListRef.value) {
const { status, responseData } = item;
if (status === UploadResultStatus.SUCCESS && responseData) {
fileList.push(responseData.url);
}
}
// 存在一个上传成功的即可保存
if (fileList.length <= 0) {
createMessage.warning('没有上传成功的文件,无法保存');
return;
}
console.log(fileList);
emit('change', fileList);
fileListRef.value = [];
closeModal();
}
// 点击关闭:则所有操作不保存,包括上传的
function handleCloseFunc() {
if (!isUploadingRef.value) {
fileListRef.value = [];
return true;
} else {
createMessage.warning('请等待文件上传结束后操作');
return false;
}
}
return {
register,
closeModal,
getHelpText,
getStringAccept,
beforeUpload,
registerTable,
fileListRef,
state,
isUploadingRef,
handleStartUpload,
handleOk,
handleCloseFunc,
};
},
});
</script>
<style lang="less">
// /deep/ .ant-upload-list {
// display: none;
// }
.upload-modal {
.ant-upload-list {
display: none;
}
.ant-table-wrapper .ant-spin-nested-loading {
padding: 0;
}
}
</style>
<template>
<BasicModal
wrapClassName="upload-preview-modal"
v-bind="$attrs"
width="800px"
@register="register"
title="预览"
:showOkBtn="false"
>
<BasicTable @register="registerTable" :dataSource="fileListRef" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, watch, ref, unref } from 'vue';
import { BasicTable, useTable } from '/@/components/Table';
import { createPreviewColumns, createPreviewActionColumn } from './data';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { priviewProps } from './props';
import { PreviewFileItem } from './types';
import { createImgPreview } from '/@/components/Preview/index';
import { downloadByUrl } from '/@/utils/file/FileDownload';
export default defineComponent({
components: { BasicModal, BasicTable },
props: priviewProps,
setup(props, { emit }) {
const [register, { closeModal }] = useModalInner();
const fileListRef = ref<PreviewFileItem[]>([]);
watch(
() => props.value,
(value) => {
fileListRef.value = [];
value.forEach((item) => {
fileListRef.value = [
...unref(fileListRef),
{
url: item,
type: item.split('.').pop() || '',
name: item.split('/').pop() || '',
},
];
});
},
{ immediate: true }
);
// 删除
function handleRemove(record: PreviewFileItem) {
const index = fileListRef.value.findIndex((item) => item.url === record.url);
if (index !== -1) {
fileListRef.value.splice(index, 1);
emit(
'change',
fileListRef.value.map((item) => item.url)
);
}
}
// 预览
function handlePreview(record: PreviewFileItem) {
const { url = '' } = record;
createImgPreview({
imageList: [url],
});
}
// 下载
function handleDownload(record: PreviewFileItem) {
const { url = '' } = record;
downloadByUrl({ url });
}
const [registerTable] = useTable({
columns: createPreviewColumns(),
pagination: false,
actionColumn: createPreviewActionColumn({ handleRemove, handlePreview, handleDownload }),
});
return {
register,
closeModal,
fileListRef,
registerTable,
};
},
});
</script>
<style lang="less">
.upload-preview-modal {
.ant-upload-list {
display: none;
}
.ant-table-wrapper .ant-spin-nested-loading {
padding: 0;
}
}
</style>
// import { BasicColumn, TableAction, ActionItem } from '@/components/table';
import { checkImgType, isImgTypeByName } from './utils';
// import ThumnUrl from './ThumbUrl.vue';
import { Progress } from 'ant-design-vue';
import { FileItem, PreviewFileItem, UploadResultStatus } from './types';
// import { ElecArchivesSaveResult } from '@/api/biz/file/model/fileModel';
// import { quryFile } from '@/api/biz/file/file';
import { BasicColumn, ActionItem, TableAction } from '/@/components/Table/index';
// 文件上传列表
export function createTableColumns(): BasicColumn[] {
return [
{
dataIndex: 'thumbUrl',
title: '图例',
width: 100,
customRender: ({ record }) => {
const { thumbUrl, type } = (record as FileItem) || {};
return <span>{thumbUrl ? <img src={thumbUrl} style={{ width: '50px' }} /> : type}</span>;
// return <ThumnUrl fileUrl={thumbUrl} fileType={type} fileName={type} />;
},
},
{
dataIndex: 'name',
title: '文件名',
align: 'left',
customRender: ({ text, record }) => {
const { percent, status: uploadStatus } = (record as FileItem) || {};
let status = 'normal';
if (uploadStatus === UploadResultStatus.ERROR) {
status = 'exception';
} else if (uploadStatus === UploadResultStatus.UPLOADING) {
status = 'active';
} else if (uploadStatus === UploadResultStatus.SUCCESS) {
status = 'success';
}
return (
<span>
<p class="ellipsis mb-1" title={text}>
{text}
</p>
<Progress percent={percent} size="small" status={status} />
</span>
);
},
},
{
dataIndex: 'size',
title: '文件大小',
width: 100,
customRender: ({ text = 0 }) => {
return text && (text / 1024).toFixed(2) + 'KB';
},
},
// {
// dataIndex: 'type',
// title: '文件类型',
// width: 100,
// },
{
dataIndex: 'status',
title: '状态',
width: 100,
customRender: ({ text }) => {
if (text === UploadResultStatus.SUCCESS) {
return '上传成功';
} else if (text === UploadResultStatus.ERROR) {
return '上传失败';
} else if (text === UploadResultStatus.UPLOADING) {
return '上传中';
}
return text;
},
},
];
}
export function createActionColumn(handleRemove: Function, handlePreview: Function): BasicColumn {
return {
width: 120,
title: '操作',
dataIndex: 'action',
fixed: false,
customRender: ({ record }) => {
const actions: ActionItem[] = [
{
label: '删除',
onClick: handleRemove.bind(null, record),
},
];
if (checkImgType(record)) {
actions.unshift({
label: '预览',
onClick: handlePreview.bind(null, record),
});
}
return <TableAction actions={actions} />;
},
};
}
// 文件预览列表
export function createPreviewColumns(): BasicColumn[] {
return [
{
dataIndex: 'url',
title: '图例',
width: 100,
customRender: ({ record }) => {
const { url, type } = (record as PreviewFileItem) || {};
return (
<span>{isImgTypeByName(url) ? <img src={url} style={{ width: '50px' }} /> : type}</span>
);
},
},
{
dataIndex: 'name',
title: '文件名',
align: 'left',
},
];
}
export function createPreviewActionColumn({
handleRemove,
handlePreview,
handleDownload,
}: {
handleRemove: Function;
handlePreview: Function;
handleDownload: Function;
}): BasicColumn {
return {
width: 160,
title: '操作',
dataIndex: 'action',
fixed: false,
customRender: ({ record }) => {
const { url } = (record as PreviewFileItem) || {};
const actions: ActionItem[] = [
{
label: '删除',
onClick: handleRemove.bind(null, record),
},
{
label: '下载',
onClick: handleDownload.bind(null, record),
},
];
if (isImgTypeByName(url)) {
actions.unshift({
label: '预览',
onClick: handlePreview.bind(null, record),
});
}
return <TableAction actions={actions} />;
},
};
}
import type { PropType } from 'vue';
export const basicProps = {
helpText: {
type: String as PropType<string>,
default: '',
},
// 文件最大多少MB
maxSize: {
type: Number as PropType<number>,
default: 2,
},
// 最大数量的文件,0不限制
maxNumber: {
type: Number as PropType<number>,
default: 0,
},
// 根据后缀,或者其他
accept: {
type: Array as PropType<string[]>,
default: () => [],
},
multiple: {
type: Boolean,
default: true,
},
};
export const uploadContainerProps = {
value: {
type: Array as PropType<string[]>,
default: () => [],
},
...basicProps,
};
export const priviewProps = {
value: {
type: Array as PropType<string[]>,
default: () => [],
},
};
import { UploadApiResult } from '/@/api/demo/model/uploadModel';
export enum UploadResultStatus {
SUCCESS = 'success',
ERROR = 'error',
UPLOADING = 'uploading',
}
export interface FileItem {
thumbUrl?: string;
name: string;
size: string | number;
type?: string;
percent: number;
file: File;
status?: UploadResultStatus;
responseData?: UploadApiResult;
uuid: string;
}
export interface PreviewFileItem {
url: string;
name: string;
type: string;
}
import { Ref, unref, computed } from 'vue';
export function useUploadType({
acceptRef,
// uploadTypeRef,
helpTextRef,
maxNumberRef,
maxSizeRef,
}: {
acceptRef: Ref<string[]>;
// uploadTypeRef: Ref<UploadTypeEnum>;
helpTextRef: Ref<string>;
maxNumberRef: Ref<number>;
maxSizeRef: Ref<number>;
}) {
// 文件类型限制
const getAccept = computed(() => {
// const uploadType = unref(uploadTypeRef);
const accept = unref(acceptRef);
if (accept && accept.length > 0) {
return accept;
}
return [];
});
const getStringAccept = computed(() => {
return unref(getAccept)
.map((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(`支持${accept.join(',')}格式`);
}
const maxSize = unref(maxSizeRef);
if (maxSize) {
helpTexts.push(`不超过${maxSize}MB`);
}
const maxNumber = unref(maxNumberRef);
if (maxNumber) {
helpTexts.push(`最多可选择${maxNumber}个文件`);
}
return helpTexts.join('');
});
return { getAccept, getStringAccept, getHelpText };
}
export function checkFileType(file: File, accepts: string[]) {
const newTypes = accepts.join('|');
// const reg = /\.(jpg|jpeg|png|gif|txt|doc|docx|xls|xlsx|xml)$/i;
const reg = new RegExp('\\.(' + newTypes + ')$', 'i');
if (!reg.test(file.name)) {
return false;
} else {
return true;
}
}
export function checkImgType(file: File) {
return /\.(jpg|jpeg|png|gif)$/i.test(file.name);
}
export function isImgTypeByName(name: string) {
return /\.(jpg|jpeg|png|gif)$/i.test(name);
}
export function getBase64WithFile(file: File) {
return new Promise<{
result: string;
file: File;
}>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve({ result: reader.result as string, file });
reader.onerror = (error) => reject(error);
});
}
......@@ -38,6 +38,10 @@ const menu: MenuModule = {
path: 'strength-meter',
name: '密码强度组件',
},
{
path: 'upload',
name: '上传组件',
},
{
path: 'scroll',
name: '滚动组件',
......
......@@ -170,5 +170,13 @@ export default {
title: '密码强度组件',
},
},
{
path: '/upload',
name: 'UploadDemo',
component: () => import('/@/views/demo/comp/upload/index.vue'),
meta: {
title: '上传组件',
},
},
],
} as AppRouteModule;
......@@ -5,9 +5,10 @@ import { AxiosCanceler } from './axiosCancel';
import { isFunction } from '/@/utils/is';
import { cloneDeep } from 'lodash-es';
import type { RequestOptions, CreateAxiosOptions, Result } from './types';
import type { RequestOptions, CreateAxiosOptions, Result, UploadFileParams } from './types';
// import { ContentTypeEnum } from '/@/enums/httpEnum';
import { errorResult } from './const';
import { ContentTypeEnum } from '/@/enums/httpEnum';
export * from './axiosTransform';
......@@ -107,25 +108,42 @@ export class VAxios {
this.axiosInstance.interceptors.response.use(undefined, responseInterceptorsCatch);
}
// /**
// * @description: 文件上传
// */
// uploadFiles(config: AxiosRequestConfig, params: File[]) {
// const formData = new FormData();
// Object.keys(params).forEach((key) => {
// formData.append(key, params[key as any]);
// });
// return this.request({
// ...config,
// method: 'POST',
// data: formData,
// headers: {
// 'Content-type': ContentTypeEnum.FORM_DATA,
// },
// });
// }
/**
* @description: 文件上传
*/
uploadFile<T = any>(config: AxiosRequestConfig, params: UploadFileParams) {
const formData = new window.FormData();
if (params.data) {
Object.keys(params.data).forEach((key) => {
if (!params.data) return;
const value = params.data[key];
// support key-value array data
if (Array.isArray(value)) {
value.forEach((item) => {
// { list: [ 11, 22 ] }
// formData.append('list[]', 11);
formData.append(`${key}[]`, item);
});
return;
}
formData.append(key, params.data[key]);
});
}
formData.append(params.name || 'file', params.file, params.filename);
return this.axiosInstance.request<T>({
...config,
method: 'POST',
data: formData,
headers: {
'Content-type': ContentTypeEnum.FORM_DATA,
ignoreCancelToken: true,
},
});
}
/**
* @description: 请求方法
......
......@@ -28,3 +28,14 @@ export interface Result<T = any> {
message: string;
result: T;
}
// multipart/form-data:上传文件
export interface UploadFileParams {
// 其他参数
data?: { [key: string]: any };
// 文件参数的接口字段名
name?: string;
// 文件
file: File | Blob;
// 文件名
filename?: string;
}
<template>
<div class="p-4">
<UploadContainer :maxSize="5" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { UploadContainer } from '/@/components/Upload/index';
// import { Alert } from 'ant-design-vue';
export default defineComponent({
components: { UploadContainer },
setup() {
return {};
},
});
</script>
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册