mirror of
https://gitee.com/ccnetcore/Yi
synced 2026-04-05 00:37:21 +08:00
feat(project): 添加vben5前端
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { default as CropperAvatar } from './src/cropper-avatar.vue';
|
||||
export { default as CropperImage } from './src/cropper.vue';
|
||||
export type { Cropper } from './src/typing';
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ButtonProps } from 'ant-design-vue';
|
||||
|
||||
import type { CSSProperties, PropType } from 'vue';
|
||||
|
||||
import { computed, ref, unref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t as t } from '@vben/locales';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import cropperModal from './cropper-modal.vue';
|
||||
|
||||
defineOptions({ name: 'CropperAvatar' });
|
||||
|
||||
const props = defineProps({
|
||||
btnProps: { default: () => ({}), type: Object as PropType<ButtonProps> },
|
||||
btnText: { default: '', type: String },
|
||||
showBtn: { default: true, type: Boolean },
|
||||
size: { default: 5, type: Number },
|
||||
uploadApi: {
|
||||
required: true,
|
||||
type: Function as PropType<
|
||||
({
|
||||
file,
|
||||
filename,
|
||||
name,
|
||||
}: {
|
||||
file: Blob;
|
||||
filename: string;
|
||||
name: string;
|
||||
}) => Promise<any>
|
||||
>,
|
||||
},
|
||||
value: { default: '', type: String },
|
||||
|
||||
width: { default: '200px', type: [String, Number] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'change']);
|
||||
|
||||
const sourceValue = ref(props.value || '');
|
||||
const prefixCls = 'cropper-avatar';
|
||||
const [CropperModal, modalApi] = useVbenModal({
|
||||
connectedComponent: cropperModal,
|
||||
});
|
||||
|
||||
const getClass = computed(() => [prefixCls]);
|
||||
|
||||
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
|
||||
|
||||
const getIconWidth = computed(
|
||||
() => `${Number.parseInt(`${props.width}`.replace(/px/, '')) / 2}px`,
|
||||
);
|
||||
|
||||
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) }));
|
||||
|
||||
const getImageWrapperStyle = computed(
|
||||
(): CSSProperties => ({ height: unref(getWidth), width: unref(getWidth) }),
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
sourceValue.value = props.value || '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sourceValue.value,
|
||||
(v: string) => {
|
||||
emit('update:value', v);
|
||||
},
|
||||
);
|
||||
|
||||
function handleUploadSuccess({ data, source }: any) {
|
||||
sourceValue.value = source;
|
||||
emit('change', { data, source });
|
||||
message.success(t('component.cropper.uploadSuccess'));
|
||||
}
|
||||
|
||||
const closeModal = () => modalApi.close();
|
||||
const openModal = () => modalApi.open();
|
||||
|
||||
defineExpose({
|
||||
closeModal,
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="getClass" :style="getStyle">
|
||||
<div
|
||||
:class="`${prefixCls}-image-wrapper`"
|
||||
:style="getImageWrapperStyle"
|
||||
@click="openModal"
|
||||
>
|
||||
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
|
||||
<span
|
||||
:style="{
|
||||
...getImageWrapperStyle,
|
||||
width: `${getIconWidth}`,
|
||||
height: `${getIconWidth}`,
|
||||
lineHeight: `${getIconWidth}`,
|
||||
}"
|
||||
class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
|
||||
></span>
|
||||
</div>
|
||||
<img v-if="sourceValue" :src="sourceValue" alt="avatar" />
|
||||
</div>
|
||||
<a-button
|
||||
v-if="showBtn"
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
@click="openModal"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
{{ btnText ? btnText : t('component.cropper.selectImage') }}
|
||||
</a-button>
|
||||
|
||||
<CropperModal
|
||||
:size="size"
|
||||
:src="sourceValue"
|
||||
:upload-api="uploadApi"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cropper-avatar {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&-image-wrapper {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
cursor: pointer;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
border: inherit;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
::v-deep(svg) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask:hover {
|
||||
opacity: 40;
|
||||
}
|
||||
|
||||
&-upload-btn {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { CropendResult, Cropper } from './typing';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t as t } from '@vben/locales';
|
||||
|
||||
import { Avatar, message, Space, Tooltip, Upload } from 'ant-design-vue';
|
||||
import { isFunction } from 'lodash-es';
|
||||
|
||||
import { dataURLtoBlob } from '#/utils/file/base64Conver';
|
||||
|
||||
import CropperImage from './cropper.vue';
|
||||
|
||||
type apiFunParams = { file: Blob; filename: string; name: string };
|
||||
|
||||
defineOptions({ name: 'CropperModal' });
|
||||
|
||||
const props = defineProps({
|
||||
circled: { default: true, type: Boolean },
|
||||
size: { default: 0, type: Number },
|
||||
src: { default: '', type: String },
|
||||
uploadApi: {
|
||||
required: true,
|
||||
type: Function as PropType<(params: apiFunParams) => Promise<any>>,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']);
|
||||
|
||||
let filename = '';
|
||||
const src = ref(props.src || '');
|
||||
const previewSource = ref('');
|
||||
const cropper = ref<Cropper>();
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const prefixCls = 'cropper-am';
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
onConfirm: handleOk,
|
||||
onOpenChange(isOpen) {
|
||||
// 打开的时候loading CropperImage组件加载完毕关闭loading
|
||||
if (isOpen) {
|
||||
modalLoading(true);
|
||||
} else {
|
||||
// 关闭时候清空右侧预览
|
||||
previewSource.value = '';
|
||||
modalLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function modalLoading(loading: boolean) {
|
||||
modalApi.setState({ confirmLoading: loading, loading });
|
||||
}
|
||||
|
||||
// Block upload
|
||||
function handleBeforeUpload(file: File) {
|
||||
if (props.size > 0 && file.size > 1024 * 1024 * props.size) {
|
||||
emit('uploadError', { msg: t('component.cropper.imageTooBig') });
|
||||
return false;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
src.value = '';
|
||||
previewSource.value = '';
|
||||
reader.addEventListener('load', (e) => {
|
||||
src.value = (e.target?.result as string) ?? '';
|
||||
filename = file.name;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCropend({ imgBase64 }: CropendResult) {
|
||||
previewSource.value = imgBase64;
|
||||
}
|
||||
|
||||
function handleReady(cropperInstance: Cropper) {
|
||||
cropper.value = cropperInstance;
|
||||
// 画布加载完毕 关闭loading
|
||||
modalLoading(false);
|
||||
}
|
||||
|
||||
function handleReadyError() {
|
||||
modalLoading(false);
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1;
|
||||
}
|
||||
if (event === 'scaleY') {
|
||||
scaleY = arg = scaleY === -1 ? 1 : -1;
|
||||
}
|
||||
(cropper?.value as any)?.[event]?.(arg);
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const uploadApi = props.uploadApi;
|
||||
if (uploadApi && isFunction(uploadApi)) {
|
||||
if (!previewSource.value) {
|
||||
message.warn('未选择图片');
|
||||
return;
|
||||
}
|
||||
const blob = dataURLtoBlob(previewSource.value);
|
||||
try {
|
||||
modalLoading(true);
|
||||
const result = await uploadApi({ file: blob, filename, name: 'file' });
|
||||
emit('uploadSuccess', { data: result.url, source: previewSource.value });
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
:confirm-text="t('component.cropper.okText')"
|
||||
:fullscreen-button="false"
|
||||
:title="t('component.cropper.modalTitle')"
|
||||
class="w-[800px]"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<div :class="`${prefixCls}-left`" class="w-full">
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
:src="src"
|
||||
crossorigin="anonymous"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
@ready-error="handleReadyError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<Upload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="[]"
|
||||
accept="image/*"
|
||||
>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.selectImage')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button size="small" type="primary">
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
<Space>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_reset')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('reset')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--reload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_rotate_left')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', -45)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="icon-[ant-design--rotate-left-outlined]"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_rotate_right')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
pre-icon="ant-design:rotate-right-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="icon-[ant-design--rotate-right-outlined]"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_scale_x')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleX')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-h]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_scale_y')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleY')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-v]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_zoom_in')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-in-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="t('component.cropper.btn_zoom_out')"
|
||||
placement="bottom"
|
||||
>
|
||||
<a-button
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-out-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img
|
||||
v-if="previewSource"
|
||||
:alt="t('component.cropper.preview')"
|
||||
:src="previewSource"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="previewSource">
|
||||
<div :class="`${prefixCls}-group`">
|
||||
<Avatar :src="previewSource" size="large" />
|
||||
<Avatar :size="48" :src="previewSource" />
|
||||
<Avatar :size="64" :src="previewSource" />
|
||||
<Avatar :size="80" :src="previewSource" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper-am {
|
||||
display: flex;
|
||||
|
||||
&-left,
|
||||
&-right {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
&-left {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
&-right {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
);
|
||||
background-position:
|
||||
0 0,
|
||||
12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CSSProperties, PropType } from 'vue';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import Cropper from 'cropperjs';
|
||||
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
type Options = Cropper.Options;
|
||||
|
||||
defineOptions({ name: 'CropperImage' });
|
||||
|
||||
const props = defineProps({
|
||||
alt: { default: '', type: String },
|
||||
circled: { default: false, type: Boolean },
|
||||
crossorigin: {
|
||||
default: undefined,
|
||||
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
|
||||
},
|
||||
height: { default: '360px', type: [String, Number] },
|
||||
imageStyle: { default: () => ({}), type: Object as PropType<CSSProperties> },
|
||||
options: { default: () => ({}), type: Object as PropType<Options> },
|
||||
realTimePreview: { default: true, type: Boolean },
|
||||
src: { required: true, type: String },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError', 'readyError']);
|
||||
|
||||
const defaultOptions: Options = {
|
||||
aspectRatio: 1,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
center: true,
|
||||
// 需要设置为false 否则会自动拼接timestamp 导致私有桶sign错误
|
||||
// 需要配合img crossorigin='anonymous'使用(默认已经做了处理)
|
||||
checkCrossOrigin: false,
|
||||
checkOrientation: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
guides: true,
|
||||
highlight: true,
|
||||
modal: true,
|
||||
movable: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
rotatable: true,
|
||||
scalable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
};
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
type ElRef<T extends HTMLElement = HTMLDivElement> = null | T;
|
||||
const imgElRef = ref<ElRef<HTMLImageElement>>();
|
||||
const cropper = ref<Cropper | null>();
|
||||
const isReady = ref(false);
|
||||
|
||||
const prefixCls = 'cropper-image';
|
||||
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80);
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${`${props.height}`.replace(/px/, '')}px` };
|
||||
});
|
||||
|
||||
onMounted(init);
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy();
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef);
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
// 判断是否为正常访问的图片
|
||||
try {
|
||||
const resp = await fetch(props.src);
|
||||
if (resp.status !== 200) {
|
||||
emit('readyError');
|
||||
}
|
||||
} catch {
|
||||
emit('readyError');
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
crop() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
ready: () => {
|
||||
isReady.value = true;
|
||||
realTimeCroppered();
|
||||
emit('ready', cropper.value);
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
...props.options,
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCroppered() {
|
||||
props.realTimePreview && croppered();
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function croppered() {
|
||||
if (!cropper.value) {
|
||||
return;
|
||||
}
|
||||
const imgInfo = cropper.value.getData();
|
||||
const canvas = props.circled
|
||||
? getRoundedCanvas()
|
||||
: cropper.value.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
const fileReader: FileReader = new FileReader();
|
||||
fileReader.readAsDataURL(blob);
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError');
|
||||
};
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas();
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
const width = sourceCanvas.width;
|
||||
const height = sourceCanvas.height;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
context.globalCompositeOperation = 'destination-in';
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.min(width, height) / 2,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
true,
|
||||
);
|
||||
context.fill();
|
||||
return canvas;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.cropper-image {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type Cropper from 'cropperjs';
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string;
|
||||
imgInfo: Cropper.Data;
|
||||
}
|
||||
|
||||
export type { Cropper };
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Description } from './src/description.vue';
|
||||
export * from './src/typing';
|
||||
export { useDescription } from './src/useDescription';
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="tsx">
|
||||
import type { CardSize } from 'ant-design-vue/es/card/Card';
|
||||
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions';
|
||||
|
||||
import type { CSSProperties, PropType, Slots } from 'vue';
|
||||
|
||||
import type { DescInstance, DescItem, DescriptionProps } from './typing';
|
||||
|
||||
import { computed, defineComponent, ref, toRefs, unref, useAttrs } from 'vue';
|
||||
|
||||
import { Card, Descriptions } from 'ant-design-vue';
|
||||
import { get, isFunction } from 'lodash-es';
|
||||
|
||||
const props = {
|
||||
bordered: { default: true, type: Boolean },
|
||||
column: {
|
||||
default: () => {
|
||||
return { lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4 };
|
||||
},
|
||||
type: [Number, Object],
|
||||
},
|
||||
data: { type: Object },
|
||||
schema: {
|
||||
default: () => [],
|
||||
type: Array as PropType<DescItem[]>,
|
||||
},
|
||||
size: {
|
||||
default: 'small',
|
||||
type: String,
|
||||
validator: (v: string) =>
|
||||
['default', 'middle', 'small', undefined].includes(v),
|
||||
},
|
||||
title: { default: '', type: String },
|
||||
useCollapse: { default: true, type: Boolean },
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated 使用antd原生组件替代 下个版本将会移除
|
||||
*/
|
||||
export default defineComponent({
|
||||
emits: ['register'],
|
||||
// eslint-disable-next-line vue/order-in-components
|
||||
name: 'Description',
|
||||
// eslint-disable-next-line vue/order-in-components
|
||||
props,
|
||||
setup(props, { emit, slots }) {
|
||||
const propsRef = ref<null | Partial<DescriptionProps>>(null);
|
||||
|
||||
const prefixCls = 'description';
|
||||
const attrs = useAttrs();
|
||||
|
||||
// Custom title component: get title
|
||||
const getMergeProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
...(unref(propsRef) as any),
|
||||
} as DescriptionProps;
|
||||
});
|
||||
|
||||
const getProps = computed(() => {
|
||||
const opt = {
|
||||
...unref(getMergeProps),
|
||||
title: undefined,
|
||||
};
|
||||
return opt as DescriptionProps;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description: Whether to setting title
|
||||
*/
|
||||
const useWrapper = computed(() => !!unref(getMergeProps).title);
|
||||
|
||||
const getDescriptionsProps = computed(() => {
|
||||
return { ...unref(attrs), ...unref(getProps) } as DescriptionsProps;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description:设置desc
|
||||
*/
|
||||
function setDescProps(descProps: Partial<DescriptionProps>): void {
|
||||
// Keep the last setDrawerProps
|
||||
propsRef.value = {
|
||||
...(unref(propsRef) as Record<string, any>),
|
||||
...descProps,
|
||||
} as Record<string, any>;
|
||||
}
|
||||
|
||||
// Prevent line breaks
|
||||
function renderLabel({ label, labelMinWidth, labelStyle }: DescItem) {
|
||||
if (!labelStyle && !labelMinWidth) {
|
||||
return label;
|
||||
}
|
||||
|
||||
const labelStyles: CSSProperties = {
|
||||
...labelStyle,
|
||||
minWidth: `${labelMinWidth}px `,
|
||||
};
|
||||
return <div style={labelStyles}>{label}</div>;
|
||||
}
|
||||
|
||||
function renderItem() {
|
||||
const { data, schema } = unref(getProps);
|
||||
return unref(schema)
|
||||
.map((item) => {
|
||||
const { contentMinWidth, field, render, show, span } = item;
|
||||
|
||||
if (show && isFunction(show) && !show(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
const _data = unref(getProps)?.data;
|
||||
if (!_data) {
|
||||
return null;
|
||||
}
|
||||
const getField = get(_data, field);
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (getField && !toRefs(_data).hasOwnProperty(field)) {
|
||||
return isFunction(render) ? render!('', _data) : '';
|
||||
}
|
||||
return isFunction(render)
|
||||
? render!(getField, _data)
|
||||
: (getField ?? '');
|
||||
};
|
||||
|
||||
const width = contentMinWidth;
|
||||
return (
|
||||
<Descriptions.Item
|
||||
key={field}
|
||||
label={renderLabel(item)}
|
||||
span={span}
|
||||
>
|
||||
{() => {
|
||||
if (!contentMinWidth) {
|
||||
return getContent();
|
||||
}
|
||||
const style: CSSProperties = {
|
||||
minWidth: `${width}px`,
|
||||
};
|
||||
return <div style={style}>{getContent()}</div>;
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
);
|
||||
})
|
||||
.filter((item) => !!item);
|
||||
}
|
||||
|
||||
const renderDesc = () => {
|
||||
return (
|
||||
<Descriptions
|
||||
class={`${prefixCls}`}
|
||||
{...(unref(getDescriptionsProps) as any)}
|
||||
>
|
||||
{renderItem()}
|
||||
</Descriptions>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContainer = () => {
|
||||
const content = props.useCollapse ? (
|
||||
renderDesc()
|
||||
) : (
|
||||
<div>{renderDesc()}</div>
|
||||
);
|
||||
// Reduce the dom level
|
||||
if (!props.useCollapse) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// const { canExpand, helpMessage } = unref(getCollapseOptions);
|
||||
const { title } = unref(getMergeProps);
|
||||
|
||||
function getSlot(slots: Slots, slot = 'default', data?: any) {
|
||||
if (!slots || !Reflect.has(slots, slot)) {
|
||||
return null;
|
||||
}
|
||||
if (!isFunction(slots[slot])) {
|
||||
console.error(`${slot} is not a function!`);
|
||||
return null;
|
||||
}
|
||||
const slotFn = slots[slot];
|
||||
if (!slotFn) return null;
|
||||
const params = { ...data };
|
||||
return slotFn(params);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size={props.size as CardSize} title={title}>
|
||||
{{
|
||||
default: () => content,
|
||||
extra: () => getSlot(slots, 'extra'),
|
||||
}}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const methods: DescInstance = {
|
||||
setDescProps,
|
||||
};
|
||||
|
||||
emit('register', methods);
|
||||
return () => (unref(useWrapper) ? renderContainer() : renderDesc());
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions';
|
||||
import type { JSX } from 'vue/jsx-runtime';
|
||||
|
||||
import type { CSSProperties, VNode } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
export interface DescItem {
|
||||
labelMinWidth?: number;
|
||||
contentMinWidth?: number;
|
||||
labelStyle?: CSSProperties;
|
||||
field: string;
|
||||
label: JSX.Element | string | VNode;
|
||||
// Merge column
|
||||
span?: number;
|
||||
show?: (...arg: any) => boolean;
|
||||
// render
|
||||
render?: (
|
||||
val: any,
|
||||
data: Recordable<any>,
|
||||
) => Element | JSX.Element | number | string | undefined | VNode;
|
||||
}
|
||||
|
||||
export interface DescriptionProps extends DescriptionsProps {
|
||||
// Whether to include the collapse component
|
||||
useCollapse?: boolean;
|
||||
/**
|
||||
* item configuration
|
||||
* @type DescItem
|
||||
*/
|
||||
schema: DescItem[];
|
||||
/**
|
||||
* 数据
|
||||
* @type object
|
||||
*/
|
||||
data: Recordable<any>;
|
||||
}
|
||||
|
||||
export interface DescInstance {
|
||||
setDescProps(descProps: Partial<DescriptionProps>, delay?: boolean): void;
|
||||
}
|
||||
|
||||
export type Register = (descInstance: DescInstance) => void;
|
||||
|
||||
/**
|
||||
* @description:
|
||||
*/
|
||||
export type UseDescReturnType = [Register, DescInstance];
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
DescInstance,
|
||||
DescriptionProps,
|
||||
UseDescReturnType,
|
||||
} from './typing';
|
||||
|
||||
import { getCurrentInstance, ref, unref } from 'vue';
|
||||
|
||||
/**
|
||||
* @deprecated 使用antd原生组件替代 下个版本将会移除
|
||||
*/
|
||||
export function useDescription(
|
||||
props?: Partial<DescriptionProps>,
|
||||
): UseDescReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error(
|
||||
'useDescription() can only be used inside setup() or functional components!',
|
||||
);
|
||||
}
|
||||
const desc = ref<DescInstance | null>(null);
|
||||
const loaded = ref(false);
|
||||
|
||||
function register(instance: DescInstance) {
|
||||
// if (unref(loaded) && import.meta.env.PROD) {
|
||||
// return;
|
||||
// }
|
||||
desc.value = instance;
|
||||
props && instance.setDescProps(props);
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
const methods: DescInstance = {
|
||||
setDescProps: (
|
||||
descProps: Partial<DescriptionProps>,
|
||||
delay = false,
|
||||
): void => {
|
||||
if (!delay) {
|
||||
unref(desc)?.setDescProps(descProps);
|
||||
return;
|
||||
}
|
||||
// 奇怪的问题 在modal中需要setTimeout才会生效
|
||||
setTimeout(() => unref(desc)?.setDescProps(descProps));
|
||||
},
|
||||
};
|
||||
|
||||
return [register, methods];
|
||||
}
|
||||
2
Yi.Vben5.Vue3/apps/web-antd/src/components/dict/index.ts
Normal file
2
Yi.Vben5.Vue3/apps/web-antd/src/components/dict/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { tagSelectOptions, tagTypes } from './src/data';
|
||||
export { default as DictTag } from './src/index.vue';
|
||||
44
Yi.Vben5.Vue3/apps/web-antd/src/components/dict/src/data.tsx
Normal file
44
Yi.Vben5.Vue3/apps/web-antd/src/components/dict/src/data.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
interface TagType {
|
||||
[key: string]: { color: string; label: string };
|
||||
}
|
||||
|
||||
export const tagTypes: TagType = {
|
||||
cyan: { color: 'cyan', label: 'cyan' },
|
||||
danger: { color: 'error', label: '危险(danger)' },
|
||||
/** 由于和elementUI不同 用于替换颜色 */
|
||||
default: { color: 'default', label: '默认(default)' },
|
||||
green: { color: 'green', label: 'green' },
|
||||
info: { color: 'default', label: '信息(info)' },
|
||||
orange: { color: 'orange', label: 'orange' },
|
||||
/** 自定义预设 color可以为16进制颜色 */
|
||||
pink: { color: 'pink', label: 'pink' },
|
||||
primary: { color: 'processing', label: '主要(primary)' },
|
||||
purple: { color: 'purple', label: 'purple' },
|
||||
red: { color: 'red', label: 'red' },
|
||||
success: { color: 'success', label: '成功(success)' },
|
||||
warning: { color: 'warning', label: '警告(warning)' },
|
||||
};
|
||||
|
||||
// 字典选择使用 { label: string; value: string }[]
|
||||
interface Options {
|
||||
label: string | VNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function tagSelectOptions() {
|
||||
const selectArray: Options[] = [];
|
||||
Object.keys(tagTypes).forEach((key) => {
|
||||
if (!tagTypes[key]) return;
|
||||
const label = tagTypes[key].label;
|
||||
const color = tagTypes[key].color;
|
||||
selectArray.push({
|
||||
label: <Tag color={color}>{label}</Tag>,
|
||||
value: key,
|
||||
});
|
||||
});
|
||||
return selectArray;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- eslint-disable eqeqeq -->
|
||||
<script setup lang="ts">
|
||||
import type { DictData } from '#/api/system/dict/dict-data-model';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { tagTypes } from './data';
|
||||
|
||||
interface Props {
|
||||
dicts: DictData[]; // dict数组
|
||||
value: number | string; // value
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
dicts: undefined,
|
||||
});
|
||||
|
||||
const color = computed<string>(() => {
|
||||
const current = props.dicts.find((item) => item.dictValue == props.value);
|
||||
const listClass = current?.listClass ?? '';
|
||||
// 是否为默认的颜色
|
||||
const isDefault = Reflect.has(tagTypes, listClass);
|
||||
// 判断是默认还是自定义颜色
|
||||
if (isDefault) {
|
||||
// 这里做了antd - element-plus的兼容
|
||||
return tagTypes[listClass]!.color;
|
||||
}
|
||||
return listClass;
|
||||
});
|
||||
|
||||
const cssClass = computed<string>(() => {
|
||||
const current = props.dicts.find((item) => item.dictValue == props.value);
|
||||
return current?.cssClass ?? '';
|
||||
});
|
||||
|
||||
const label = computed<number | string>(() => {
|
||||
const current = props.dicts.find((item) => item.dictValue == props.value);
|
||||
return current?.dictLabel ?? 'unknown';
|
||||
});
|
||||
|
||||
const tagComponent = computed(() => (color.value ? Tag : 'div'));
|
||||
|
||||
const loading = computed(() => {
|
||||
return props.dicts?.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<component
|
||||
v-if="!loading"
|
||||
:is="tagComponent"
|
||||
:class="cssClass"
|
||||
:color="color"
|
||||
>
|
||||
{{ label }}
|
||||
</component>
|
||||
<Spin v-else :spinning="true" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
21
Yi.Vben5.Vue3/apps/web-antd/src/components/global/button.ts
Normal file
21
Yi.Vben5.Vue3/apps/web-antd/src/components/global/button.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import buttonProps from 'ant-design-vue/es/button/buttonTypes';
|
||||
import { omit } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* 表格操作列按钮专用
|
||||
*/
|
||||
export const GhostButton = defineComponent({
|
||||
name: 'GhostButton',
|
||||
props: omit(buttonProps(), ['type', 'ghost', 'size']),
|
||||
setup(props, { attrs, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
Button,
|
||||
{ ...props, ...attrs, type: 'primary', ghost: true, size: 'small' },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
14
Yi.Vben5.Vue3/apps/web-antd/src/components/global/index.ts
Normal file
14
Yi.Vben5.Vue3/apps/web-antd/src/components/global/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { App } from 'vue';
|
||||
|
||||
import { Button as AButton } from 'ant-design-vue';
|
||||
|
||||
import { GhostButton } from './button';
|
||||
|
||||
/**
|
||||
* 全局组件注册
|
||||
*/
|
||||
export function setupGlobalComponent(app: App) {
|
||||
app.use(AButton);
|
||||
// 表格操作列专用按钮
|
||||
app.component('GhostButton', GhostButton);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as OptionsTag } from './src/options-tag.vue';
|
||||
export { default as TableSwitch } from './src/table-switch.vue';
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'OptionsTag' });
|
||||
|
||||
const props = defineProps<{
|
||||
options: { color?: string; label: string; value: number | string }[];
|
||||
value: number | string;
|
||||
}>();
|
||||
|
||||
const found = computed(() =>
|
||||
props.options.find((item) => item.value === props.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag v-if="found" :color="found.color">{{ found.label }}</Tag>
|
||||
<span v-else>未知</span>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Modal, Switch } from 'ant-design-vue';
|
||||
import { isFunction } from 'lodash-es';
|
||||
|
||||
type CheckedType = boolean | number | string;
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 选中的文本
|
||||
* @default i18n 启用
|
||||
*/
|
||||
checkedText?: string;
|
||||
/**
|
||||
* 未选中的文本
|
||||
* @default i18n 禁用
|
||||
*/
|
||||
unCheckedText?: string;
|
||||
checkedValue?: CheckedType;
|
||||
unCheckedValue?: CheckedType;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 需要自己在内部处理更新的逻辑 因为status已经双向绑定了 可以直接获取
|
||||
*/
|
||||
api: () => PromiseLike<void>;
|
||||
/**
|
||||
* 更新前是否弹窗确认
|
||||
* @default false
|
||||
*/
|
||||
confirm?: boolean;
|
||||
/**
|
||||
* 对应的提示内容
|
||||
* @param checked 选中的值(更新后的值)
|
||||
* @default string '确认要更新状态吗?'
|
||||
*/
|
||||
confirmText?: (checked: CheckedType) => string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkedText: undefined,
|
||||
unCheckedText: undefined,
|
||||
checkedValue: true,
|
||||
unCheckedValue: false,
|
||||
confirm: false,
|
||||
confirmText: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
|
||||
// 修改为computed 支持语言切换
|
||||
const checkedTextComputed = computed(() => {
|
||||
return props.checkedText ?? $t('pages.common.enable');
|
||||
});
|
||||
|
||||
const unCheckedTextComputed = computed(() => {
|
||||
return props.unCheckedText ?? $t('pages.common.disable');
|
||||
});
|
||||
|
||||
const currentChecked = defineModel<CheckedType>('value', {
|
||||
default: false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
function confirmUpdate(checked: CheckedType, lastStatus: CheckedType) {
|
||||
const content = isFunction(props.confirmText)
|
||||
? props.confirmText(checked)
|
||||
: `确认要更新状态吗?`;
|
||||
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content,
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const { api } = props;
|
||||
isFunction(api) && (await api());
|
||||
emit('reload');
|
||||
} catch {
|
||||
currentChecked.value = lastStatus;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
currentChecked.value = lastStatus;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChange(checked: CheckedType, e: Event) {
|
||||
// 阻止事件冒泡 否则会跟行选中冲突
|
||||
e.stopPropagation();
|
||||
const { checkedValue, unCheckedValue } = props;
|
||||
// 原本的状态
|
||||
const lastStatus = checked === checkedValue ? unCheckedValue : checkedValue;
|
||||
// 切换状态
|
||||
currentChecked.value = checked;
|
||||
const { api } = props;
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
if (props.confirm) {
|
||||
confirmUpdate(checked, lastStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
isFunction(api) && (await api());
|
||||
emit('reload');
|
||||
} catch {
|
||||
currentChecked.value = lastStatus;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch
|
||||
v-bind="$attrs"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:checked="currentChecked"
|
||||
:checked-children="checkedTextComputed"
|
||||
:checked-value="checkedValue"
|
||||
:un-checked-children="unCheckedTextComputed"
|
||||
:un-checked-value="unCheckedValue"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TenantToggle } from './src/index.vue';
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageType } from 'ant-design-vue/es/message';
|
||||
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select';
|
||||
|
||||
import type { TenantOption } from '#/api';
|
||||
|
||||
import { computed, onMounted, ref, shallowRef, unref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { useTabs } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message, Select, Spin } from 'ant-design-vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { tenantDynamicClear, tenantDynamicToggle } from '#/api/system/tenant';
|
||||
import { useDictStore } from '#/store/dict';
|
||||
import { useTenantStore } from '#/store/tenant';
|
||||
|
||||
const { hasAccessByRoles } = useAccess();
|
||||
|
||||
// 上一次选择的租户
|
||||
const lastSelected = ref<string>();
|
||||
// 当前选择租户的id
|
||||
const selected = ref<string>();
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const { initTenant, setChecked } = tenantStore;
|
||||
const { tenantEnable, tenantList } = storeToRefs(tenantStore);
|
||||
|
||||
const showToggle = computed<boolean>(() => {
|
||||
// 超级管理员 && 启用租户
|
||||
return hasAccessByRoles(['superadmin']) && unref(tenantEnable);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 没有超级管理员权限 不会调用接口
|
||||
if (!hasAccessByRoles(['superadmin'])) {
|
||||
return;
|
||||
}
|
||||
await initTenant();
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const { closeOtherTabs, refreshTab, closeAllTabs } = useTabs();
|
||||
|
||||
async function close(checked: boolean) {
|
||||
// store设置状态
|
||||
setChecked(checked);
|
||||
|
||||
/**
|
||||
* 切换租户需要回到首页的页面 一般为带id的页面
|
||||
* 其他则直接刷新页面
|
||||
*/
|
||||
if (route.meta.requireHomeRedirect) {
|
||||
await closeAllTabs();
|
||||
} else {
|
||||
// 先关闭再刷新 这里不用Promise.all()
|
||||
await closeOtherTabs();
|
||||
await refreshTab();
|
||||
}
|
||||
}
|
||||
|
||||
const dictStore = useDictStore();
|
||||
// 用于清理上一条message
|
||||
const messageInstance = shallowRef<MessageType | null>();
|
||||
// loading加载中效果
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 选中租户的处理
|
||||
* @param tenantId tenantId
|
||||
* @param option 当前option
|
||||
*/
|
||||
const onSelected: SelectHandler = async (tenantId: string, option: any) => {
|
||||
if (unref(lastSelected) === tenantId) {
|
||||
// createMessage.info('选择一致');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await tenantDynamicToggle(tenantId);
|
||||
lastSelected.value = tenantId;
|
||||
|
||||
// 关闭之前的message 只保留一条
|
||||
messageInstance.value?.();
|
||||
messageInstance.value = message.success(
|
||||
`${$t('component.tenantToggle.switch')} ${option.companyName}`,
|
||||
);
|
||||
|
||||
close(true);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
async function onDeselect() {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await tenantDynamicClear();
|
||||
// 关闭之前的message 只保留一条
|
||||
messageInstance.value?.();
|
||||
messageInstance.value = message.success($t('component.tenantToggle.reset'));
|
||||
|
||||
lastSelected.value = '';
|
||||
close(false);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* select搜索使用
|
||||
* @param input 输入内容
|
||||
* @param option 选项
|
||||
*/
|
||||
function filterOption(input: string, option: TenantOption) {
|
||||
return option.companyName.toLowerCase().includes(input.toLowerCase());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showToggle" class="mr-[8px] hidden md:block">
|
||||
<Select
|
||||
v-model:value="selected"
|
||||
:disabled="loading"
|
||||
:field-names="{ label: 'companyName', value: 'tenantId' }"
|
||||
:filter-option="filterOption"
|
||||
:options="tenantList"
|
||||
:placeholder="$t('component.tenantToggle.placeholder')"
|
||||
:dropdown-style="{ position: 'fixed', zIndex: 1024 }"
|
||||
allow-clear
|
||||
class="w-60"
|
||||
show-search
|
||||
@deselect="onDeselect"
|
||||
@select="onSelected"
|
||||
>
|
||||
<template v-if="loading" #suffixIcon>
|
||||
<Spin size="small" spinning />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 当选中时 添加border样式
|
||||
:deep(.ant-select-selector) {
|
||||
&:has(.ant-select-selection-item) {
|
||||
box-shadow: 0 0 10px hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Tinymce } from './src/editor.vue';
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
|
||||
import type { Editor as EditorType } from 'tinymce/tinymce';
|
||||
|
||||
import type { AxiosProgressEvent, UploadResult } from '#/api';
|
||||
|
||||
import { computed, nextTick, ref, shallowRef, useAttrs, watch } from 'vue';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
import { camelCase } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
import {
|
||||
plugins as defaultPlugins,
|
||||
toolbar as defaultToolbar,
|
||||
} from '#/components/tinymce/src/tinymce';
|
||||
|
||||
type InitOptions = IPropTypes['init'];
|
||||
|
||||
interface Props {
|
||||
height?: number | string;
|
||||
options?: Partial<InitOptions>;
|
||||
plugins?: string;
|
||||
toolbar?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'Tinymce',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 400,
|
||||
options: () => ({}),
|
||||
plugins: defaultPlugins,
|
||||
toolbar: defaultToolbar,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
mounted: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* https://www.jianshu.com/p/59a9c3802443
|
||||
* 使用自托管方案(本地)代替cdn 没有key的限制
|
||||
* 注意publicPath要以/结尾
|
||||
*/
|
||||
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
|
||||
|
||||
const content = defineModel<string>('modelValue', {
|
||||
default: '',
|
||||
});
|
||||
|
||||
const editorRef = shallowRef<EditorType | null>(null);
|
||||
|
||||
const { isDark, locale } = usePreferences();
|
||||
const skinName = computed(() => {
|
||||
return isDark.value ? 'oxide-dark' : 'oxide';
|
||||
});
|
||||
|
||||
const contentCss = computed(() => {
|
||||
return isDark.value ? 'dark' : 'default';
|
||||
});
|
||||
|
||||
/**
|
||||
* tinymce支持 en zh_CN
|
||||
*/
|
||||
const langName = computed(() => {
|
||||
const lang = preferences.app.locale.replace('-', '_');
|
||||
if (lang.includes('en_US')) {
|
||||
return 'en';
|
||||
}
|
||||
return 'zh_CN';
|
||||
});
|
||||
|
||||
/**
|
||||
* 通过v-if来挂载/卸载组件来完成主题切换切换
|
||||
* 语言切换也需要监听 不监听在切换时候会显示原始<textarea>样式
|
||||
*/
|
||||
const init = ref(true);
|
||||
watch([isDark, locale], async () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
// 相当于手动unmounted清理 非常重要
|
||||
editorRef.value.destroy();
|
||||
init.value = false;
|
||||
// 放在下一次tick来切换
|
||||
// 需要先加载组件 也就是v-if为true 然后需要拿到editorRef 必须放在setTimeout(相当于onMounted)
|
||||
await nextTick();
|
||||
init.value = true;
|
||||
});
|
||||
|
||||
// 加载完毕前显示spin
|
||||
const loading = ref(true);
|
||||
const initOptions = computed((): InitOptions => {
|
||||
const { height, options, plugins, toolbar } = props;
|
||||
return {
|
||||
auto_focus: true,
|
||||
branding: false, // 显示右下角的'使用 TinyMCE 构建'
|
||||
content_css: contentCss.value,
|
||||
content_style:
|
||||
'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }',
|
||||
contextmenu: 'link image table',
|
||||
default_link_target: '_blank',
|
||||
height,
|
||||
image_advtab: true, // 图片高级选项
|
||||
image_caption: true,
|
||||
importcss_append: true,
|
||||
language: langName.value,
|
||||
link_title: false,
|
||||
menubar: 'file edit view insert format tools table help',
|
||||
noneditable_class: 'mceNonEditable',
|
||||
/**
|
||||
* 允许粘贴图片 默认base64格式
|
||||
* images_upload_handler启用时为上传
|
||||
*/
|
||||
paste_data_images: true,
|
||||
images_file_types: 'jpeg,jpg,png,gif,bmp,webp',
|
||||
plugins,
|
||||
quickbars_selection_toolbar:
|
||||
'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
|
||||
skin: skinName.value,
|
||||
toolbar,
|
||||
toolbar_mode: 'sliding',
|
||||
...options,
|
||||
/**
|
||||
* 覆盖默认的base64行为
|
||||
* @param blobInfo
|
||||
* 大坑 不要调用这两个函数 success failure:
|
||||
* 使用resolve/reject代替
|
||||
* (PS: 新版已经没有success failure)
|
||||
*/
|
||||
images_upload_handler: (blobInfo, progress) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = blobInfo.blob();
|
||||
// const filename = blobInfo.filename();
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
progress(percent);
|
||||
};
|
||||
uploadApi(file, { onUploadProgress: progressEvent })
|
||||
.then((response) => {
|
||||
const { url } = response as unknown as UploadResult;
|
||||
console.log('tinymce上传图片:', url);
|
||||
resolve(url);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('tinymce上传图片失败:', error);
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
reject({ message: error.message, remove: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
setup: (editor) => {
|
||||
editorRef.value = editor;
|
||||
editor.on('init', () => {
|
||||
emit('mounted');
|
||||
loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
/**
|
||||
* 获取透传的事件 通过v-on绑定
|
||||
* 可绑定的事件 https://www.tiny.cloud/docs/tinymce/latest/vue-ref/#event-binding
|
||||
*/
|
||||
const events = computed(() => {
|
||||
const onEvents: Record<string, any> = {};
|
||||
for (const key in attrs) {
|
||||
if (key.startsWith('on')) {
|
||||
const eventKey = camelCase(key.split('on')[1]!);
|
||||
onEvents[eventKey] = attrs[key];
|
||||
}
|
||||
}
|
||||
return onEvents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-tinymce">
|
||||
<Spin :spinning="loading">
|
||||
<Editor
|
||||
v-if="init"
|
||||
v-model="content"
|
||||
:init="initOptions"
|
||||
:tinymce-script-src="tinymceScriptSrc"
|
||||
:disabled="disabled"
|
||||
license-key="gpl"
|
||||
v-on="events"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
// 展开层元素z-index
|
||||
$dropdown-index: 2025;
|
||||
|
||||
@mixin tinymce-valid-fail($color) {
|
||||
.app-tinymce {
|
||||
// 最外层的tinymce容器
|
||||
.tox-tinymce {
|
||||
border-color: $color;
|
||||
}
|
||||
// focus样式
|
||||
.tox .tox-edit-area::before {
|
||||
border-color: $color;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tox.tox-silver-sink.tox-tinymce-aux {
|
||||
/** 该样式默认为1300的zIndex */
|
||||
z-index: $dropdown-index;
|
||||
}
|
||||
|
||||
.tox-fullscreen .tox.tox-tinymce-aux {
|
||||
z-index: $dropdown-index !important;
|
||||
}
|
||||
|
||||
.app-tinymce {
|
||||
/**
|
||||
隐藏右上角upgrade按钮
|
||||
*/
|
||||
.tox-promotion {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** 保持focus时与primary色一致 */
|
||||
.tox .tox-edit-area::before {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
// antd原生表单 校验失败样式
|
||||
.ant-form-item:has(.ant-form-item-explain-error) {
|
||||
$error-color: #ff3860;
|
||||
|
||||
@include tinymce-valid-fail($error-color);
|
||||
}
|
||||
|
||||
// useVbenForm 校验失败样式
|
||||
.form-valid-error {
|
||||
$error-color: hsl(var(--destructive));
|
||||
|
||||
@include tinymce-valid-fail($error-color);
|
||||
}
|
||||
|
||||
// 全屏下样式处理 不去掉transform位置会异常
|
||||
div[role='dialog']:has(.tox.tox-tinymce.tox-fullscreen) {
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
// Any plugins you want to setting has to be imported
|
||||
// Detail plugins list see https://www.tinymce.com/docs/plugins/
|
||||
// Custom builds see https://www.tinymce.com/download/custom-builds/
|
||||
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
|
||||
|
||||
// quickbars 快捷栏
|
||||
export const plugins =
|
||||
'preview importcss searchreplace autolink autosave save directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount help charmap emoticons accordion';
|
||||
|
||||
export const toolbar =
|
||||
'undo redo | accordion accordionremove | blocks fontfamily fontsize | bold italic underline strikethrough | align numlist bullist | link image | table media | lineheight outdent indent| forecolor backcolor removeformat | charmap emoticons | code fullscreen preview | save print | pagebreak anchor codesample | ltr rtl';
|
||||
2
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/index.ts
Normal file
2
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as MenuSelectTable } from './src/menu-select-table.vue';
|
||||
export { default as TreeSelectPanel } from './src/tree-select-panel.vue';
|
||||
98
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/data.tsx
Normal file
98
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/data.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { ID } from '#/api/common';
|
||||
import type { MenuOption } from '#/api/system/menu/model';
|
||||
|
||||
import { h, markRaw } from 'vue';
|
||||
|
||||
import { FolderIcon, MenuIcon, OkButtonIcon, VbenIcon } from '@vben/icons';
|
||||
|
||||
export interface Permission {
|
||||
checked: boolean;
|
||||
id: ID;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MenuPermissionOption extends MenuOption {
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
// (M目录 C菜单 F按钮)
|
||||
// 支持多种格式的菜单类型值
|
||||
const menuTypes: Record<string, { icon: ReturnType<typeof markRaw>; value: string }> = {
|
||||
c: { icon: markRaw(MenuIcon), value: '菜单' },
|
||||
menu: { icon: markRaw(MenuIcon), value: '菜单' },
|
||||
Menu: { icon: markRaw(MenuIcon), value: '菜单' },
|
||||
catalog: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
directory: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
folder: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
m: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
catalogue: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
Catalogue: { icon: markRaw(FolderIcon), value: '目录' },
|
||||
component: { icon: markRaw(OkButtonIcon), value: '按钮' },
|
||||
Component: { icon: markRaw(OkButtonIcon), value: '按钮' },
|
||||
f: { icon: markRaw(OkButtonIcon), value: '按钮' },
|
||||
button: { icon: markRaw(OkButtonIcon), value: '按钮' },
|
||||
};
|
||||
|
||||
export const nodeOptions = [
|
||||
{ label: '节点关联', value: true },
|
||||
{ label: '节点独立', value: false },
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
title: '菜单名称',
|
||||
field: 'menuName',
|
||||
treeNode: true,
|
||||
headerAlign: 'left',
|
||||
align: 'left',
|
||||
width: 230,
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
field: 'menuIcon',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
if (row?.menuIcon === '#' || !row?.menuIcon) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
<span class={'flex justify-center'}>
|
||||
<VbenIcon icon={row.menuIcon} />
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
field: 'menuType',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
const typeKey = `${row.menuType ?? ''}`.toString().trim().toLowerCase();
|
||||
const current = menuTypes[typeKey];
|
||||
if (!current) {
|
||||
return '未知';
|
||||
}
|
||||
return (
|
||||
<span class="flex items-center justify-center gap-1">
|
||||
{h(current.icon, { class: 'size-[18px]' })}
|
||||
<span>{current.value}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '权限标识',
|
||||
field: 'permissions',
|
||||
headerAlign: 'left',
|
||||
align: 'left',
|
||||
slots: {
|
||||
default: 'permissions',
|
||||
},
|
||||
},
|
||||
];
|
||||
206
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/helper.tsx
Normal file
206
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/helper.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { MenuPermissionOption } from './data';
|
||||
|
||||
import type { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import type { MenuOption } from '#/api/system/menu/model';
|
||||
|
||||
import { eachTree, treeToList } from '@vben/utils';
|
||||
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { difference, isEmpty, isUndefined } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* 权限列设置是否全选
|
||||
* @param record 行记录
|
||||
* @param checked 是否选中
|
||||
*/
|
||||
export function setPermissionsChecked(
|
||||
record: MenuPermissionOption,
|
||||
checked: boolean,
|
||||
) {
|
||||
if (record?.permissions?.length > 0) {
|
||||
// 全部设置为选中
|
||||
record.permissions.forEach((permission) => {
|
||||
permission.checked = checked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前行 & 所有子节点选中状态
|
||||
* @param record 行
|
||||
* @param checked 是否选中
|
||||
*/
|
||||
export function rowAndChildrenChecked(
|
||||
record: MenuPermissionOption,
|
||||
checked: boolean,
|
||||
) {
|
||||
// 当前行选中
|
||||
setPermissionsChecked(record, checked);
|
||||
// 所有子节点选中
|
||||
record?.children?.forEach?.((permission) => {
|
||||
rowAndChildrenChecked(permission as MenuPermissionOption, checked);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* void方法 会直接修改原始数据
|
||||
* 将树结构转为 tree+permissions结构
|
||||
* @param menus 后台返回的menu
|
||||
*/
|
||||
export function menusWithPermissions(menus: MenuOption[]) {
|
||||
eachTree(menus, (item: MenuPermissionOption) => {
|
||||
validateMenuTree(item);
|
||||
if (item.children && item.children.length > 0) {
|
||||
/**
|
||||
* 所有为按钮的节点提取出来
|
||||
* 需要注意 这里需要过滤目录下直接是按钮的情况
|
||||
* 将按钮往children添加而非加到permissions
|
||||
*/
|
||||
const permissions = item.children.filter(
|
||||
(child: MenuOption) =>
|
||||
isComponentType(child.menuType) && !isCatalogueType(item.menuType),
|
||||
);
|
||||
// 取差集
|
||||
const diffCollection = difference(item.children, permissions);
|
||||
// 更新后的children 即去除按钮
|
||||
item.children = diffCollection;
|
||||
|
||||
// permissions作为字段添加到item
|
||||
const permissionsArr = permissions.map((permission) => {
|
||||
return {
|
||||
id: permission.id,
|
||||
label: permission.menuName,
|
||||
checked: false,
|
||||
};
|
||||
});
|
||||
item.permissions = permissionsArr;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表格选中
|
||||
* @param checkedKeys 选中的keys
|
||||
* @param menus 菜单 转换后的菜单
|
||||
* @param tableApi api
|
||||
* @param association 是否节点关联
|
||||
*/
|
||||
export function setTableChecked(
|
||||
checkedKeys: (number | string)[],
|
||||
menus: MenuPermissionOption[],
|
||||
tableApi: ReturnType<typeof useVbenVxeGrid>['1'],
|
||||
association: boolean,
|
||||
) {
|
||||
// tree转list
|
||||
const menuList: MenuPermissionOption[] = treeToList(menus);
|
||||
// 拿到勾选的行数据
|
||||
let checkedRows = menuList.filter((item) => checkedKeys.includes(item.id));
|
||||
|
||||
/**
|
||||
* 节点独立切换到节点关联 只需要最末尾的数据 即children为空
|
||||
*/
|
||||
if (!association) {
|
||||
checkedRows = checkedRows.filter(
|
||||
(item) => isUndefined(item.children) || isEmpty(item.children),
|
||||
);
|
||||
}
|
||||
|
||||
// 设置行选中 & permissions选中
|
||||
checkedRows.forEach((item) => {
|
||||
tableApi.grid.setCheckboxRow(item, true);
|
||||
if (item?.permissions?.length > 0) {
|
||||
item.permissions.forEach((permission) => {
|
||||
if (checkedKeys.includes(permission.id)) {
|
||||
permission.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 节点独立切换到节点关联
|
||||
* 勾选后还需要过滤权限没有任何勾选的情况 这时候取消行的勾选
|
||||
*/
|
||||
if (!association) {
|
||||
const emptyRows = checkedRows.filter((item) => {
|
||||
if (isUndefined(item.permissions) || isEmpty(item.permissions)) {
|
||||
return false;
|
||||
}
|
||||
return item.permissions.every(
|
||||
(permission) => permission.checked === false,
|
||||
);
|
||||
});
|
||||
// 设置为不选中
|
||||
tableApi.grid.setCheckboxRow(emptyRows, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为菜单类型(Menu/C)
|
||||
*/
|
||||
function isMenuType(menuType: string): boolean {
|
||||
const type = menuType?.toLowerCase();
|
||||
return type === 'c' || type === 'menu';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为目录类型(Catalogue/M)
|
||||
*/
|
||||
function isCatalogueType(menuType: string): boolean {
|
||||
const type = menuType?.toLowerCase();
|
||||
return type === 'm' || type === 'catalogue' || type === 'catalog' || type === 'directory' || type === 'folder';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为按钮类型(Component/F)
|
||||
*/
|
||||
function isComponentType(menuType: string): boolean {
|
||||
const type = menuType?.toLowerCase();
|
||||
return type === 'f' || type === 'component' || type === 'button';
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验是否符合规范 给出warning提示
|
||||
*
|
||||
* 不符合规范
|
||||
* 比如: 菜单下放目录 菜单下放菜单
|
||||
* 比如: 按钮下放目录 按钮下放菜单 按钮下放按钮
|
||||
* @param menu menu
|
||||
*/
|
||||
function validateMenuTree(menu: MenuOption) {
|
||||
// 菜单下不能放目录/菜单
|
||||
if (isMenuType(menu.menuType)) {
|
||||
menu.children?.forEach?.((item) => {
|
||||
if (isMenuType(item.menuType) || isCatalogueType(item.menuType)) {
|
||||
const description = `错误用法: [${menu.menuName} - 菜单]下不能放 目录/菜单 -> [${item.menuName}]`;
|
||||
console.warn(description);
|
||||
notification.warning({
|
||||
message: '提示',
|
||||
description,
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// 按钮为最末级 不能再放置
|
||||
if (isComponentType(menu.menuType)) {
|
||||
/**
|
||||
* 其实可以直接判断length 这里为了更准确知道menuName 采用遍历的形式
|
||||
*/
|
||||
menu.children?.forEach?.((item) => {
|
||||
if (
|
||||
isMenuType(item.menuType) ||
|
||||
isComponentType(item.menuType) ||
|
||||
isCatalogueType(item.menuType)
|
||||
) {
|
||||
const description = `错误用法: [${menu.menuName} - 按钮]下不能放置'目录/菜单/按钮' -> [${item.menuName}]`;
|
||||
console.warn(description);
|
||||
notification.warning({
|
||||
message: '提示',
|
||||
description,
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
62
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/hook.tsx
Normal file
62
Yi.Vben5.Vue3/apps/web-antd/src/components/tree/src/hook.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type { TourProps } from 'ant-design-vue';
|
||||
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { Tour } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* 全屏引导
|
||||
* @returns value
|
||||
*/
|
||||
export function useFullScreenGuide() {
|
||||
const open = ref(false);
|
||||
/**
|
||||
* 是否已读 只显示一次
|
||||
*/
|
||||
const read = useLocalStorage('menu_select_fullscreen_read', false);
|
||||
|
||||
function openGuide() {
|
||||
if (!read.value) {
|
||||
open.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeGuide() {
|
||||
open.value = false;
|
||||
read.value = true;
|
||||
}
|
||||
|
||||
const steps: TourProps['steps'] = [
|
||||
{
|
||||
title: '提示',
|
||||
description: '点击这里可以全屏',
|
||||
target: () =>
|
||||
document.querySelector(
|
||||
'div#menu-select-table .vxe-tools--operate > button[title="全屏"]',
|
||||
)!,
|
||||
},
|
||||
];
|
||||
|
||||
const FullScreenGuide = defineComponent({
|
||||
name: 'FullScreenGuide',
|
||||
inheritAttrs: false,
|
||||
setup() {
|
||||
return () => (
|
||||
<Tour
|
||||
onClose={closeGuide}
|
||||
open={open.value}
|
||||
steps={steps}
|
||||
zIndex={9999}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
FullScreenGuide,
|
||||
openGuide,
|
||||
closeGuide,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
<!--
|
||||
不兼容也不会兼容一些错误用法
|
||||
比如: 菜单下放目录 菜单下放菜单
|
||||
比如: 按钮下放目录 按钮下放菜单 按钮下放按钮
|
||||
-->
|
||||
<script setup lang="tsx">
|
||||
import type { RadioChangeEvent } from 'ant-design-vue';
|
||||
|
||||
import type { MenuPermissionOption } from './data';
|
||||
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { MenuOption } from '#/api/system/menu/model';
|
||||
|
||||
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import { cloneDeep, findGroupParentIds } from '@vben/utils';
|
||||
|
||||
import { Alert, Checkbox, RadioGroup, Space } from 'ant-design-vue';
|
||||
import { uniq } from 'lodash-es';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { columns, nodeOptions } from './data';
|
||||
import {
|
||||
menusWithPermissions,
|
||||
rowAndChildrenChecked,
|
||||
setPermissionsChecked,
|
||||
setTableChecked,
|
||||
} from './helper';
|
||||
import { useFullScreenGuide } from './hook';
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuSelectTable',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
checkedKeys: (number | string)[];
|
||||
defaultExpandAll?: boolean;
|
||||
menus: MenuOption[];
|
||||
}>(),
|
||||
{
|
||||
/**
|
||||
* 是否默认展开全部
|
||||
*/
|
||||
defaultExpandAll: true,
|
||||
/**
|
||||
* 注意这里不是双向绑定 需要调用getCheckedKeys实例方法来获取真正选中的节点
|
||||
*/
|
||||
checkedKeys: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否节点关联
|
||||
*/
|
||||
const association = defineModel<boolean>('association', {
|
||||
default: true,
|
||||
});
|
||||
|
||||
const gridOptions: VxeGridProps = {
|
||||
checkboxConfig: {
|
||||
// checkbox显示的字段
|
||||
labelField: 'menuName',
|
||||
// 是否严格模式 即节点不关联
|
||||
checkStrictly: !association.value,
|
||||
},
|
||||
size: 'small',
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: false,
|
||||
custom: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: false,
|
||||
isCurrent: false,
|
||||
keyField: 'id',
|
||||
},
|
||||
/**
|
||||
* 开启虚拟滚动
|
||||
* 数据量小可以选择关闭
|
||||
* 如果遇到样式问题(空白、错位 滚动等)可以选择关闭虚拟滚动
|
||||
*/
|
||||
scrollY: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parentId',
|
||||
rowField: 'id',
|
||||
transform: false,
|
||||
},
|
||||
// 溢出换行显示
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 用于界面显示选中的数量
|
||||
*/
|
||||
const checkedNum = ref(0);
|
||||
/**
|
||||
* 更新选中的数量
|
||||
*/
|
||||
function updateCheckedNumber() {
|
||||
checkedNum.value = getCheckedKeys().length;
|
||||
}
|
||||
|
||||
const [BasicTable, tableApi] = useVbenVxeGrid({
|
||||
gridOptions,
|
||||
gridEvents: {
|
||||
// 勾选事件
|
||||
checkboxChange: (params) => {
|
||||
// 选中还是取消选中
|
||||
const checked = params.checked;
|
||||
// 行
|
||||
const record = params.row;
|
||||
if (association.value) {
|
||||
// 节点关联
|
||||
// 设置所有子节点选中状态
|
||||
rowAndChildrenChecked(record, checked);
|
||||
} else {
|
||||
// 节点独立
|
||||
// 点行会勾选/取消全部权限 点权限不会勾选行
|
||||
setPermissionsChecked(record, checked);
|
||||
}
|
||||
updateCheckedNumber();
|
||||
},
|
||||
// 全选事件
|
||||
checkboxAll: (params) => {
|
||||
const records = params.$grid.getData();
|
||||
records.forEach((item) => {
|
||||
rowAndChildrenChecked(item, params.checked);
|
||||
});
|
||||
updateCheckedNumber();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置表格选中
|
||||
* @param menus menu
|
||||
* @param keys 选中的key
|
||||
* @param triggerOnchange 节点独立情况 不需要触发onChange(false)
|
||||
*/
|
||||
function setCheckedByKeys(
|
||||
menus: MenuPermissionOption[],
|
||||
keys: (number | string)[],
|
||||
triggerOnchange: boolean,
|
||||
) {
|
||||
menus.forEach((item) => {
|
||||
// 设置行选中
|
||||
if (keys.includes(item.id)) {
|
||||
tableApi.grid.setCheckboxRow(item, true);
|
||||
}
|
||||
// 设置权限columns选中
|
||||
if (item.permissions && item.permissions.length > 0) {
|
||||
// 遍历 设置勾选
|
||||
item.permissions.forEach((permission) => {
|
||||
if (keys.includes(permission.id)) {
|
||||
permission.checked = true;
|
||||
// 手动触发onChange来选中 节点独立情况不需要处理
|
||||
triggerOnchange && handlePermissionChange(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 设置children选中
|
||||
if (item.children && item.children.length > 0) {
|
||||
setCheckedByKeys(item.children as any, keys, triggerOnchange);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { FullScreenGuide, openGuide } = useFullScreenGuide();
|
||||
onMounted(() => {
|
||||
/**
|
||||
* 加载表格数据 转为指定结构
|
||||
*/
|
||||
watch(
|
||||
() => props.menus,
|
||||
async (menus) => {
|
||||
const clonedMenus = cloneDeep(menus);
|
||||
menusWithPermissions(clonedMenus);
|
||||
// console.log(clonedMenus);
|
||||
await tableApi.grid.loadData(clonedMenus);
|
||||
// 展开全部 默认true
|
||||
if (props.defaultExpandAll) {
|
||||
await nextTick();
|
||||
setExpandOrCollapse(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 节点关联变动 更新表格勾选效果
|
||||
*/
|
||||
watch(association, (value) => {
|
||||
tableApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
checkStrictly: !value,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* checkedKeys依赖menus
|
||||
* 要注意加载顺序
|
||||
* !!!要在外部确保menus先加载!!!
|
||||
*/
|
||||
watch(
|
||||
() => props.checkedKeys,
|
||||
(value) => {
|
||||
const allCheckedKeys = uniq([...value]);
|
||||
// 获取表格data 如果checkedKeys在menus的watch之前触发 这里会拿到空 导致勾选异常
|
||||
const records = tableApi.grid.getData();
|
||||
setCheckedByKeys(records, allCheckedKeys, association.value);
|
||||
updateCheckedNumber();
|
||||
|
||||
// 全屏引导
|
||||
setTimeout(openGuide, 1000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// 缓存上次(切换节点关系前)选中的keys
|
||||
const lastCheckedKeys = shallowRef<(number | string)[]>([]);
|
||||
/**
|
||||
* 节点关联变动 事件
|
||||
*/
|
||||
async function handleAssociationChange(e: RadioChangeEvent) {
|
||||
lastCheckedKeys.value = getCheckedKeys();
|
||||
// 清空全部permissions选中
|
||||
const records = tableApi.grid.getData();
|
||||
records.forEach((item) => {
|
||||
rowAndChildrenChecked(item, false);
|
||||
});
|
||||
// 需要清空全部勾选
|
||||
await tableApi.grid.clearCheckboxRow();
|
||||
// 滚动到顶部
|
||||
await tableApi.grid.scrollTo(0, 0);
|
||||
|
||||
// 节点切换 不同的选中
|
||||
setTableChecked(lastCheckedKeys.value, records, tableApi, !e.target.value);
|
||||
|
||||
updateCheckedNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部展开/折叠
|
||||
* @param expand 是否展开
|
||||
*/
|
||||
function setExpandOrCollapse(expand: boolean) {
|
||||
tableApi.grid?.setAllTreeExpand(expand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限列表 checkbox勾选的事件
|
||||
* @param row 行
|
||||
*/
|
||||
function handlePermissionChange(row: any) {
|
||||
// 节点关联
|
||||
if (association.value) {
|
||||
const checkedPermissions = row.permissions.filter(
|
||||
(item: any) => item.checked === true,
|
||||
);
|
||||
// 有一条选中 则整个行选中
|
||||
if (checkedPermissions.length > 0) {
|
||||
tableApi.grid.setCheckboxRow(row, true);
|
||||
}
|
||||
// 无任何选中 则整个行不选中
|
||||
if (checkedPermissions.length === 0) {
|
||||
tableApi.grid.setCheckboxRow(row, false);
|
||||
}
|
||||
}
|
||||
// 节点独立 不处理
|
||||
updateCheckedNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取勾选的key
|
||||
* @param records 行记录列表
|
||||
* @param addCurrent 是否添加当前行的id
|
||||
*/
|
||||
function getKeys(records: MenuPermissionOption[], addCurrent: boolean) {
|
||||
const allKeys: (number | string)[] = [];
|
||||
records.forEach((item) => {
|
||||
// 处理children
|
||||
if (item.children && item.children.length > 0) {
|
||||
const keys = getKeys(item.children as MenuPermissionOption[], addCurrent);
|
||||
allKeys.push(...keys);
|
||||
} else {
|
||||
// 当前行的id
|
||||
addCurrent && allKeys.push(item.id);
|
||||
// 当前行权限id 获取已经选中的
|
||||
if (item.permissions && item.permissions.length > 0) {
|
||||
const ids = item.permissions
|
||||
.filter((m) => m.checked === true)
|
||||
.map((m) => m.id);
|
||||
allKeys.push(...ids);
|
||||
}
|
||||
}
|
||||
});
|
||||
return uniq(allKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取选中的key
|
||||
*/
|
||||
function getCheckedKeys() {
|
||||
// 节点关联
|
||||
if (association.value) {
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
|
||||
// 子节点
|
||||
const nodeKeys = getKeys(records, true);
|
||||
// 所有父节点
|
||||
// Note: findGroupParentIds is typed for number[] but works with strings at runtime
|
||||
const parentIds = findGroupParentIds(
|
||||
props.menus,
|
||||
nodeKeys as any,
|
||||
) as (string | number)[];
|
||||
// 拼接 去重
|
||||
const realKeys = uniq([...parentIds, ...nodeKeys]);
|
||||
return realKeys;
|
||||
}
|
||||
// 节点独立
|
||||
|
||||
// 勾选的行
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
|
||||
// 全部数据 用于获取permissions
|
||||
const allRecords = tableApi?.grid?.getData?.() ?? [];
|
||||
// 表格已经选中的行ids
|
||||
const checkedIds = records.map((item) => item.id);
|
||||
// 所有已经勾选权限的ids
|
||||
const permissionIds = getKeys(allRecords, false);
|
||||
// 合并 去重
|
||||
const allIds = uniq([...checkedIds, ...permissionIds]);
|
||||
return allIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露给外部使用 获取已选中的key
|
||||
*/
|
||||
defineExpose({
|
||||
getCheckedKeys,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col" id="menu-select-table">
|
||||
<BasicTable>
|
||||
<template #toolbar-actions>
|
||||
<RadioGroup
|
||||
v-model:value="association"
|
||||
:options="nodeOptions"
|
||||
button-style="solid"
|
||||
option-type="button"
|
||||
@change="handleAssociationChange"
|
||||
/>
|
||||
<Alert class="mx-2" type="info">
|
||||
<template #message>
|
||||
<div>
|
||||
已选中
|
||||
<span class="text-primary mx-1 font-semibold">
|
||||
{{ checkedNum }}
|
||||
</span>
|
||||
个节点
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<Space>
|
||||
<a-button @click="setExpandOrCollapse(false)">
|
||||
{{ $t('pages.common.collapse') }}
|
||||
</a-button>
|
||||
<a-button @click="setExpandOrCollapse(true)">
|
||||
{{ $t('pages.common.expand') }}
|
||||
</a-button>
|
||||
</Space>
|
||||
</template>
|
||||
<template #permissions="{ row }">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<Checkbox
|
||||
v-for="permission in row.permissions"
|
||||
:key="permission.id"
|
||||
v-model:checked="permission.checked"
|
||||
@change="() => handlePermissionChange(row)"
|
||||
>
|
||||
{{ permission.label }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</template>
|
||||
</BasicTable>
|
||||
<!-- 全屏引导 -->
|
||||
<FullScreenGuide />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-alert) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface';
|
||||
import type { DataNode } from 'ant-design-vue/es/tree';
|
||||
import type { CheckInfo } from 'ant-design-vue/es/vc-tree/props';
|
||||
|
||||
import type { PropType, SetupContext } from 'vue';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue';
|
||||
|
||||
import { findGroupParentIds, treeToList } from '@vben/utils';
|
||||
|
||||
import { Checkbox, Tree } from 'ant-design-vue';
|
||||
import { uniq } from 'lodash-es';
|
||||
|
||||
/** 需要禁止透传 */
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
checkStrictly: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
expandAllOnInit: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
fieldNames: {
|
||||
default: () => ({ key: 'id', title: 'label' }),
|
||||
type: Object as PropType<{ key: string; title: string }>,
|
||||
},
|
||||
/** 点击节点关联/独立时 清空已勾选的节点 */
|
||||
resetOnStrictlyChange: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
treeData: {
|
||||
default: () => [],
|
||||
type: Array as PropType<DataNode[]>,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits<{ checkStrictlyChange: [boolean] }>();
|
||||
|
||||
const expandStatus = ref(false);
|
||||
const selectAllStatus = ref(false);
|
||||
|
||||
/**
|
||||
* 后台的这个字段跟antd/ele是反的
|
||||
* 组件库这个字段代表不关联
|
||||
* 后台这个代表关联
|
||||
*/
|
||||
const innerCheckedStrictly = computed(() => {
|
||||
return !props.checkStrictly;
|
||||
});
|
||||
|
||||
const associationText = computed(() => {
|
||||
return props.checkStrictly ? '父子节点关联' : '父子节点独立';
|
||||
});
|
||||
|
||||
/**
|
||||
* 这个只用于界面显示
|
||||
* 关联情况下 只会有最末尾的节点被选中
|
||||
*/
|
||||
const checkedKeys = defineModel('value', {
|
||||
default: () => [],
|
||||
type: Array as PropType<(number | string)[]>,
|
||||
});
|
||||
// 所有节点的ID
|
||||
const allKeys = computed(() => {
|
||||
const idField = props.fieldNames.key;
|
||||
return treeToList(props.treeData).map((item: any) => item[idField]);
|
||||
});
|
||||
|
||||
/** 已经选择的所有节点 包括子/父节点 用于提交 */
|
||||
const checkedRealKeys = ref<(number | string)[]>([]);
|
||||
|
||||
/**
|
||||
* 取第一次的menuTree id 设置到checkedMenuKeys
|
||||
* 主要为了解决没有任何修改 直接点击保存的情况
|
||||
*
|
||||
* length为0情况(即新增时候没有勾选节点) 勾选这里会延迟触发 节点会拼接上父节点 导致ID重复
|
||||
*/
|
||||
const stop = watch([checkedKeys, () => props.treeData], () => {
|
||||
if (
|
||||
props.checkStrictly &&
|
||||
checkedKeys.value.length > 0 &&
|
||||
props.treeData.length > 0
|
||||
) {
|
||||
/** 找到父节点 添加上 */
|
||||
// Note: findGroupParentIds is typed for number[] but works with strings at runtime
|
||||
const parentIds = findGroupParentIds(
|
||||
props.treeData,
|
||||
checkedKeys.value as any,
|
||||
{ id: props.fieldNames.key },
|
||||
) as (string | number)[];
|
||||
/**
|
||||
* uniq 解决上面的id重复问题
|
||||
*/
|
||||
checkedRealKeys.value = uniq([...parentIds, ...checkedKeys.value]);
|
||||
stop();
|
||||
}
|
||||
if (!props.checkStrictly && checkedKeys.value.length > 0) {
|
||||
/** 节点独立 这里是全部的节点 */
|
||||
checkedRealKeys.value = checkedKeys.value;
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param checkedStateKeys 已经选中的子节点的ID
|
||||
* @param info info.halfCheckedKeys为父节点的ID
|
||||
*/
|
||||
type CheckedState<T = number | string> =
|
||||
| T[]
|
||||
| { checked: T[]; halfChecked: T[] };
|
||||
function handleChecked(checkedStateKeys: CheckedState, info: CheckInfo) {
|
||||
// 数组的话为节点关联
|
||||
if (Array.isArray(checkedStateKeys)) {
|
||||
const halfCheckedKeys = (info.halfCheckedKeys || []) as (number | string)[];
|
||||
checkedRealKeys.value = [...halfCheckedKeys, ...checkedStateKeys];
|
||||
} else {
|
||||
checkedRealKeys.value = [...checkedStateKeys.checked];
|
||||
// fix: Invalid prop: type check failed for prop "value". Expected Array, got Object
|
||||
checkedKeys.value = [...checkedStateKeys.checked];
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpandChange(e: CheckboxChangeEvent) {
|
||||
// 这个用于展示
|
||||
checkedKeys.value = e.target.checked ? allKeys.value : [];
|
||||
// 这个用于提交
|
||||
checkedRealKeys.value = e.target.checked ? allKeys.value : [];
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
function handleExpandOrCollapseAll(e: CheckboxChangeEvent) {
|
||||
const expand = e.target.checked;
|
||||
expandedKeys.value = expand ? allKeys.value : [];
|
||||
}
|
||||
|
||||
function handleCheckStrictlyChange(e: CheckboxChangeEvent) {
|
||||
emit('checkStrictlyChange', e.target.checked);
|
||||
if (props.resetOnStrictlyChange) {
|
||||
checkedKeys.value = [];
|
||||
checkedRealKeys.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露方法来获取用于提交的全部节点
|
||||
* uniq去重(保险方案)
|
||||
*/
|
||||
defineExpose({
|
||||
getCheckedKeys: () => uniq(checkedRealKeys.value),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.expandAllOnInit) {
|
||||
await nextTick();
|
||||
expandedKeys.value = allKeys.value;
|
||||
}
|
||||
});
|
||||
|
||||
const slots = useSlots() as SetupContext['slots'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background w-full rounded-lg border-[1px] p-[12px]">
|
||||
<div class="flex items-center justify-between gap-2 border-b-[1px] pb-2">
|
||||
<div>
|
||||
<span>节点状态: </span>
|
||||
<span :class="[props.checkStrictly ? 'text-primary' : 'text-red-500']">
|
||||
{{ associationText }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
已选中
|
||||
<span class="text-primary mx-1 font-semibold">
|
||||
{{ checkedRealKeys.length }}
|
||||
</span>
|
||||
个节点
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between border-b-[1px] py-2"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:checked="expandStatus"
|
||||
@change="handleExpandOrCollapseAll"
|
||||
>
|
||||
展开/折叠全部
|
||||
</Checkbox>
|
||||
<Checkbox v-model:checked="selectAllStatus" @change="handleExpandChange">
|
||||
全选/取消全选
|
||||
</Checkbox>
|
||||
<Checkbox :checked="checkStrictly" @change="handleCheckStrictlyChange">
|
||||
父子节点关联
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<Tree
|
||||
v-if="treeData.length > 0"
|
||||
v-model:check-strictly="innerCheckedStrictly"
|
||||
v-model:checked-keys="checkedKeys"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:checkable="true"
|
||||
:field-names="fieldNames"
|
||||
:selectable="false"
|
||||
:tree-data="treeData"
|
||||
@check="handleChecked"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in Object.keys(slots)"
|
||||
:key="slotName"
|
||||
#[slotName]="data"
|
||||
>
|
||||
<slot :name="slotName" v-bind="data ?? {}"></slot>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @description: 旧版文件上传组件 使用FileUpload代替
|
||||
*/
|
||||
export { default as FileUploadOld } from './src/file-upload.vue';
|
||||
/**
|
||||
* @description: 旧版图片上传组件 使用ImageUpload代替
|
||||
*/
|
||||
export { default as ImageUploadOld } from './src/image-upload.vue';
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
|
||||
import type { AxiosProgressEvent, UploadApi } from '#/api';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 建议使用拓展名(不带.)
|
||||
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
|
||||
* 需自行改造 ./helper/checkFileType方法
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: UploadApi;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url' | string;
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
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']>([]);
|
||||
const isLtMsg = ref<boolean>(true);
|
||||
const isActMsg = ref<boolean>(true);
|
||||
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, {
|
||||
onUploadProgress: 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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
list-type="text"
|
||||
:progress="{ showInfo: true }"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<a-button>
|
||||
<UploadOutlined />
|
||||
{{ $t('component.upload.upload') }}
|
||||
</a-button>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fileTypeFromBlob } from '@vben/utils';
|
||||
|
||||
/**
|
||||
* 不支持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[]) {
|
||||
if (!accepts || accepts?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
console.log(file);
|
||||
const fileType = await fileTypeFromBlob(file);
|
||||
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'];
|
||||
/**
|
||||
* 判断文件类型是否符合要求
|
||||
* @param file file对象
|
||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
* @returns 是否通过文件类型校验
|
||||
*/
|
||||
export async function checkImageFileType(file: File, accepts: string[]) {
|
||||
// 空的accepts 使用默认规则
|
||||
if (!accepts || accepts.length === 0) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
|
||||
import type { AxiosProgressEvent, UploadApi } from '#/api';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Modal, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
import { ossInfo } from '#/api/system/oss';
|
||||
|
||||
import { checkImageFileType, defaultImageAccept } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: UploadApi;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
listType?: ListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url';
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
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']>([]);
|
||||
const isLtMsg = ref<boolean>(true);
|
||||
const isActMsg = ref<boolean>(true);
|
||||
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) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => {
|
||||
resolve(reader.result as T);
|
||||
});
|
||||
reader.addEventListener('error', (error) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
if (!file.url && !file.preview) {
|
||||
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, {
|
||||
onUploadProgress: 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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
:progress="{ showInfo: true }"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="mt-2 flex flex-wrap items-center text-[14px]"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
<Modal
|
||||
:footer="null"
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<img :src="previewImage" alt="" style="width: 100%" />
|
||||
</Modal>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FileUpload } from './src/file-upload.vue';
|
||||
export { default as ImageUpload } from './src/image-upload.vue';
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
385
Yi.Vben5.Vue3/apps/web-antd/src/components/upload/src/hook.ts
Normal file
385
Yi.Vben5.Vue3/apps/web-antd/src/components/upload/src/hook.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
122
Yi.Vben5.Vue3/apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal file
122
Yi.Vben5.Vue3/apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user