物业代码生成

This commit is contained in:
2025-06-18 11:03:42 +08:00
commit 1262d4c745
1881 changed files with 249599 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,150 @@
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type { UploadListType } from 'ant-design-vue/es/upload/interface';
import type { BaseUploadProps, UploadEmits } from './props';
import { computed } from 'vue';
import { $t, I18nT } from '@vben/locales';
import { InboxOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { Upload } from 'ant-design-vue';
import { uploadApi } from '#/api';
import { defaultFileAcceptExts, defaultFilePreview } from './helper';
import { useUpload } from './hook';
interface FileUploadProps extends BaseUploadProps {
/**
* 同antdv的listType 但是排除picture-card
* 文件上传不适合用picture-card显示
* @default text
*/
listType?: Exclude<UploadListType, 'picture-card'>;
}
const props = withDefaults(defineProps<FileUploadProps>(), {
api: () => uploadApi,
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultFileAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
helpMessage: true,
preview: defaultFilePreview,
enableDragUpload: false,
directory: false,
abortOnUnmounted: true,
listType: 'text',
});
const emit = defineEmits<UploadEmits>();
/** 返回不同的上传组件 */
const CurrentUploadComponent = computed(() => {
if (props.enableDragUpload) {
return Upload.Dragger;
}
return Upload;
});
// 双向绑定 ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
const {
customRequest,
acceptStr,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
} = useUpload(props, emit, ossIdList, 'file');
</script>
<!--
Upload.Dragger只会影响样式
使用普通Upload也是支持拖拽上传的
-->
<template>
<div>
<CurrentUploadComponent
v-model:file-list="innerFileList"
:accept="accept"
:list-type="listType"
:disabled="disabled"
:directory="directory"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
:custom-request="customRequest"
@preview="preview"
@change="handleChange"
@remove="handleRemove"
>
<div v-if="!enableDragUpload && innerFileList?.length < maxCount">
<a-button :disabled="disabled">
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</div>
<div v-if="enableDragUpload">
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">
{{ $t('component.upload.clickOrDrag') }}
</p>
</div>
</CurrentUploadComponent>
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
class="mt-2"
:class="{ 'upload-text__disabled': disabled }"
>
<template #size>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ maxSize }}MB
</span>
</template>
<template #ext>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ acceptStr }}
</span>
</template>
</I18nT>
</slot>
</div>
</template>
<style lang="scss">
// 禁用的样式和antd保持一致
.upload-text__disabled {
color: rgb(50 54 57 / 25%);
cursor: not-allowed;
&:where(.dark, .dark *) {
color: rgb(242 242 242 / 25%);
}
}
</style>

View File

@@ -0,0 +1,28 @@
import type { UploadFile } from 'ant-design-vue';
/**
* 默认支持上传的图片文件类型
*/
export const defaultImageAcceptExts = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
];
/**
* 默认支持上传的文件类型
*/
export const defaultFileAcceptExts = ['.xlsx', '.csv', '.docx', '.pdf'];
/**
* 文件(非图片)的默认预览逻辑
* 默认: window.open打开 交给浏览器接管
* @param file file
*/
export function defaultFilePreview(file: UploadFile) {
if (file?.url) {
window.open(file.url);
}
}

View File

@@ -0,0 +1,385 @@
/* 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 {
RcFile,
UploadRequestOption,
} from 'ant-design-vue/es/vc-upload/interface';
import type { ModelRef } from 'vue';
import type {
BaseUploadProps,
CustomGetter,
UploadEmits,
UploadType,
} from './props';
import type { AxiosProgressEvent, UploadResult } from '#/api';
import type { OssFile } from '#/api/system/oss/model';
import { computed, onUnmounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { message, Modal } from 'ant-design-vue';
import { isFunction, isString } from 'lodash-es';
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 emit 事件
* @param bindValue 双向绑定的idList
* @param uploadType 区分是文件还是图片上传
* @returns hook
*/
export function useUpload(
props: Readonly<BaseUploadProps>,
emit: UploadEmits,
bindValue: ModelRef<string | string[]>,
uploadType: UploadType,
) {
// 组件内部维护fileList
const innerFileList = ref<UploadFile[]>([]);
const acceptStr = computed(() => {
// string类型
if (isString(props.acceptFormat)) {
return props.acceptFormat;
}
// 函数类型
if (isFunction(props.acceptFormat)) {
return props.acceptFormat(props.accept!);
}
// 默认 会对拓展名做处理
return props.accept
?.split(',')
.map((item) => {
if (item.startsWith('.')) {
return item.slice(1);
}
return item;
})
.join(', ');
});
/**
* 自定义文件显示名称 需要区分不同的接口
* @param cb callback
* @returns 文件名
*/
function transformFilename(cb: Parameters<CustomGetter<string>>[0]) {
if (isFunction(props.customFilename)) {
return props.customFilename(cb);
}
// info接口
if (cb.type === 'info') {
return cb.response.originalName;
}
// 上传接口
return cb.response.fileName;
}
/**
* 自定义缩略图 需要区分不同的接口
* @param cb callback
* @returns 缩略图地址
*/
function transformThumbUrl(cb: Parameters<CustomGetter<undefined>>[0]) {
if (isFunction(props.customThumbUrl)) {
return props.customThumbUrl(cb);
}
// image 默认返回图片链接
if (uploadType === 'image') {
// info接口
if (cb.type === 'info') {
return cb.response.url;
}
// 上传接口
return cb.response.url;
}
// 文件默认返回空 走antd默认的预览图逻辑
return undefined;
}
// 用来标识是否为上传 这样在watch内部不需要请求api
let isUpload = false;
function handleChange(info: UploadChangeParam) {
/**
* 移除当前文件
* @param currentFile 当前文件
* @param currentFileList 当前所有文件list
*/
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;
}
// 获取返回结果 为customRequest的reslove参数
// 只有success才会走到这里
const { ossId, url } = currentFile.response as UploadResult;
currentFile.url = url;
currentFile.uid = ossId;
const cb = {
type: 'upload',
response: currentFile.response as UploadResult,
} as const;
currentFile.fileName = transformFilename(cb);
currentFile.name = transformFilename(cb);
currentFile.thumbUrl = transformThumbUrl(cb);
// 标记为上传 watch根据值做处理
isUpload = true;
// ossID添加 单个文件会被当做string
if (props.maxCount === 1) {
bindValue.value = ossId;
} else {
// 给默认值
if (!Array.isArray(bindValue.value)) {
bindValue.value = [];
}
// 直接使用.value无法触发useForm的更新(原生是正常的) 需要修改地址
bindValue.value = [...bindValue.value, ossId];
}
break;
}
// 上传失败 网络原因导致httpStatus 不等于200
case 'error': {
removeCurrentFile(currentFile, fileList);
}
}
emit('change', info);
}
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,
);
}
// 触发remove事件
emit('remove', currentFile);
}
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);
},
});
});
}
/**
* 上传前检测文件大小
* 拖拽时候前置会有浏览器自身的accept校验 校验失败不会执行此方法
* @param file file
* @returns file | 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;
}
const uploadAbort = new AbortController();
/**
* 自定义上传实现
* @param info
*/
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!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, {
onUploadProgress: progressEvent,
signal: uploadAbort.signal,
otherData: props?.data,
});
info.onSuccess!(res);
if (props.showSuccessMsg) {
message.success($t('component.upload.uploadSuccess'));
}
emit('success', info.file as RcFile, res);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
onUnmounted(() => {
props.abortOnUnmounted && uploadAbort.abort();
});
/**
* 这里默认只监听list地址变化 即重新赋值才会触发watch
* immediate用于初始化触发
*/
watch(
() => bindValue.value,
async (value) => {
if (value.length === 0) {
// 清空绑定值时同时清空innerFileList避免外部使用时还能读取到
innerFileList.value = [];
return;
}
// 上传完毕 不需要调用获取信息接口
if (isUpload) {
// 清理 使下一次状态可用
isUpload = false;
return;
}
const resp = await ossInfo(value);
function transformFile(info: OssFile) {
const cb = { type: 'info', response: info } as const;
const fileitem: UploadFile = {
uid: info.ossId,
name: transformFilename(cb),
fileName: transformFilename(cb),
url: info.url,
thumbUrl: transformThumbUrl(cb),
status: 'done',
};
return fileitem;
}
const transformOptions = resp.map((item) => transformFile(item));
innerFileList.value = transformOptions;
// 单文件 丢弃策略
if (props.maxCount === 1 && resp.length === 0 && !props.keepMissingId) {
bindValue.value = '';
return;
}
// 多文件
// 单文件查到了也会走这里的逻辑 filter会报错 需要maxCount判断处理
if (
resp.length !== value.length &&
!props.keepMissingId &&
props.maxCount !== 1
) {
// 给默认值
if (!Array.isArray(bindValue.value)) {
bindValue.value = [];
}
bindValue.value = bindValue.value.filter((ossId) =>
resp.map((res) => res.ossId).includes(ossId),
);
}
},
{ immediate: true },
);
return {
handleChange,
handleRemove,
beforeUpload,
customRequest,
innerFileList,
acceptStr,
};
}

View File

@@ -0,0 +1,190 @@
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type {
UploadFile,
UploadListType,
} from 'ant-design-vue/es/upload/interface';
import type { BaseUploadProps, UploadEmits } from './props';
import { $t, I18nT } from '@vben/locales';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { Image, ImagePreviewGroup, Upload } from 'ant-design-vue';
import { isFunction } from 'lodash-es';
import { uploadApi } from '#/api';
import { defaultImageAcceptExts } from './helper';
import { useImagePreview, useUpload } from './hook';
interface ImageUploadProps extends BaseUploadProps {
/**
* 同antdv的listType
* @default picture-card
*/
listType?: UploadListType;
/**
* 使用list-type: picture-card时 是否显示动画
* 会有一个`弹跳`的效果 默认关闭
* @default false
*/
withAnimation?: boolean;
}
const props = withDefaults(defineProps<ImageUploadProps>(), {
api: () => uploadApi,
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultImageAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
listType: 'picture-card',
helpMessage: true,
enableDragUpload: false,
abortOnUnmounted: true,
withAnimation: false,
});
const emit = defineEmits<UploadEmits>();
// 双向绑定 ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
const {
acceptStr,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
customRequest,
} = useUpload(props, emit, ossIdList, 'image');
const { previewVisible, previewImage, handleCancel, handlePreview } =
useImagePreview();
function currentPreview(file: UploadFile) {
// 有自定义预览逻辑走自定义
if (isFunction(props.preview)) {
return props.preview(file);
}
// 否则走默认预览
return handlePreview(file);
}
</script>
<template>
<div>
<Upload
v-model:file-list="innerFileList"
:class="{ 'upload-animation__disabled': !withAnimation }"
:list-type="listType"
:accept="accept"
:disabled="disabled"
:directory="directory"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
:custom-request="customRequest"
@preview="currentPreview"
@change="handleChange"
@remove="handleRemove"
>
<div
v-if="innerFileList?.length < maxCount && listType === 'picture-card'"
>
<PlusOutlined />
<div class="mt-[8px]">{{ $t('component.upload.upload') }}</div>
</div>
<a-button
v-if="innerFileList?.length < maxCount && listType !== 'picture-card'"
:disabled="disabled"
>
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</Upload>
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
:class="{
'upload-text__disabled': disabled,
'mt-2': listType !== 'picture-card',
}"
>
<template #size>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ maxSize }}MB
</span>
</template>
<template #ext>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ acceptStr }}
</span>
</template>
</I18nT>
</slot>
<ImagePreviewGroup
:preview="{
visible: previewVisible,
onVisibleChange: handleCancel,
}"
>
<Image class="hidden" :src="previewImage" />
</ImagePreviewGroup>
</div>
</template>
<style lang="scss">
.ant-upload-select-picture-card {
i {
@apply text-[32px] text-[#999];
}
.ant-upload-text {
@apply mt-[8px] text-[#666];
}
}
.ant-upload-list-picture-card {
.ant-upload-list-item::before {
border-radius: 4px;
}
}
// 禁用的样式和antd保持一致
.upload-text__disabled {
color: rgb(50 54 57 / 25%);
cursor: not-allowed;
&:where(.dark, .dark *) {
color: rgb(242 242 242 / 25%);
}
}
// list-type: picture-card动画效果关闭样式
.upload-animation__disabled {
.ant-upload-animate-inline {
animation-duration: 0s !important;
}
}
</style>

View File

@@ -0,0 +1,26 @@
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`库的`fileTypeFromBlob`方法不支持Safari 去掉可以正常上传
safari不支持`ReadableStreamBYOBReader`api
详见: https://github.com/sindresorhus/file-type/issues/690

View File

@@ -0,0 +1,122 @@
import type { UploadFile } from 'ant-design-vue';
import type { RcFile } from 'ant-design-vue/es/vc-upload/interface';
import type { UploadApi, UploadResult } from '#/api';
import type { OssFile } from '#/api/system/oss/model';
import { UploadChangeParam } from 'ant-design-vue';
export type UploadType = 'file' | 'image';
/**
* 自定义返回文件名/缩略图使用 泛型控制返回是否必填
* type 为不同的接口返回值 需要自行if判断
*/
export type CustomGetter<T extends string | undefined> = (
cb:
| { response: OssFile; type: 'info' }
| { response: UploadResult; type: 'upload' },
) => T extends undefined ? string | undefined : string;
export interface BaseUploadProps {
/**
* 上传接口
*/
api?: UploadApi;
/**
* 文件上传失败 是否从展示列表中删除
* @default true
*/
removeOnError?: boolean;
/**
* 上传成功 是否展示提示信息
* @default true
*/
showSuccessMsg?: boolean;
/**
* 删除文件前是否需要确认
* @default false
*/
removeConfirm?: boolean;
/**
* 同antdv参数
*/
accept?: string;
/**
* 你可能使用的是application/pdf这种mime类型, 但是这样用户可能看不懂, 在这里自定义逻辑
* @default 原始accept
*/
acceptFormat?: ((accept: string) => string) | 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;
/**
* 是否支持上传文件夹
* @default false
*/
directory?: boolean;
/**
* 是否支持拖拽上传
* @default false
*/
enableDragUpload?: boolean;
/**
* 当ossId查询不到文件信息时 比如被删除了
* 是否保留列表对应的ossId 默认不保留
* @default false
*/
keepMissingId?: boolean;
/**
* 自定义文件/图片预览逻辑 比如: 你可以改为下载
* 图片上传默认为预览
* 文件上传默认为window.open
* @param file file
*/
preview?: (file: UploadFile) => Promise<void> | void;
/**
* 是否在组件Unmounted时取消上传
* @default true
*/
abortOnUnmounted?: boolean;
/**
* 自定义文件名 需要区分两个接口的返回值
*/
customFilename?: CustomGetter<string>;
/**
* 自定义缩略图 需要区分两个接口的返回值
*/
customThumbUrl?: CustomGetter<undefined>;
}
export interface UploadEmits {
(e: 'success', file: RcFile, response: UploadResult): void;
(e: 'remove', file: UploadFile): void;
(e: 'change', info: UploadChangeParam): void;
}