init project
This commit is contained in:
49
apps/web-antd/src/components/content-wrap/content-wrap.vue
Normal file
49
apps/web-antd/src/components/content-wrap/content-wrap.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
参考自 https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/components/ContentWrap/src/ContentWrap.vue
|
||||
保证和 yudao-ui-admin-vue3 功能的一致性
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { ShieldQuestion } from '@vben/icons';
|
||||
|
||||
import { Card, Tooltip } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'ContentWrap' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
bodyStyle?: CSSProperties;
|
||||
message?: string;
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
bodyStyle: () => ({ padding: '10px' }),
|
||||
title: '',
|
||||
message: '',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :body-style="bodyStyle" :title="title" class="mb-4">
|
||||
<template v-if="title" #title>
|
||||
<div class="flex items-center">
|
||||
<span class="text-4 font-[700]">{{ title }}</span>
|
||||
<Tooltip placement="right">
|
||||
<template #title>
|
||||
<div class="max-w-[200px]">{{ message }}</div>
|
||||
</template>
|
||||
<ShieldQuestion :size="14" class="ml-5px" />
|
||||
</Tooltip>
|
||||
<div class="pl-20px flex flex-grow">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<slot name="extra"></slot>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</Card>
|
||||
</template>
|
||||
1
apps/web-antd/src/components/content-wrap/index.ts
Normal file
1
apps/web-antd/src/components/content-wrap/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ContentWrap } from './content-wrap.vue';
|
||||
157
apps/web-antd/src/components/cropper/cropper-avatar.vue
Normal file
157
apps/web-antd/src/components/cropper/cropper-avatar.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { CropperAvatarProps } from './typing';
|
||||
|
||||
import { computed, ref, unref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import cropperModal from './cropper-modal.vue';
|
||||
|
||||
defineOptions({ name: 'CropperAvatar' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
||||
width: 200,
|
||||
value: '',
|
||||
showBtn: true,
|
||||
btnProps: () => ({}),
|
||||
btnText: '',
|
||||
uploadApi: () => Promise.resolve(),
|
||||
size: 5,
|
||||
});
|
||||
|
||||
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('ui.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>
|
||||
<Button
|
||||
v-if="showBtn"
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
@click="openModal"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
{{ btnText ? btnText : $t('ui.cropper.selectImage') }}
|
||||
</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>
|
||||
357
apps/web-antd/src/components/cropper/cropper-modal.vue
Normal file
357
apps/web-antd/src/components/cropper/cropper-modal.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CropendResult, CropperModalProps, CropperType } from './typing';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { dataURLtoBlob, isFunction } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
message,
|
||||
Space,
|
||||
Tooltip,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import CropperImage from './cropper.vue';
|
||||
|
||||
defineOptions({ name: 'CropperModal' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperModalProps>(), {
|
||||
circled: true,
|
||||
size: 0,
|
||||
src: '',
|
||||
uploadApi: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']);
|
||||
|
||||
let filename = '';
|
||||
const src = ref(props.src || '');
|
||||
const previewSource = ref('');
|
||||
const cropper = ref<CropperType>();
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const prefixCls = 'cropper-am';
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onConfirm: handleOk,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
|
||||
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('ui.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: CropperType) {
|
||||
cropper.value = cropperInstance;
|
||||
// 画布加载完毕 关闭 loading
|
||||
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 url = await uploadApi({ file: blob, filename, name: 'file' });
|
||||
emit('uploadSuccess', { data: url, source: previewSource.value });
|
||||
await modalApi.close();
|
||||
} finally {
|
||||
modalLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-bind="$attrs"
|
||||
:confirm-text="$t('ui.cropper.okText')"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('ui.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"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<Upload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="[]"
|
||||
accept="image/*"
|
||||
>
|
||||
<Tooltip :title="$t('ui.cropper.selectImage')" placement="bottom">
|
||||
<Button size="small" type="primary">
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
<Space>
|
||||
<Tooltip :title="$t('ui.cropper.btn_reset')" placement="bottom">
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="$t('ui.cropper.btn_rotate_left')"
|
||||
placement="bottom"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
:title="$t('ui.cropper.btn_rotate_right')"
|
||||
placement="bottom"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="$t('ui.cropper.btn_scale_x')" placement="bottom">
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="$t('ui.cropper.btn_scale_y')" placement="bottom">
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="$t('ui.cropper.btn_zoom_in')" placement="bottom">
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="$t('ui.cropper.btn_zoom_out')" placement="bottom">
|
||||
<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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img
|
||||
v-if="previewSource"
|
||||
:alt="$t('ui.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>
|
||||
</Modal>
|
||||
</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>
|
||||
173
apps/web-antd/src/components/cropper/cropper.vue
Normal file
173
apps/web-antd/src/components/cropper/cropper.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { CropperProps } from './typing';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import Cropper from 'cropperjs';
|
||||
|
||||
import { defaultOptions } from './typing';
|
||||
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
defineOptions({ name: 'CropperImage' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperProps>(), {
|
||||
src: '',
|
||||
alt: '',
|
||||
circled: false,
|
||||
realTimePreview: true,
|
||||
height: '360px',
|
||||
crossorigin: undefined,
|
||||
imageStyle: () => ({}),
|
||||
options: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError']);
|
||||
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 debounceRealTimeCropped = useDebounceFn(realTimeCropped, 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;
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true;
|
||||
realTimeCropped();
|
||||
emit('ready', cropper.value);
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
...props.options,
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCropped() {
|
||||
props.realTimePreview && cropped();
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function cropped() {
|
||||
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>
|
||||
3
apps/web-antd/src/components/cropper/index.ts
Normal file
3
apps/web-antd/src/components/cropper/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CropperAvatar } from './cropper-avatar.vue';
|
||||
export { default as CropperImage } from './cropper.vue';
|
||||
export type { CropperType } from './typing';
|
||||
68
apps/web-antd/src/components/cropper/typing.ts
Normal file
68
apps/web-antd/src/components/cropper/typing.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ButtonProps } from 'ant-design-vue';
|
||||
import type Cropper from 'cropperjs';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
export interface apiFunParams {
|
||||
file: Blob;
|
||||
filename: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string;
|
||||
imgInfo: Cropper.Data;
|
||||
}
|
||||
|
||||
export interface CropperProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
circled?: boolean;
|
||||
realTimePreview?: boolean;
|
||||
height?: number | string;
|
||||
crossorigin?: '' | 'anonymous' | 'use-credentials' | undefined;
|
||||
imageStyle?: CSSProperties;
|
||||
options?: Cropper.Options;
|
||||
}
|
||||
|
||||
export interface CropperAvatarProps {
|
||||
width?: number | string;
|
||||
value?: string;
|
||||
showBtn?: boolean;
|
||||
btnProps?: ButtonProps;
|
||||
btnText?: string;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CropperModalProps {
|
||||
circled?: boolean;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
src?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const defaultOptions: Cropper.Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true,
|
||||
};
|
||||
|
||||
export type { Cropper as CropperType };
|
||||
80
apps/web-antd/src/components/description/description.vue
Normal file
80
apps/web-antd/src/components/description/description.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="tsx">
|
||||
import type { DescriptionsProps } from 'ant-design-vue';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { DescriptionItemSchema, DescriptionsOptions } from './typing';
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
|
||||
|
||||
/** 对 Descriptions 进行二次封装 */
|
||||
const Description = defineComponent({
|
||||
name: 'Descriptions',
|
||||
props: {
|
||||
data: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
schema: {
|
||||
type: Array as PropType<DescriptionItemSchema[]>,
|
||||
default: () => [],
|
||||
},
|
||||
// Descriptions 原生 props
|
||||
componentProps: {
|
||||
type: Object as PropType<DescriptionsProps>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
setup(props: DescriptionsOptions) {
|
||||
// TODO @puhui999:每个 field 的 slot 的考虑
|
||||
// TODO @puhui999:from 5.0:extra: () => getSlot(slots, 'extra')
|
||||
/** 过滤掉不需要展示的 */
|
||||
const shouldShowItem = (item: DescriptionItemSchema) => {
|
||||
if (item.hidden === undefined) return true;
|
||||
return typeof item.hidden === 'function'
|
||||
? !item.hidden(props.data)
|
||||
: !item.hidden;
|
||||
};
|
||||
/** 渲染内容 */
|
||||
const renderContent = (item: DescriptionItemSchema) => {
|
||||
if (item.content) {
|
||||
return typeof item.content === 'function'
|
||||
? item.content(props.data)
|
||||
: item.content;
|
||||
}
|
||||
return item.field ? props.data?.[item.field] : null;
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Descriptions
|
||||
{...props}
|
||||
bordered={props.componentProps?.bordered}
|
||||
colon={props.componentProps?.colon}
|
||||
column={props.componentProps?.column}
|
||||
extra={props.componentProps?.extra}
|
||||
layout={props.componentProps?.layout}
|
||||
size={props.componentProps?.size}
|
||||
title={props.componentProps?.title}
|
||||
>
|
||||
{props.schema?.filter(shouldShowItem).map((item) => (
|
||||
<DescriptionsItem
|
||||
contentStyle={item.contentStyle}
|
||||
key={item.field || String(item.label)}
|
||||
label={item.label}
|
||||
labelStyle={item.labelStyle}
|
||||
span={item.span}
|
||||
>
|
||||
{renderContent(item)}
|
||||
</DescriptionsItem>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// TODO @puhui999:from 5.0:emits: ['register'] 事件
|
||||
export default Description;
|
||||
</script>
|
||||
3
apps/web-antd/src/components/description/index.ts
Normal file
3
apps/web-antd/src/components/description/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Description } from './description.vue';
|
||||
export * from './typing';
|
||||
export { useDescription } from './use-description';
|
||||
27
apps/web-antd/src/components/description/typing.ts
Normal file
27
apps/web-antd/src/components/description/typing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DescriptionsProps } from 'ant-design-vue';
|
||||
|
||||
import type { CSSProperties, VNode } from 'vue';
|
||||
|
||||
// TODO @puhui999:【content】这个纠结下;1)vben2.0 是 render;https://doc.vvbin.cn/components/desc.html#usage 2)
|
||||
// TODO @puhui999:vben2.0 还有 sapn【done】、labelMinWidth、contentMinWidth
|
||||
// TODO @puhui999:【hidden】这个纠结下;1)vben2.0 是 show;
|
||||
export interface DescriptionItemSchema {
|
||||
label: string | VNode; // 内容的描述
|
||||
field?: string; // 对应 data 中的字段名
|
||||
content?: ((data: any) => string | VNode) | string | VNode; // 自定义需要展示的内容,比如说 dict-tag
|
||||
span?: number; // 包含列的数量
|
||||
labelStyle?: CSSProperties; // 自定义标签样式
|
||||
contentStyle?: CSSProperties; // 自定义内容样式
|
||||
hidden?: ((data: any) => boolean) | boolean; // 是否显示
|
||||
}
|
||||
|
||||
// TODO @puhui999:vben2.0 还有 title【done】、bordered【done】d、useCollapse、collapseOptions
|
||||
// TODO @puhui999:from 5.0:bordered 默认为 true
|
||||
// TODO @puhui999:from 5.0:column 默认为 lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4
|
||||
// TODO @puhui999:from 5.0:size 默认为 small;有 'default', 'middle', 'small', undefined
|
||||
// TODO @puhui999:from 5.0:useCollapse 默认为 true
|
||||
export interface DescriptionsOptions {
|
||||
data?: Record<string, any>; // 数据
|
||||
schema?: DescriptionItemSchema[]; // 描述项配置
|
||||
componentProps?: DescriptionsProps; // antd Descriptions 组件参数
|
||||
}
|
||||
71
apps/web-antd/src/components/description/use-description.ts
Normal file
71
apps/web-antd/src/components/description/use-description.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { DescriptionsOptions } from './typing';
|
||||
|
||||
import { defineComponent, h, isReactive, reactive, watch } from 'vue';
|
||||
|
||||
import { Description } from './index';
|
||||
|
||||
/** 描述列表 api 定义 */
|
||||
class DescriptionApi {
|
||||
private state = reactive<Record<string, any>>({});
|
||||
|
||||
constructor(options: DescriptionsOptions) {
|
||||
this.state = { ...options };
|
||||
}
|
||||
|
||||
getState(): DescriptionsOptions {
|
||||
return this.state as DescriptionsOptions;
|
||||
}
|
||||
|
||||
// TODO @puhui999:【setState】纠结下:1)vben2.0 是 data https://doc.vvbin.cn/components/desc.html#usage;
|
||||
setState(newState: Partial<DescriptionsOptions>) {
|
||||
this.state = { ...this.state, ...newState };
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtendedDescriptionApi = DescriptionApi;
|
||||
|
||||
export function useDescription(options: DescriptionsOptions) {
|
||||
const IS_REACTIVE = isReactive(options);
|
||||
const api = new DescriptionApi(options);
|
||||
// 扩展API
|
||||
const extendedApi: ExtendedDescriptionApi = api as never;
|
||||
const Desc = defineComponent({
|
||||
name: 'UseDescription',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
// 合并props和attrs到state
|
||||
api.setState({ ...attrs });
|
||||
|
||||
return () =>
|
||||
h(
|
||||
Description,
|
||||
{
|
||||
...api.getState(),
|
||||
...attrs,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 响应式支持
|
||||
if (IS_REACTIVE) {
|
||||
watch(
|
||||
() => options.schema,
|
||||
(newSchema) => {
|
||||
api.setState({ schema: newSchema });
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options.data,
|
||||
(newData) => {
|
||||
api.setState({ data: newData });
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
return [Desc, extendedApi] as const;
|
||||
}
|
||||
72
apps/web-antd/src/components/dict-tag/dict-tag.vue
Normal file
72
apps/web-antd/src/components/dict-tag/dict-tag.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
// import { isHexColor } from '@/utils/color' // TODO @芋艿:【可优化】增加 cssClass 的处理 https://gitee.com/yudaocode/yudao-ui-admin-vben/blob/v2.4.1/src/components/DictTag/src/DictTag.vue#L60
|
||||
import { getDictObj } from '#/utils';
|
||||
|
||||
interface DictTagProps {
|
||||
/**
|
||||
* 字典类型
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* 字典值
|
||||
*/
|
||||
value: any;
|
||||
/**
|
||||
* 图标
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<DictTagProps>();
|
||||
|
||||
/** 获取字典标签 */
|
||||
const dictTag = computed(() => {
|
||||
// 校验参数有效性
|
||||
if (!props.type || props.value === undefined || props.value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取字典对象
|
||||
const dict = getDictObj(props.type, String(props.value));
|
||||
if (!dict) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理颜色类型
|
||||
let colorType = dict.colorType;
|
||||
switch (colorType) {
|
||||
case 'danger': {
|
||||
colorType = 'error';
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
colorType = 'default';
|
||||
break;
|
||||
}
|
||||
case 'primary': {
|
||||
colorType = 'processing';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (!colorType) {
|
||||
colorType = 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: dict.label || '',
|
||||
colorType,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag v-if="dictTag" :color="dictTag.colorType">
|
||||
{{ dictTag.label }}
|
||||
</Tag>
|
||||
</template>
|
||||
1
apps/web-antd/src/components/dict-tag/index.ts
Normal file
1
apps/web-antd/src/components/dict-tag/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DictTag } from './dict-tag.vue';
|
||||
34
apps/web-antd/src/components/doc-alert/doc-alert.vue
Normal file
34
apps/web-antd/src/components/doc-alert/doc-alert.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import { isDocAlertEnable } from '@vben/hooks';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { Alert, Typography } from 'ant-design-vue';
|
||||
|
||||
export interface DocAlertProps {
|
||||
/**
|
||||
* 文档标题
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 文档 URL 地址
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
const props = defineProps<DocAlertProps>();
|
||||
|
||||
/** 跳转 URL 链接 */
|
||||
const goToUrl = () => {
|
||||
openWindow(props.url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert v-if="isDocAlertEnable()" type="info" show-icon class="mb-2 rounded">
|
||||
<template #message>
|
||||
<Typography.Link @click="goToUrl">
|
||||
【{{ title }}】文档地址:{{ url }}
|
||||
</Typography.Link>
|
||||
</template>
|
||||
</Alert>
|
||||
</template>
|
||||
1
apps/web-antd/src/components/doc-alert/index.ts
Normal file
1
apps/web-antd/src/components/doc-alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DocAlert } from './doc-alert.vue';
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- 数据字典 Select 选择器 -->
|
||||
<script lang="ts" setup>
|
||||
import type { DictSelectProps } from '../typing';
|
||||
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getDictObj, getIntDictOptions, getStrDictOptions } from '#/utils';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const props = withDefaults(defineProps<DictSelectProps>(), {
|
||||
valueType: 'str',
|
||||
selectType: 'select',
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
// 获得字典配置
|
||||
// TODO @dhb:可以使用 getDictOptions 替代么?
|
||||
const getDictOptions = computed(() => {
|
||||
switch (props.valueType) {
|
||||
case 'bool': {
|
||||
return getDictObj(props.dictType, 'bool');
|
||||
}
|
||||
case 'int': {
|
||||
return getIntDictOptions(props.dictType);
|
||||
}
|
||||
case 'str': {
|
||||
return getStrDictOptions(props.dictType);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-if="selectType === 'select'" class="w-1/1" v-bind="attrs">
|
||||
<SelectOption
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<RadioGroup v-if="selectType === 'radio'" class="w-1/1" v-bind="attrs">
|
||||
<Radio
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<CheckboxGroup v-if="selectType === 'checkbox'" class="w-1/1" v-bind="attrs">
|
||||
<Checkbox
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ApiSelectProps } from '#/components/form-create/typing';
|
||||
|
||||
import { defineComponent, onMounted, ref, useAttrs } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export const useApiSelect = (option: ApiSelectProps) => {
|
||||
return defineComponent({
|
||||
name: option.name,
|
||||
props: {
|
||||
// 选项标签
|
||||
labelField: {
|
||||
type: String,
|
||||
default: () => option.labelField ?? 'label',
|
||||
},
|
||||
// 选项的值
|
||||
valueField: {
|
||||
type: String,
|
||||
default: () => option.valueField ?? 'value',
|
||||
},
|
||||
// api 接口
|
||||
url: {
|
||||
type: String,
|
||||
default: () => option.url ?? '',
|
||||
},
|
||||
// 请求类型
|
||||
method: {
|
||||
type: String,
|
||||
default: 'GET',
|
||||
},
|
||||
// 选项解析函数
|
||||
parseFunc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 请求参数
|
||||
data: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 选择器类型,下拉框 select、多选框 checkbox、单选框 radio
|
||||
selectType: {
|
||||
type: String,
|
||||
default: 'select',
|
||||
},
|
||||
// 是否多选
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否远程搜索
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 远程搜索时携带的参数
|
||||
remoteField: {
|
||||
type: String,
|
||||
default: 'label',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const attrs = useAttrs();
|
||||
const options = ref<any[]>([]); // 下拉数据
|
||||
const loading = ref(false); // 是否正在从远程获取数据
|
||||
const queryParam = ref<any>(); // 当前输入的值
|
||||
const getOptions = async () => {
|
||||
options.value = [];
|
||||
// 接口选择器
|
||||
if (isEmpty(props.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (props.method) {
|
||||
case 'GET': {
|
||||
let url: string = props.url;
|
||||
if (props.remote && queryParam.value !== undefined) {
|
||||
url = url.includes('?')
|
||||
? `${url}&${props.remoteField}=${queryParam.value}`
|
||||
: `${url}?${props.remoteField}=${queryParam.value}`;
|
||||
}
|
||||
parseOptions(await requestClient.get(url));
|
||||
break;
|
||||
}
|
||||
case 'POST': {
|
||||
const data: any = JSON.parse(props.data);
|
||||
if (props.remote) {
|
||||
data[props.remoteField] = queryParam.value;
|
||||
}
|
||||
parseOptions(await requestClient.post(props.url, data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function parseOptions(data: any) {
|
||||
// 情况一:如果有自定义解析函数优先使用自定义解析
|
||||
if (!isEmpty(props.parseFunc)) {
|
||||
options.value = parseFunc()?.(data);
|
||||
return;
|
||||
}
|
||||
// 情况二:返回的直接是一个列表
|
||||
if (Array.isArray(data)) {
|
||||
parseOptions0(data);
|
||||
return;
|
||||
}
|
||||
// 情况二:返回的是分页数据,尝试读取 list
|
||||
data = data.list;
|
||||
if (!!data && Array.isArray(data)) {
|
||||
parseOptions0(data);
|
||||
return;
|
||||
}
|
||||
// 情况三:不是 yudao-vue-pro 标准返回
|
||||
console.warn(
|
||||
`接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseOptions0(data: any[]) {
|
||||
if (Array.isArray(data)) {
|
||||
options.value = data.map((item: any) => ({
|
||||
label: parseExpression(item, props.labelField),
|
||||
value: parseExpression(item, props.valueField),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
console.warn(`接口[${props.url}] 返回结果不是一个数组`);
|
||||
}
|
||||
|
||||
function parseFunc() {
|
||||
let parse: any = null;
|
||||
if (props.parseFunc) {
|
||||
// 解析字符串函数
|
||||
// eslint-disable-next-line no-new-func
|
||||
parse = new Function(`return ${props.parseFunc}`)();
|
||||
}
|
||||
return parse;
|
||||
}
|
||||
|
||||
function parseExpression(data: any, template: string) {
|
||||
// 检测是否使用了表达式
|
||||
if (!template.includes('${')) {
|
||||
return data[template];
|
||||
}
|
||||
// 正则表达式匹配模板字符串中的 ${...}
|
||||
const pattern = /\$\{([^}]*)\}/g;
|
||||
// 使用replace函数配合正则表达式和回调函数来进行替换
|
||||
return template.replaceAll(pattern, (_, expr) => {
|
||||
// expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值
|
||||
const result = data[expr.trim()]; // 去除前后空白,以防用户输入带空格的属性名
|
||||
if (!result) {
|
||||
console.warn(
|
||||
`接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
const remoteMethod = async (query: any) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
queryParam.value = query;
|
||||
await getOptions();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await getOptions();
|
||||
});
|
||||
|
||||
const buildSelect = () => {
|
||||
if (props.multiple) {
|
||||
// fix:多写此步是为了解决 multiple 属性问题
|
||||
return (
|
||||
<Select
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
mode="multiple"
|
||||
{...attrs}
|
||||
// TODO: remote 对等实现
|
||||
// remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<SelectOption key={index} value={item.value}>
|
||||
{item.label}
|
||||
</SelectOption>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
{...attrs}
|
||||
// TODO: @dhb52 remote 对等实现, 还是说没作用
|
||||
// remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<SelectOption key={index} value={item.value}>
|
||||
{item.label}
|
||||
</SelectOption>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
const buildCheckbox = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<CheckboxGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<Checkbox key={index} value={item.value}>
|
||||
{item.label}
|
||||
</Checkbox>
|
||||
),
|
||||
)}
|
||||
</CheckboxGroup>
|
||||
);
|
||||
};
|
||||
const buildRadio = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<RadioGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<Radio key={index} value={item.value}>
|
||||
{item.label}
|
||||
</Radio>
|
||||
),
|
||||
)}
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
return () => (
|
||||
<>
|
||||
{(() => {
|
||||
switch (props.selectType) {
|
||||
case 'checkbox': {
|
||||
return buildCheckbox();
|
||||
}
|
||||
case 'radio': {
|
||||
return buildRadio();
|
||||
}
|
||||
case 'select': {
|
||||
return buildSelect();
|
||||
}
|
||||
default: {
|
||||
return buildSelect();
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import ImageUpload from '#/components/upload/image-upload.vue';
|
||||
|
||||
export const useImagesUpload = () => {
|
||||
return defineComponent({
|
||||
name: 'ImagesUpload',
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maxNumber: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
// TODO: @dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递
|
||||
return (props: { maxNumber?: number; multiple?: boolean }) => (
|
||||
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
182
apps/web-antd/src/components/form-create/helpers.ts
Normal file
182
apps/web-antd/src/components/form-create/helpers.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Menu } from '#/components/form-create/typing';
|
||||
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
|
||||
import { apiSelectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
import {
|
||||
useDictSelectRule,
|
||||
useEditorRule,
|
||||
useSelectRule,
|
||||
useUploadFileRule,
|
||||
useUploadImageRule,
|
||||
useUploadImagesRule,
|
||||
} from './rules';
|
||||
|
||||
export function makeRequiredRule() {
|
||||
return {
|
||||
type: 'Required',
|
||||
field: 'formCreate$required',
|
||||
title: '是否必填',
|
||||
};
|
||||
}
|
||||
|
||||
export const localeProps = (
|
||||
t: (msg: string) => any,
|
||||
prefix: string,
|
||||
rules: any[],
|
||||
) => {
|
||||
return rules.map((rule: { field: string; title: any }) => {
|
||||
if (rule.field === 'formCreate$required') {
|
||||
rule.title = t('props.required') || rule.title;
|
||||
} else if (rule.field && rule.field !== '_optionType') {
|
||||
rule.title = t(`components.${prefix}.${rule.field}`) || rule.title;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析表单组件的 field, title 等字段(递归,如果组件包含子组件)
|
||||
*
|
||||
* @param rule 组件的生成规则 https://www.form-create.com/v3/guide/rule
|
||||
* @param fields 解析后表单组件字段
|
||||
* @param parentTitle 如果是子表单,子表单的标题,默认为空
|
||||
*/
|
||||
export const parseFormFields = (
|
||||
rule: Record<string, any>,
|
||||
fields: Array<Record<string, any>> = [],
|
||||
parentTitle: string = '',
|
||||
) => {
|
||||
const { type, field, $required, title: tempTitle, children } = rule;
|
||||
if (field && tempTitle) {
|
||||
let title = tempTitle;
|
||||
if (parentTitle) {
|
||||
title = `${parentTitle}.${tempTitle}`;
|
||||
}
|
||||
let required = false;
|
||||
if ($required) {
|
||||
required = true;
|
||||
}
|
||||
fields.push({
|
||||
field,
|
||||
title,
|
||||
type,
|
||||
required,
|
||||
});
|
||||
// TODO 子表单 需要处理子表单字段
|
||||
// if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
|
||||
// // 解析子表单的字段
|
||||
// rule.props.rule.forEach((item) => {
|
||||
// parseFields(item, fieldsPermission, title)
|
||||
// })
|
||||
// }
|
||||
}
|
||||
if (children && Array.isArray(children)) {
|
||||
children.forEach((rule) => {
|
||||
parseFormFields(rule, fields);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单设计器增强 hook
|
||||
* 新增
|
||||
* - 文件上传
|
||||
* - 单图上传
|
||||
* - 多图上传
|
||||
* - 字典选择器
|
||||
* - 用户选择器
|
||||
* - 部门选择器
|
||||
* - 富文本
|
||||
*/
|
||||
export const useFormCreateDesigner = async (designer: Ref) => {
|
||||
const editorRule = useEditorRule();
|
||||
const uploadFileRule = useUploadFileRule();
|
||||
const uploadImageRule = useUploadImageRule();
|
||||
const uploadImagesRule = useUploadImagesRule();
|
||||
|
||||
/**
|
||||
* 构建表单组件
|
||||
*/
|
||||
const buildFormComponents = () => {
|
||||
// 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
|
||||
designer.value?.removeMenuItem('upload');
|
||||
// 移除自带的富文本组件规则,使用 editorRule 替代
|
||||
designer.value?.removeMenuItem('fc-editor');
|
||||
const components = [
|
||||
editorRule,
|
||||
uploadFileRule,
|
||||
uploadImageRule,
|
||||
uploadImagesRule,
|
||||
];
|
||||
components.forEach((component) => {
|
||||
// 插入组件规则
|
||||
designer.value?.addComponent(component);
|
||||
// 插入拖拽按钮到 `main` 分类下
|
||||
designer.value?.appendMenuItem('main', {
|
||||
icon: component.icon,
|
||||
name: component.name,
|
||||
label: component.label,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const userSelectRule = useSelectRule({
|
||||
name: 'UserSelect',
|
||||
label: '用户选择器',
|
||||
icon: 'icon-eye',
|
||||
});
|
||||
const deptSelectRule = useSelectRule({
|
||||
name: 'DeptSelect',
|
||||
label: '部门选择器',
|
||||
icon: 'icon-tree',
|
||||
});
|
||||
const dictSelectRule = useDictSelectRule();
|
||||
const apiSelectRule0 = useSelectRule({
|
||||
name: 'ApiSelect',
|
||||
label: '接口选择器',
|
||||
icon: 'icon-json',
|
||||
props: [...apiSelectRule],
|
||||
event: ['click', 'change', 'visibleChange', 'clear', 'blur', 'focus'],
|
||||
});
|
||||
|
||||
/**
|
||||
* 构建系统字段菜单
|
||||
*/
|
||||
const buildSystemMenu = () => {
|
||||
// 移除自带的下拉选择器组件,使用 currencySelectRule 替代
|
||||
// designer.value?.removeMenuItem('select')
|
||||
// designer.value?.removeMenuItem('radio')
|
||||
// designer.value?.removeMenuItem('checkbox')
|
||||
const components = [
|
||||
userSelectRule,
|
||||
deptSelectRule,
|
||||
dictSelectRule,
|
||||
apiSelectRule0,
|
||||
];
|
||||
const menu: Menu = {
|
||||
name: 'system',
|
||||
title: '系统字段',
|
||||
list: components.map((component) => {
|
||||
// 插入组件规则
|
||||
designer.value?.addComponent(component);
|
||||
// 插入拖拽按钮到 `system` 分类下
|
||||
return {
|
||||
icon: component.icon,
|
||||
name: component.name,
|
||||
label: component.label,
|
||||
};
|
||||
}),
|
||||
};
|
||||
designer.value?.addMenu(menu);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
buildFormComponents();
|
||||
buildSystemMenu();
|
||||
});
|
||||
};
|
||||
3
apps/web-antd/src/components/form-create/index.ts
Normal file
3
apps/web-antd/src/components/form-create/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useApiSelect } from './components/use-api-select';
|
||||
|
||||
export { useFormCreateDesigner } from './helpers';
|
||||
182
apps/web-antd/src/components/form-create/rules/data.ts
Normal file
182
apps/web-antd/src/components/form-create/rules/data.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
const selectRule = [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'selectType',
|
||||
title: '选择器类型',
|
||||
value: 'select',
|
||||
options: [
|
||||
{ label: '下拉框', value: 'select' },
|
||||
{ label: '单选框', value: 'radio' },
|
||||
{ label: '多选框', value: 'checkbox' },
|
||||
],
|
||||
// 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性
|
||||
control: [
|
||||
{
|
||||
value: 'select',
|
||||
condition: '==',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
'multiple',
|
||||
'clearable',
|
||||
'collapseTags',
|
||||
'multipleLimit',
|
||||
'allowCreate',
|
||||
'filterable',
|
||||
'noMatchText',
|
||||
'remote',
|
||||
'remoteMethod',
|
||||
'reserveKeyword',
|
||||
'defaultFirstOption',
|
||||
'automaticDropdown',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'filterable',
|
||||
title: '是否可搜索',
|
||||
},
|
||||
{ type: 'switch', field: 'multiple', title: '是否多选' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
},
|
||||
{ type: 'switch', field: 'clearable', title: '是否可以清空选项' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'collapseTags',
|
||||
title: '多选时是否将选中值按文字的形式展示',
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'multipleLimit',
|
||||
title: '多选时用户最多可以选择的项目数,为 0 则不限制',
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'autocomplete',
|
||||
title: 'autocomplete 属性',
|
||||
},
|
||||
{ type: 'input', field: 'placeholder', title: '占位符' },
|
||||
{ type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
|
||||
{
|
||||
type: 'input',
|
||||
field: 'noMatchText',
|
||||
title: '搜索条件无匹配时显示的文字',
|
||||
},
|
||||
{ type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'reserveKeyword',
|
||||
title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'defaultFirstOption',
|
||||
title: '在输入框按下回车,选择第一个匹配项',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'popperAppendToBody',
|
||||
title: '是否将弹出框插入至 body 元素',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'automaticDropdown',
|
||||
title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单',
|
||||
},
|
||||
];
|
||||
|
||||
const apiSelectRule = [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'url',
|
||||
title: 'url 地址',
|
||||
props: {
|
||||
placeholder: '/system/user/simple-list',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'method',
|
||||
title: '请求类型',
|
||||
value: 'GET',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
],
|
||||
control: [
|
||||
{
|
||||
value: 'GET',
|
||||
condition: '!=',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'data',
|
||||
title: '请求参数 JSON 格式',
|
||||
props: {
|
||||
autosize: true,
|
||||
type: 'textarea',
|
||||
placeholder: '{"type": 1}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'labelField',
|
||||
title: 'label 属性',
|
||||
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
|
||||
props: {
|
||||
placeholder: 'nickname',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'valueField',
|
||||
title: 'value 属性',
|
||||
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
|
||||
props: {
|
||||
placeholder: 'id',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'parseFunc',
|
||||
title: '选项解析函数',
|
||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||
(data: any)=>{ label: string; value: any }[]`,
|
||||
props: {
|
||||
autosize: true,
|
||||
rows: { minRows: 2, maxRows: 6 },
|
||||
type: 'textarea',
|
||||
placeholder: `
|
||||
function (data) {
|
||||
console.log(data)
|
||||
return data.list.map(item=> ({label: item.nickname,value: item.id}))
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'remote',
|
||||
info: '是否可搜索',
|
||||
title: '其中的选项是否从服务器远程加载',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'remoteField',
|
||||
title: '请求参数',
|
||||
info: '远程请求时请求携带的参数名称,如:name',
|
||||
},
|
||||
];
|
||||
|
||||
export { apiSelectRule, selectRule };
|
||||
6
apps/web-antd/src/components/form-create/rules/index.ts
Normal file
6
apps/web-antd/src/components/form-create/rules/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useDictSelectRule } from './use-dict-select';
|
||||
export { useEditorRule } from './use-editor-rule';
|
||||
export { useSelectRule } from './use-select-rule';
|
||||
export { useUploadFileRule } from './use-upload-file-rule';
|
||||
export { useUploadImageRule } from './use-upload-image-rule';
|
||||
export { useUploadImagesRule } from './use-upload-images-rule';
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import * as DictDataApi from '#/api/system/dict/type';
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/**
|
||||
* 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule
|
||||
*/
|
||||
export const useDictSelectRule = () => {
|
||||
const label = '字典选择器';
|
||||
const name = 'DictSelect';
|
||||
const rules = cloneDeep(selectRule);
|
||||
const dictOptions = ref<{ label: string; value: string }[]>([]); // 字典类型下拉数据
|
||||
onMounted(async () => {
|
||||
const data = await DictDataApi.getSimpleDictTypeList();
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
dictOptions.value =
|
||||
data?.map((item: DictDataApi.SystemDictTypeApi.DictType) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
})) ?? [];
|
||||
});
|
||||
return {
|
||||
icon: 'icon-descriptions',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'dictType',
|
||||
title: '字典类型',
|
||||
value: '',
|
||||
options: dictOptions.value,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'valueType',
|
||||
title: '字典值类型',
|
||||
value: 'str',
|
||||
options: [
|
||||
{ label: '数字', value: 'int' },
|
||||
{ label: '字符串', value: 'str' },
|
||||
{ label: '布尔值', value: 'bool' },
|
||||
],
|
||||
},
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useEditorRule = () => {
|
||||
const label = '富文本';
|
||||
const name = 'Tinymce';
|
||||
return {
|
||||
icon: 'icon-editor',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '高度',
|
||||
},
|
||||
{ type: 'switch', field: 'readonly', title: '是否只读' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SelectRuleOption } from '#/components/form-create/typing';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/**
|
||||
* 通用选择器规则 hook
|
||||
*
|
||||
* @param option 规则配置
|
||||
*/
|
||||
export const useSelectRule = (option: SelectRuleOption) => {
|
||||
const label = option.label;
|
||||
const name = option.name;
|
||||
const rules = cloneDeep(selectRule);
|
||||
return {
|
||||
icon: option.icon,
|
||||
label,
|
||||
name,
|
||||
event: option.event,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
if (!option.props) {
|
||||
option.props = [];
|
||||
}
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
...option.props,
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadFileRule = () => {
|
||||
const label = '文件上传';
|
||||
const name = 'FileUpload';
|
||||
return {
|
||||
icon: 'icon-upload',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '文件类型',
|
||||
value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
|
||||
options: [
|
||||
{ label: 'doc', value: 'doc' },
|
||||
{ label: 'xls', value: 'xls' },
|
||||
{ label: 'ppt', value: 'ppt' },
|
||||
{ label: 'txt', value: 'txt' },
|
||||
{ label: 'pdf', value: 'pdf' },
|
||||
],
|
||||
props: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'autoUpload',
|
||||
title: '是否在选取文件后立即进行上传',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'isShowTip',
|
||||
title: '是否显示提示',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'limit',
|
||||
title: '数量限制',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadImageRule = () => {
|
||||
const label = '单图上传';
|
||||
const name = 'ImageUpload';
|
||||
return {
|
||||
icon: 'icon-image',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '图片类型限制',
|
||||
value: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
options: [
|
||||
{ label: 'image/apng', value: 'image/apng' },
|
||||
{ label: 'image/bmp', value: 'image/bmp' },
|
||||
{ label: 'image/gif', value: 'image/gif' },
|
||||
{ label: 'image/jpeg', value: 'image/jpeg' },
|
||||
{ label: 'image/pjpeg', value: 'image/pjpeg' },
|
||||
{ label: 'image/svg+xml', value: 'image/svg+xml' },
|
||||
{ label: 'image/tiff', value: 'image/tiff' },
|
||||
{ label: 'image/webp', value: 'image/webp' },
|
||||
{ label: 'image/x-icon', value: 'image/x-icon' },
|
||||
],
|
||||
props: {
|
||||
multiple: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '组件高度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'width',
|
||||
title: '组件宽度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'borderradius',
|
||||
title: '组件边框圆角',
|
||||
value: '8px',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否显示删除按钮',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'showBtnText',
|
||||
title: '是否显示按钮文字',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadImagesRule = () => {
|
||||
const label = '多图上传';
|
||||
const name = 'ImagesUpload';
|
||||
return {
|
||||
icon: 'icon-image',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '图片类型限制',
|
||||
value: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
options: [
|
||||
{ label: 'image/apng', value: 'image/apng' },
|
||||
{ label: 'image/bmp', value: 'image/bmp' },
|
||||
{ label: 'image/gif', value: 'image/gif' },
|
||||
{ label: 'image/jpeg', value: 'image/jpeg' },
|
||||
{ label: 'image/pjpeg', value: 'image/pjpeg' },
|
||||
{ label: 'image/svg+xml', value: 'image/svg+xml' },
|
||||
{ label: 'image/tiff', value: 'image/tiff' },
|
||||
{ label: 'image/webp', value: 'image/webp' },
|
||||
{ label: 'image/x-icon', value: 'image/x-icon' },
|
||||
],
|
||||
props: {
|
||||
multiple: true,
|
||||
maxNumber: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'limit',
|
||||
title: '数量限制',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '组件高度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'width',
|
||||
title: '组件宽度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'borderradius',
|
||||
title: '组件边框圆角',
|
||||
value: '8px',
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
60
apps/web-antd/src/components/form-create/typing.ts
Normal file
60
apps/web-antd/src/components/form-create/typing.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Rule } from '@form-create/ant-design-vue';
|
||||
|
||||
/** 数据字典 Select 选择器组件 Props 类型 */
|
||||
export interface DictSelectProps {
|
||||
dictType: string; // 字典类型
|
||||
valueType?: 'bool' | 'int' | 'str'; // 字典值类型 TODO @芋艿:'boolean' | 'number' | 'string';需要和 vue3 一起统一!
|
||||
selectType?: 'checkbox' | 'radio' | 'select'; // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio
|
||||
formCreateInject?: any;
|
||||
}
|
||||
|
||||
/** 左侧拖拽按钮 */
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 左侧拖拽按钮分类 */
|
||||
export interface Menu {
|
||||
title: string;
|
||||
name: string;
|
||||
list: MenuItem[];
|
||||
}
|
||||
|
||||
export type MenuList = Array<Menu>;
|
||||
|
||||
// TODO @dhb52:MenuList、Menu、MenuItem、DragRule 这几个,是不是没用到呀?
|
||||
// 拖拽组件的规则
|
||||
export interface DragRule {
|
||||
icon: string;
|
||||
name: string;
|
||||
label: string;
|
||||
children?: string;
|
||||
inside?: true;
|
||||
drag?: string | true;
|
||||
dragBtn?: false;
|
||||
mask?: false;
|
||||
|
||||
rule(): Rule;
|
||||
|
||||
props(v: any, v1: any): Rule[];
|
||||
}
|
||||
|
||||
/** 通用 API 下拉组件 Props 类型 */
|
||||
export interface ApiSelectProps {
|
||||
name: string; // 组件名称
|
||||
labelField?: string; // 选项标签
|
||||
valueField?: string; // 选项的值
|
||||
url?: string; // url 接口
|
||||
isDict?: boolean; // 是否字典选择器
|
||||
}
|
||||
|
||||
/** 选择组件规则配置类型 */
|
||||
export interface SelectRuleOption {
|
||||
label: string; // label 名称
|
||||
name: string; // 组件名称
|
||||
icon: string; // 组件图标
|
||||
props?: any[]; // 组件规则
|
||||
event?: any[]; // 事件配置
|
||||
}
|
||||
38
apps/web-antd/src/components/iframe/iframe.vue
Normal file
38
apps/web-antd/src/components/iframe/iframe.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
interface IFrameProps {
|
||||
/** iframe 的源地址 */
|
||||
src: string;
|
||||
}
|
||||
|
||||
const props = defineProps<IFrameProps>();
|
||||
|
||||
const loading = ref(true);
|
||||
const height = ref('');
|
||||
const frameRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function init() {
|
||||
height.value = `${document.documentElement.clientHeight - 94.5}px`;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
init();
|
||||
}, 300);
|
||||
});
|
||||
// TODO @芋艿:优化:未来使用 vben 自带的内链实现
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" :style="`height:${height}`">
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
:src="props.src"
|
||||
style="width: 100%; height: 100%"
|
||||
frameborder="no"
|
||||
scrolling="auto"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
1
apps/web-antd/src/components/iframe/index.ts
Normal file
1
apps/web-antd/src/components/iframe/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as IFrame } from './iframe.vue';
|
||||
2
apps/web-antd/src/components/table-action/index.ts
Normal file
2
apps/web-antd/src/components/table-action/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TableAction } from './table-action.vue';
|
||||
export * from './typing';
|
||||
268
apps/web-antd/src/components/table-action/table-action.vue
Normal file
268
apps/web-antd/src/components/table-action/table-action.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<!-- add by 星语:参考 vben2 的方式,增加 TableAction 组件 -->
|
||||
<script setup lang="ts">
|
||||
import type { ButtonType } from 'ant-design-vue/es/button';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { ActionItem, PopConfirm } from './typing';
|
||||
|
||||
import { computed, toRaw } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isBoolean, isFunction } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Array as PropType<ActionItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
dropDownActions: {
|
||||
type: Array as PropType<ActionItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
divider: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
function isIfShow(action: ActionItem): boolean {
|
||||
const ifShow = action.ifShow;
|
||||
|
||||
let isIfShow = true;
|
||||
|
||||
if (isBoolean(ifShow)) {
|
||||
isIfShow = ifShow;
|
||||
}
|
||||
if (isFunction(ifShow)) {
|
||||
isIfShow = ifShow(action);
|
||||
}
|
||||
return isIfShow;
|
||||
}
|
||||
|
||||
const getActions = computed(() => {
|
||||
return (toRaw(props.actions) || [])
|
||||
.filter((action) => {
|
||||
return (
|
||||
(hasAccessByCodes(action.auth || []) ||
|
||||
(action.auth || []).length === 0) &&
|
||||
isIfShow(action)
|
||||
);
|
||||
})
|
||||
.map((action) => {
|
||||
const { popConfirm } = action;
|
||||
return {
|
||||
// getPopupContainer: document.body,
|
||||
type: 'link' as ButtonType,
|
||||
...action,
|
||||
...popConfirm,
|
||||
onConfirm: popConfirm?.confirm,
|
||||
onCancel: popConfirm?.cancel,
|
||||
enable: !!popConfirm,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const getDropdownList = computed((): any[] => {
|
||||
return (toRaw(props.dropDownActions) || [])
|
||||
.filter((action) => {
|
||||
return (
|
||||
(hasAccessByCodes(action.auth || []) ||
|
||||
(action.auth || []).length === 0) &&
|
||||
isIfShow(action)
|
||||
);
|
||||
})
|
||||
.map((action, index) => {
|
||||
const { label, popConfirm } = action;
|
||||
return {
|
||||
...action,
|
||||
...popConfirm,
|
||||
onConfirm: popConfirm?.confirm,
|
||||
onCancel: popConfirm?.cancel,
|
||||
text: label,
|
||||
divider:
|
||||
index < props.dropDownActions.length - 1 ? props.divider : false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function getPopConfirmProps(attrs: PopConfirm) {
|
||||
const originAttrs: any = attrs;
|
||||
delete originAttrs.icon;
|
||||
if (attrs.confirm && isFunction(attrs.confirm)) {
|
||||
originAttrs.onConfirm = attrs.confirm;
|
||||
delete originAttrs.confirm;
|
||||
}
|
||||
if (attrs.cancel && isFunction(attrs.cancel)) {
|
||||
originAttrs.onCancel = attrs.cancel;
|
||||
delete originAttrs.cancel;
|
||||
}
|
||||
return originAttrs;
|
||||
}
|
||||
|
||||
function getButtonProps(action: ActionItem) {
|
||||
const res = {
|
||||
type: action.type || 'primary',
|
||||
...action,
|
||||
};
|
||||
delete res.icon;
|
||||
return res;
|
||||
}
|
||||
|
||||
function handleMenuClick(e: any) {
|
||||
const action = getDropdownList.value[e.key];
|
||||
if (action.onClick && isFunction(action.onClick)) {
|
||||
action.onClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-table-action">
|
||||
<Space
|
||||
:size="
|
||||
getActions?.some((item: ActionItem) => item.type === 'link') ? 0 : 8
|
||||
"
|
||||
>
|
||||
<template v-for="(action, index) in getActions" :key="index">
|
||||
<Popconfirm
|
||||
v-if="action.popConfirm"
|
||||
v-bind="getPopConfirmProps(action.popConfirm)"
|
||||
>
|
||||
<template v-if="action.popConfirm.icon" #icon>
|
||||
<IconifyIcon :icon="action.popConfirm.icon" />
|
||||
</template>
|
||||
<Tooltip
|
||||
v-bind="
|
||||
typeof action.tooltip === 'string'
|
||||
? { title: action.tooltip }
|
||||
: { ...action.tooltip }
|
||||
"
|
||||
>
|
||||
<Button v-bind="getButtonProps(action)">
|
||||
<template v-if="action.icon" #icon>
|
||||
<IconifyIcon :icon="action.icon" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
<Tooltip
|
||||
v-else
|
||||
v-bind="
|
||||
typeof action.tooltip === 'string'
|
||||
? { title: action.tooltip }
|
||||
: { ...action.tooltip }
|
||||
"
|
||||
>
|
||||
<Button v-bind="getButtonProps(action)" @click="action.onClick">
|
||||
<template v-if="action.icon" #icon>
|
||||
<IconifyIcon :icon="action.icon" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Space>
|
||||
|
||||
<Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']">
|
||||
<slot name="more">
|
||||
<Button size="small" type="link">
|
||||
<template #icon>
|
||||
{{ $t('page.action.more') }}
|
||||
<IconifyIcon class="icon-more" icon="ant-design:more-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</slot>
|
||||
<template #overlay>
|
||||
<Menu @click="handleMenuClick">
|
||||
<Menu.Item v-for="(action, index) in getDropdownList" :key="index">
|
||||
<template v-if="action.popConfirm">
|
||||
<Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
|
||||
<template v-if="action.popConfirm.icon" #icon>
|
||||
<IconifyIcon :icon="action.popConfirm.icon" />
|
||||
</template>
|
||||
<div
|
||||
:class="
|
||||
action.disabled === true
|
||||
? 'cursor-not-allowed text-gray-300'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<IconifyIcon v-if="action.icon" :icon="action.icon" />
|
||||
<span class="ml-1">{{ action.text }}</span>
|
||||
</div>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
:class="
|
||||
action.disabled === true
|
||||
? 'cursor-not-allowed text-gray-300'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<IconifyIcon v-if="action.icon" :icon="action.icon" />
|
||||
{{ action.label }}
|
||||
</div>
|
||||
</template>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.m-table-action {
|
||||
.ant-btn {
|
||||
padding: 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ant-btn > .iconify + span,
|
||||
.ant-btn > span + .iconify {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.iconify {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
font-style: normal;
|
||||
line-height: 0;
|
||||
vertical-align: -0.125em;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popconfirm {
|
||||
.ant-popconfirm-buttons {
|
||||
.ant-btn {
|
||||
margin-inline-start: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
apps/web-antd/src/components/table-action/typing.ts
Normal file
27
apps/web-antd/src/components/table-action/typing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
|
||||
import type { TooltipProps } from 'ant-design-vue/es/tooltip/Tooltip';
|
||||
|
||||
export interface PopConfirm {
|
||||
title: string;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
confirm: () => void;
|
||||
cancel?: () => void;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionItem extends ButtonProps {
|
||||
onClick?: () => void;
|
||||
label?: string;
|
||||
color?: 'error' | 'success' | 'warning';
|
||||
icon?: string;
|
||||
popConfirm?: PopConfirm;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
// 权限编码控制是否显示
|
||||
auth?: string[];
|
||||
// 业务控制是否显示
|
||||
ifShow?: ((action: ActionItem) => boolean) | boolean;
|
||||
tooltip?: string | TooltipProps;
|
||||
}
|
||||
1
apps/web-antd/src/components/table-toolbar/index.ts
Normal file
1
apps/web-antd/src/components/table-toolbar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TableToolbar } from './table-toolbar.vue';
|
||||
79
apps/web-antd/src/components/table-toolbar/table-toolbar.vue
Normal file
79
apps/web-antd/src/components/table-toolbar/table-toolbar.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- add by puhui999:vxe table 工具栏二次封装,提供给 vxe 原生列表使用 -->
|
||||
<script setup lang="ts">
|
||||
import type { VxeToolbarInstance } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useContentMaximize, useRefresh } from '@vben/hooks';
|
||||
import { Expand, MsRefresh, Search, TMinimize } from '@vben/icons';
|
||||
|
||||
import { Button, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import { VxeToolbar } from '#/adapter/vxe-table';
|
||||
|
||||
/** 列表工具栏封装 */
|
||||
defineOptions({ name: 'TableToolbar' });
|
||||
|
||||
const props = defineProps<{
|
||||
hiddenSearch: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['update:hiddenSearch']);
|
||||
|
||||
const toolbarRef = ref<VxeToolbarInstance>();
|
||||
const { toggleMaximizeAndTabbarHidden, contentIsMaximize } =
|
||||
useContentMaximize();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
/** 隐藏搜索栏 */
|
||||
function onHiddenSearchBar() {
|
||||
emits('update:hiddenSearch', !props.hiddenSearch);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getToolbarRef: () => toolbarRef.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VxeToolbar ref="toolbarRef" custom>
|
||||
<template #toolPrefix>
|
||||
<slot></slot>
|
||||
<Tooltip placement="bottom">
|
||||
<template #title>
|
||||
<div class="max-w-[200px]">搜索</div>
|
||||
</template>
|
||||
<Button
|
||||
class="ml-2 font-[8px]"
|
||||
shape="circle"
|
||||
@click="onHiddenSearchBar"
|
||||
>
|
||||
<Search :size="15" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom">
|
||||
<template #title>
|
||||
<div class="max-w-[200px]">刷新</div>
|
||||
</template>
|
||||
<Button class="ml-2 font-[8px]" shape="circle" @click="refresh">
|
||||
<MsRefresh :size="15" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom">
|
||||
<template #title>
|
||||
<div class="max-w-[200px]">
|
||||
{{ contentIsMaximize ? '还原' : '全屏' }}
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
class="ml-2 font-[8px]"
|
||||
shape="circle"
|
||||
@click="toggleMaximizeAndTabbarHidden"
|
||||
>
|
||||
<Expand v-if="!contentIsMaximize" :size="15" />
|
||||
<TMinimize v-else :size="15" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</VxeToolbar>
|
||||
</template>
|
||||
357
apps/web-antd/src/components/tinymce/editor.vue
Normal file
357
apps/web-antd/src/components/tinymce/editor.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
|
||||
import type { Editor as EditorType } from 'tinymce/tinymce';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
ref,
|
||||
unref,
|
||||
useAttrs,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { buildShortUUID, isNumber } from '@vben/utils';
|
||||
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
|
||||
import { bindHandlers } from './helper';
|
||||
import ImgUpload from './img-upload.vue';
|
||||
import {
|
||||
plugins as defaultPlugins,
|
||||
toolbar as defaultToolbar,
|
||||
} from './tinymce';
|
||||
|
||||
type InitOptions = IPropTypes['init'];
|
||||
|
||||
defineOptions({ name: 'Tinymce', inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object as PropType<Partial<InitOptions>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
toolbar: {
|
||||
type: String,
|
||||
default: defaultToolbar,
|
||||
},
|
||||
plugins: {
|
||||
type: String,
|
||||
default: defaultPlugins,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
required: false,
|
||||
default: 400,
|
||||
},
|
||||
width: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
required: false,
|
||||
default: 'auto',
|
||||
},
|
||||
showImageUpload: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
/** 外部使用 v-model 绑定值 */
|
||||
const modelValue = defineModel('modelValue', { default: '', type: String });
|
||||
|
||||
/** TinyMCE 自托管:https://www.jianshu.com/p/59a9c3802443 */
|
||||
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
|
||||
|
||||
const attrs = useAttrs();
|
||||
const editorRef = ref<EditorType>();
|
||||
const fullscreen = ref(false); // 图片上传,是否放到全屏的位置
|
||||
const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
|
||||
const elRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const containerWidth = computed(() => {
|
||||
const width = props.width;
|
||||
if (isNumber(width)) {
|
||||
return `${width}px`;
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
/** 主题皮肤 */
|
||||
const { isDark } = usePreferences();
|
||||
const skinName = computed(() => {
|
||||
return isDark.value ? 'oxide-dark' : 'oxide';
|
||||
});
|
||||
|
||||
const contentCss = computed(() => {
|
||||
return isDark.value ? 'dark' : 'default';
|
||||
});
|
||||
|
||||
/** 国际化:需要在 langs 目录下,放好语言包 */
|
||||
const { locale } = usePreferences();
|
||||
const langName = computed(() => {
|
||||
if (locale.value === 'en-US') {
|
||||
return 'en';
|
||||
}
|
||||
return 'zh_CN';
|
||||
});
|
||||
|
||||
/** 监听 mode、locale 进行主题、语言切换 */
|
||||
const init = ref(true);
|
||||
watch(
|
||||
() => [preferences.theme.mode, preferences.app.locale],
|
||||
async () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
// 通过 init + v-if 来挂载/卸载组件
|
||||
destroy();
|
||||
init.value = false;
|
||||
await nextTick();
|
||||
init.value = true;
|
||||
// 等待加载完成
|
||||
await nextTick();
|
||||
setEditorMode();
|
||||
},
|
||||
);
|
||||
|
||||
const initOptions = computed((): InitOptions => {
|
||||
const { height, options, plugins, toolbar } = props;
|
||||
return {
|
||||
height,
|
||||
toolbar,
|
||||
menubar: 'file edit view insert format tools table help',
|
||||
plugins,
|
||||
language: langName.value,
|
||||
branding: false, // 禁止显示,右下角的“使用 TinyMCE 构建”
|
||||
default_link_target: '_blank',
|
||||
link_title: false,
|
||||
object_resizing: true, // 和 vben2.0 不同,它默认是 false
|
||||
auto_focus: undefined, // 和 vben2.0 不同,它默认是 true
|
||||
skin: skinName.value,
|
||||
content_css: contentCss.value,
|
||||
content_style:
|
||||
'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }',
|
||||
contextmenu: 'link image table',
|
||||
image_advtab: true, // 图片高级选项
|
||||
image_caption: true,
|
||||
importcss_append: true,
|
||||
noneditable_class: 'mceNonEditable',
|
||||
paste_data_images: true, // 允许粘贴图片,默认 base64 格式,images_upload_handler 启用时为上传
|
||||
quickbars_selection_toolbar:
|
||||
'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
|
||||
toolbar_mode: 'sliding',
|
||||
...options,
|
||||
images_upload_handler: (blobInfo) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = blobInfo.blob() as File;
|
||||
const { httpRequest } = useUpload();
|
||||
httpRequest(file)
|
||||
.then((url) => {
|
||||
resolve(url);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('tinymce 上传图片失败:', error);
|
||||
reject(error.message);
|
||||
});
|
||||
});
|
||||
},
|
||||
setup: (editor) => {
|
||||
editorRef.value = editor;
|
||||
editor.on('init', (e) => initSetup(e));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** 监听 options.readonly 是否只读 */
|
||||
const disabled = computed(() => props.options.readonly ?? false);
|
||||
watch(
|
||||
() => props.options,
|
||||
(options) => {
|
||||
const getDisabled = options && Reflect.get(options, 'readonly');
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
editor.mode.set(getDisabled ? 'readonly' : 'design');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!initOptions.value.inline) {
|
||||
tinymceId.value = buildShortUUID('tiny-vue');
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initEditor();
|
||||
setEditorMode();
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
setEditorMode();
|
||||
});
|
||||
|
||||
function setEditorMode() {
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
const mode = props.options.readonly ? 'readonly' : 'design';
|
||||
editor.mode.set(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
const editor = unref(editorRef);
|
||||
editor?.destroy();
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const el = unref(elRef);
|
||||
if (el) {
|
||||
el.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
function initSetup(e: any) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const value = modelValue.value || '';
|
||||
|
||||
editor.setContent(value);
|
||||
bindModelHandlers(editor);
|
||||
bindHandlers(e, attrs, unref(editorRef));
|
||||
}
|
||||
|
||||
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
|
||||
if (
|
||||
editor &&
|
||||
typeof val === 'string' &&
|
||||
val !== prevVal &&
|
||||
val !== editor.getContent({ format: attrs.outputFormat })
|
||||
) {
|
||||
editor.setContent(val);
|
||||
}
|
||||
}
|
||||
|
||||
function bindModelHandlers(editor: any) {
|
||||
const modelEvents = attrs.modelEvents ?? null;
|
||||
const normalizedEvents = Array.isArray(modelEvents)
|
||||
? modelEvents.join(' ')
|
||||
: modelEvents;
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(val, prevVal) => {
|
||||
setValue(editor, val, prevVal);
|
||||
},
|
||||
);
|
||||
|
||||
editor.on(normalizedEvents || 'change keyup undo redo', () => {
|
||||
const content = editor.getContent({ format: attrs.outputFormat });
|
||||
emit('change', content);
|
||||
});
|
||||
|
||||
editor.on('FullscreenStateChanged', (e: any) => {
|
||||
fullscreen.value = e.state;
|
||||
});
|
||||
}
|
||||
|
||||
function getUploadingImgName(name: string) {
|
||||
return `[uploading:${name}]`;
|
||||
}
|
||||
|
||||
function handleImageUploading(name: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
|
||||
const content = editor?.getContent() ?? '';
|
||||
setValue(editor, content);
|
||||
}
|
||||
|
||||
function handleDone(name: string, url: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const content = editor?.getContent() ?? '';
|
||||
const val =
|
||||
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
|
||||
setValue(editor, val);
|
||||
}
|
||||
|
||||
function handleError(name: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const content = editor?.getContent() ?? '';
|
||||
const val = content?.replace(getUploadingImgName(name), '') ?? '';
|
||||
setValue(editor, val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ width: containerWidth }" class="app-tinymce">
|
||||
<ImgUpload
|
||||
v-if="showImageUpload"
|
||||
v-show="editorRef"
|
||||
:disabled="disabled"
|
||||
:fullscreen="fullscreen"
|
||||
@done="handleDone"
|
||||
@error="handleError"
|
||||
@uploading="handleImageUploading"
|
||||
/>
|
||||
<Editor
|
||||
v-if="!initOptions.inline && init"
|
||||
v-model="modelValue"
|
||||
:init="initOptions"
|
||||
:style="{ visibility: 'hidden', zIndex: 3000 }"
|
||||
:tinymce-script-src="tinymceScriptSrc"
|
||||
license-key="gpl"
|
||||
/>
|
||||
<slot v-else></slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.tox.tox-silver-sink.tox-tinymce-aux {
|
||||
z-index: 2025; /* 由于 vben modal/drawer 的 zIndex 为 2000,需要调整 z-index(默认 1300)超过它,避免遮挡 */
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-tinymce {
|
||||
position: relative;
|
||||
line-height: normal;
|
||||
|
||||
:deep(.textarea) {
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏右上角 tinymce upgrade 按钮 */
|
||||
:deep(.tox-promotion) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
85
apps/web-antd/src/components/tinymce/helper.ts
Normal file
85
apps/web-antd/src/components/tinymce/helper.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const validEvents = new Set([
|
||||
'onActivate',
|
||||
'onAddUndo',
|
||||
'onBeforeAddUndo',
|
||||
'onBeforeExecCommand',
|
||||
'onBeforeGetContent',
|
||||
'onBeforePaste',
|
||||
'onBeforeRenderUI',
|
||||
'onBeforeSetContent',
|
||||
'onBlur',
|
||||
'onChange',
|
||||
'onClearUndos',
|
||||
'onClick',
|
||||
'onContextMenu',
|
||||
'onCopy',
|
||||
'onCut',
|
||||
'onDblclick',
|
||||
'onDeactivate',
|
||||
'onDirty',
|
||||
'onDrag',
|
||||
'onDragDrop',
|
||||
'onDragEnd',
|
||||
'onDragGesture',
|
||||
'onDragOver',
|
||||
'onDrop',
|
||||
'onExecCommand',
|
||||
'onFocus',
|
||||
'onFocusIn',
|
||||
'onFocusOut',
|
||||
'onGetContent',
|
||||
'onHide',
|
||||
'onInit',
|
||||
'onKeyDown',
|
||||
'onKeyPress',
|
||||
'onKeyUp',
|
||||
'onLoadContent',
|
||||
'onMouseDown',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
'onMouseMove',
|
||||
'onMouseOut',
|
||||
'onMouseOver',
|
||||
'onMouseUp',
|
||||
'onNodeChange',
|
||||
'onObjectResized',
|
||||
'onObjectResizeStart',
|
||||
'onObjectSelected',
|
||||
'onPaste',
|
||||
'onPostProcess',
|
||||
'onPostRender',
|
||||
'onPreProcess',
|
||||
'onProgressState',
|
||||
'onRedo',
|
||||
'onRemove',
|
||||
'onReset',
|
||||
'onSaveContent',
|
||||
'onSelectionChange',
|
||||
'onSetAttrib',
|
||||
'onSetContent',
|
||||
'onShow',
|
||||
'onSubmit',
|
||||
'onUndo',
|
||||
'onVisualAid',
|
||||
]);
|
||||
|
||||
const isValidKey = (key: string) => validEvents.has(key);
|
||||
|
||||
export const bindHandlers = (
|
||||
initEvent: Event,
|
||||
listeners: any,
|
||||
editor: any,
|
||||
): void => {
|
||||
Object.keys(listeners)
|
||||
.filter((element) => isValidKey(element))
|
||||
.forEach((key: string) => {
|
||||
const handler = listeners[key];
|
||||
if (typeof handler === 'function') {
|
||||
if (key === 'onInit') {
|
||||
handler(initEvent, editor);
|
||||
} else {
|
||||
editor.on(key.slice(2), (e: any) => handler(e, editor));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
85
apps/web-antd/src/components/tinymce/img-upload.vue
Normal file
85
apps/web-antd/src/components/tinymce/img-upload.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Button, Upload } from 'ant-design-vue';
|
||||
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
|
||||
defineOptions({ name: 'TinymceImageUpload' });
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
fullscreen: {
|
||||
// 图片上传,是否放到全屏的位置
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['uploading', 'done', 'error']);
|
||||
|
||||
const uploading = ref(false);
|
||||
|
||||
const getButtonProps = computed(() => {
|
||||
const { disabled } = props;
|
||||
return {
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
// 1. emit 上传中
|
||||
const file = info.file as File;
|
||||
const name = file?.name;
|
||||
if (!uploading.value) {
|
||||
emit('uploading', name);
|
||||
uploading.value = true;
|
||||
}
|
||||
|
||||
// 2. 执行上传
|
||||
const { httpRequest } = useUpload();
|
||||
try {
|
||||
const url = await httpRequest(file);
|
||||
emit('done', name, url);
|
||||
} catch {
|
||||
emit('error', name);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
|
||||
<Upload
|
||||
:show-upload-list="false"
|
||||
accept=".jpg,.jpeg,.gif,.png,.webp"
|
||||
multiple
|
||||
:custom-request="customRequest"
|
||||
>
|
||||
<Button type="primary" v-bind="{ ...getButtonProps }">
|
||||
{{ $t('ui.upload.imgUpload') }}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tinymce-image-upload {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
|
||||
&.fullscreen {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/web-antd/src/components/tinymce/index.ts
Normal file
1
apps/web-antd/src/components/tinymce/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tinymce } from './editor.vue';
|
||||
17
apps/web-antd/src/components/tinymce/tinymce.ts
Normal file
17
apps/web-antd/src/components/tinymce/tinymce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Any plugins you want to setting has to be imported
|
||||
// Detail plugins list see https://www.tiny.cloud/docs/plugins/
|
||||
// Custom builds see https://www.tiny.cloud/download/custom-builds/
|
||||
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
|
||||
|
||||
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 emoticons accordion';
|
||||
|
||||
// 和 vben2.0 不同,从 https://www.tiny.cloud/ 拷贝 Vue 部分,然后去掉 importword exportword exportpdf | math 部分,并额外增加最后一行(来自 vben2.0 差异的部分)
|
||||
export const toolbar =
|
||||
'undo redo | accordion accordionremove | \\\n' +
|
||||
' blocks fontfamily fontsize | bold italic underline strikethrough | \\\n' +
|
||||
' align numlist bullist | link image | table media | \\\n' +
|
||||
' lineheight outdent indent | forecolor backcolor removeformat | \\\n' +
|
||||
' charmap emoticons | code fullscreen preview | save print | \\\n' +
|
||||
' pagebreak anchor codesample | ltr rtl | \\\n' +
|
||||
' hr searchreplace alignleft aligncenter alignright blockquote subscript superscript';
|
||||
219
apps/web-antd/src/components/upload/file-upload.vue
Normal file
219
apps/web-antd/src/components/upload/file-upload.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<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 { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { CloudUpload } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { Button, message, Upload } from 'ant-design-vue';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: false,
|
||||
},
|
||||
);
|
||||
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 (Array.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: UploadResultStatus.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 = checkFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, progressEvent);
|
||||
info.onSuccess!(res);
|
||||
message.success($t('ui.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;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
||||
if (props.maxNumber === 1) {
|
||||
return list.length > 0 ? list[0] : '';
|
||||
}
|
||||
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">
|
||||
<Button>
|
||||
<CloudUpload />
|
||||
{{ $t('ui.upload.upload') }}
|
||||
</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>
|
||||
20
apps/web-antd/src/components/upload/helper.ts
Normal file
20
apps/web-antd/src/components/upload/helper.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const newTypes = accepts.join('|');
|
||||
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
|
||||
return reg.test(file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
export function checkImgType(
|
||||
file: File,
|
||||
accepts: string[] = defaultImageAccepts,
|
||||
) {
|
||||
return checkFileType(file, accepts);
|
||||
}
|
||||
274
apps/web-antd/src/components/upload/image-upload.vue
Normal file
274
apps/web-antd/src/components/upload/image-upload.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<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 { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { UploadListType } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { CloudUpload } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { message, Modal, Upload } from 'ant-design-vue';
|
||||
|
||||
import { checkImgType, defaultImageAccepts } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
listType?: UploadListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccepts,
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
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 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) {
|
||||
if (Array.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: UploadResultStatus.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,
|
||||
},
|
||||
);
|
||||
|
||||
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 = checkImgType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, progressEvent);
|
||||
info.onSuccess!(res);
|
||||
message.success($t('ui.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;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
||||
if (props.maxNumber === 1) {
|
||||
return list.length > 0 ? list[0] : '';
|
||||
}
|
||||
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"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<CloudUpload />
|
||||
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</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="" class="w-full" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
2
apps/web-antd/src/components/upload/index.ts
Normal file
2
apps/web-antd/src/components/upload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileUpload } from './file-upload.vue';
|
||||
export { default as ImageUpload } from './image-upload.vue';
|
||||
8
apps/web-antd/src/components/upload/typing.ts
Normal file
8
apps/web-antd/src/components/upload/typing.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum UploadResultStatus {
|
||||
DONE = 'done',
|
||||
ERROR = 'error',
|
||||
SUCCESS = 'success',
|
||||
UPLOADING = 'uploading',
|
||||
}
|
||||
|
||||
export type UploadListType = 'picture' | 'picture-card' | 'text';
|
||||
166
apps/web-antd/src/components/upload/use-upload.ts
Normal file
166
apps/web-antd/src/components/upload/use-upload.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { AxiosProgressEvent, InfraFileApi } from '#/api/infra/file';
|
||||
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
// import CryptoJS from 'crypto-js';
|
||||
import { createFile, getFilePresignedUrl, uploadFile } from '#/api/infra/file';
|
||||
import { baseRequestClient } from '#/api/request';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
/**
|
||||
* 上传类型
|
||||
*/
|
||||
enum UPLOAD_TYPE {
|
||||
// 客户端直接上传(只支持S3服务)
|
||||
CLIENT = 'client',
|
||||
// 客户端发送到后端上传
|
||||
SERVER = 'server',
|
||||
}
|
||||
|
||||
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('ui.upload.accept', [accept.join(',')]));
|
||||
}
|
||||
|
||||
const maxSize = unref(maxSizeRef);
|
||||
if (maxSize) {
|
||||
helpTexts.push($t('ui.upload.maxSize', [maxSize]));
|
||||
}
|
||||
|
||||
const maxNumber = unref(maxNumberRef);
|
||||
if (maxNumber && maxNumber !== Infinity) {
|
||||
helpTexts.push($t('ui.upload.maxNumber', [maxNumber]));
|
||||
}
|
||||
return helpTexts.join(',');
|
||||
});
|
||||
return { getAccept, getStringAccept, getHelpText };
|
||||
}
|
||||
|
||||
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
|
||||
export const useUpload = (directory?: string) => {
|
||||
// 后端上传地址
|
||||
const uploadUrl = getUploadUrl();
|
||||
// 是否使用前端直连上传
|
||||
const isClientUpload =
|
||||
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
|
||||
// 重写ElUpload上传方法
|
||||
const httpRequest = async (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => {
|
||||
// 模式一:前端上传
|
||||
if (isClientUpload) {
|
||||
// 1.1 生成文件名称
|
||||
const fileName = await generateFileName(file);
|
||||
// 1.2 获取文件预签名地址
|
||||
const presignedInfo = await getFilePresignedUrl(fileName, directory);
|
||||
// 1.3 上传文件
|
||||
return baseRequestClient
|
||||
.put(presignedInfo.uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
// 1.4. 记录文件信息到后端(异步)
|
||||
createFile0(presignedInfo, file);
|
||||
// 通知成功,数据格式保持与后端上传的返回结果一致
|
||||
return { url: presignedInfo.url };
|
||||
});
|
||||
} else {
|
||||
// 模式二:后端上传
|
||||
return uploadFile({ file, directory }, onUploadProgress);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
httpRequest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获得上传 URL
|
||||
*/
|
||||
export const getUploadUrl = (): string => {
|
||||
return `${apiURL}/infra/file/upload`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
*
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile0(vo: InfraFileApi.FilePresignedUrlRespVO, file: File) {
|
||||
const fileVO = {
|
||||
configId: vo.configId,
|
||||
url: vo.url,
|
||||
path: vo.path,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
createFile(fileVO);
|
||||
return fileVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名称(使用算法SHA256)
|
||||
*
|
||||
* @param file 要上传的文件
|
||||
*/
|
||||
async function generateFileName(file: File) {
|
||||
// // 读取文件内容
|
||||
// const data = await file.arrayBuffer();
|
||||
// const wordArray = CryptoJS.lib.WordArray.create(data);
|
||||
// // 计算SHA256
|
||||
// const sha256 = CryptoJS.SHA256(wordArray).toString();
|
||||
// // 拼接后缀
|
||||
// const ext = file.name.slice(Math.max(0, file.name.lastIndexOf('.')));
|
||||
// return `${sha256}${ext}`;
|
||||
return file.name;
|
||||
}
|
||||
1
apps/web-antd/src/components/user-select-modal/index.ts
Normal file
1
apps/web-antd/src/components/user-select-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as UserSelectModal } from './user-select-modal.vue';
|
||||
@@ -0,0 +1,549 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO @芋艿:是否有更好的组织形式?!
|
||||
import type { Key } from 'ant-design-vue/es/table/interface';
|
||||
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
message,
|
||||
Pagination,
|
||||
Row,
|
||||
Spin,
|
||||
Transfer,
|
||||
Tree,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getSimpleDeptList } from '#/api/system/dept';
|
||||
import { getUserPage } from '#/api/system/user';
|
||||
|
||||
// 部门树节点接口
|
||||
interface DeptTreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
children?: DeptTreeNode[];
|
||||
}
|
||||
|
||||
defineOptions({ name: 'UserSelectModal' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
multiple?: boolean;
|
||||
title?: string;
|
||||
value?: number[];
|
||||
}>(),
|
||||
{
|
||||
title: '选择用户',
|
||||
multiple: true,
|
||||
value: () => [],
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
closed: [];
|
||||
confirm: [value: SystemUserApi.User[]];
|
||||
'update:value': [value: number[]];
|
||||
}>();
|
||||
|
||||
// 部门树数据
|
||||
const deptTree = ref<any[]>([]);
|
||||
const deptList = ref<SystemDeptApi.Dept[]>([]);
|
||||
const expandedKeys = ref<Key[]>([]);
|
||||
const selectedDeptId = ref<number>();
|
||||
const deptSearchKeys = ref('');
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
// 用户数据管理
|
||||
const userList = ref<SystemUserApi.User[]>([]); // 存储所有已知用户
|
||||
const selectedUserIds = ref<string[]>([]);
|
||||
|
||||
// 左侧列表状态
|
||||
const leftListState = ref({
|
||||
loading: false,
|
||||
searchValue: '',
|
||||
dataSource: [] as SystemUserApi.User[],
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 右侧列表状态
|
||||
const rightListState = ref({
|
||||
searchValue: '',
|
||||
dataSource: [] as SystemUserApi.User[],
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 计算属性:Transfer 数据源
|
||||
const transferDataSource = computed(() => {
|
||||
return [
|
||||
...leftListState.value.dataSource,
|
||||
...rightListState.value.dataSource,
|
||||
];
|
||||
});
|
||||
|
||||
// 过滤部门树数据
|
||||
const filteredDeptTree = computed(() => {
|
||||
if (!deptSearchKeys.value) return deptTree.value;
|
||||
|
||||
const filterNode = (node: any): any => {
|
||||
const title = node?.title?.toLowerCase();
|
||||
const search = deptSearchKeys.value.toLowerCase();
|
||||
|
||||
// 如果当前节点匹配
|
||||
if (title.includes(search)) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children?.map((child: any) => filterNode(child)),
|
||||
};
|
||||
}
|
||||
|
||||
// 如果当前节点不匹配,检查子节点
|
||||
if (node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map((child: any) => filterNode(child))
|
||||
.filter(Boolean);
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return deptTree.value.map((node: any) => filterNode(node)).filter(Boolean);
|
||||
});
|
||||
|
||||
// 加载用户数据
|
||||
const loadUserData = async (pageNo: number, pageSize: number) => {
|
||||
leftListState.value.loading = true;
|
||||
try {
|
||||
const { list, total } = await getUserPage({
|
||||
pageNo,
|
||||
pageSize,
|
||||
deptId: selectedDeptId.value,
|
||||
username: leftListState.value.searchValue || undefined,
|
||||
});
|
||||
|
||||
leftListState.value.dataSource = list;
|
||||
leftListState.value.pagination.total = total;
|
||||
leftListState.value.pagination.current = pageNo;
|
||||
leftListState.value.pagination.pageSize = pageSize;
|
||||
|
||||
// 更新用户列表缓存
|
||||
const newUsers = list.filter(
|
||||
(user) => !userList.value.some((u) => u.id === user.id),
|
||||
);
|
||||
if (newUsers.length > 0) {
|
||||
userList.value.push(...newUsers);
|
||||
}
|
||||
} finally {
|
||||
leftListState.value.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 更新右侧列表数据
|
||||
const updateRightListData = () => {
|
||||
// 使用 Set 来去重选中的用户ID
|
||||
const uniqueSelectedIds = new Set(selectedUserIds.value);
|
||||
|
||||
// 获取选中的用户,确保不重复
|
||||
const selectedUsers = userList.value.filter((user) =>
|
||||
uniqueSelectedIds.has(String(user.id)),
|
||||
);
|
||||
|
||||
// 应用搜索过滤
|
||||
const filteredUsers = rightListState.value.searchValue
|
||||
? selectedUsers.filter((user) =>
|
||||
user.nickname
|
||||
.toLowerCase()
|
||||
.includes(rightListState.value.searchValue.toLowerCase()),
|
||||
)
|
||||
: selectedUsers;
|
||||
|
||||
// 更新总数(使用 Set 确保唯一性)
|
||||
rightListState.value.pagination.total = new Set(
|
||||
filteredUsers.map((user) => user.id),
|
||||
).size;
|
||||
|
||||
// 应用分页
|
||||
const { current, pageSize } = rightListState.value.pagination;
|
||||
const startIndex = (current - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
rightListState.value.dataSource = filteredUsers.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
// 处理左侧分页变化
|
||||
const handleLeftPaginationChange = async (page: number, pageSize: number) => {
|
||||
await loadUserData(page, pageSize);
|
||||
};
|
||||
|
||||
// 处理右侧分页变化
|
||||
const handleRightPaginationChange = (page: number, pageSize: number) => {
|
||||
rightListState.value.pagination.current = page;
|
||||
rightListState.value.pagination.pageSize = pageSize;
|
||||
updateRightListData();
|
||||
};
|
||||
|
||||
// 处理用户搜索
|
||||
const handleUserSearch = async (direction: string, value: string) => {
|
||||
if (direction === 'left') {
|
||||
leftListState.value.searchValue = value;
|
||||
leftListState.value.pagination.current = 1;
|
||||
await loadUserData(1, leftListState.value.pagination.pageSize);
|
||||
} else {
|
||||
rightListState.value.searchValue = value;
|
||||
rightListState.value.pagination.current = 1;
|
||||
updateRightListData();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理用户选择变化
|
||||
const handleUserChange = (targetKeys: string[]) => {
|
||||
// 使用 Set 来去重选中的用户ID
|
||||
selectedUserIds.value = [...new Set(targetKeys)];
|
||||
emit('update:value', selectedUserIds.value.map(Number));
|
||||
updateRightListData();
|
||||
};
|
||||
|
||||
// 重置数据
|
||||
const resetData = () => {
|
||||
userList.value = [];
|
||||
selectedUserIds.value = [];
|
||||
|
||||
// 取消部门选中
|
||||
selectedDeptId.value = undefined;
|
||||
|
||||
// 取消选中的用户
|
||||
selectedUserIds.value = [];
|
||||
|
||||
leftListState.value = {
|
||||
loading: false,
|
||||
searchValue: '',
|
||||
dataSource: [],
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
rightListState.value = {
|
||||
searchValue: '',
|
||||
dataSource: [],
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const open = async (userIds: string[]) => {
|
||||
resetData();
|
||||
loading.value = true;
|
||||
try {
|
||||
// 加载部门数据
|
||||
const deptData = await getSimpleDeptList();
|
||||
deptList.value = deptData;
|
||||
const treeData = handleTree(deptData);
|
||||
deptTree.value = treeData.map((node) => processDeptNode(node));
|
||||
expandedKeys.value = deptTree.value.map((node) => node.key);
|
||||
|
||||
// 加载初始用户数据
|
||||
await loadUserData(1, leftListState.value.pagination.pageSize);
|
||||
|
||||
// 设置已选用户
|
||||
if (userIds?.length) {
|
||||
selectedUserIds.value = userIds.map(String);
|
||||
// 加载已选用户的完整信息 TODO 目前接口暂不支持 多个用户ID 查询, 需要后端支持
|
||||
const { list } = await getUserPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100, // 临时使用固定值确保能加载所有已选用户
|
||||
userIds,
|
||||
});
|
||||
// 使用 Map 来去重,以用户 ID 为 key
|
||||
const userMap = new Map(userList.value.map((user) => [user.id, user]));
|
||||
list.forEach((user) => {
|
||||
if (!userMap.has(user.id)) {
|
||||
userMap.set(user.id, user);
|
||||
}
|
||||
});
|
||||
userList.value = [...userMap.values()];
|
||||
updateRightListData();
|
||||
}
|
||||
|
||||
modalApi.open();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO 后端接口目前仅支持 username 检索, 筛选条件需要跟后端请求参数保持一致。
|
||||
const filterOption = (inputValue: string, option: any) => {
|
||||
return option.username.toLowerCase().includes(inputValue.toLowerCase());
|
||||
};
|
||||
|
||||
// 处理部门树展开/折叠
|
||||
const handleExpand = (keys: Key[]) => {
|
||||
expandedKeys.value = keys;
|
||||
};
|
||||
|
||||
// 处理部门搜索
|
||||
const handleDeptSearch = (value: string) => {
|
||||
deptSearchKeys.value = value;
|
||||
|
||||
// 如果有搜索结果,自动展开所有节点
|
||||
if (value) {
|
||||
const getAllKeys = (nodes: any[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
for (const node of nodes) {
|
||||
keys.push(node.key);
|
||||
if (node.children) {
|
||||
keys.push(...getAllKeys(node.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
expandedKeys.value = getAllKeys(deptTree.value);
|
||||
} else {
|
||||
// 清空搜索时,只展开第一级节点
|
||||
expandedKeys.value = deptTree.value.map((node) => node.key);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理部门选择
|
||||
const handleDeptSelect = async (selectedKeys: Key[], _info: any) => {
|
||||
// 更新选中的部门ID
|
||||
const newDeptId =
|
||||
selectedKeys.length > 0 ? Number(selectedKeys[0]) : undefined;
|
||||
selectedDeptId.value =
|
||||
newDeptId === selectedDeptId.value ? undefined : newDeptId;
|
||||
|
||||
// 重置分页并加载数据
|
||||
const { pageSize } = leftListState.value.pagination;
|
||||
leftListState.value.pagination.current = 1;
|
||||
await loadUserData(1, pageSize);
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (selectedUserIds.value.length === 0) {
|
||||
message.warning('请选择用户');
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
'confirm',
|
||||
userList.value.filter((user) =>
|
||||
selectedUserIds.value.includes(String(user.id)),
|
||||
),
|
||||
);
|
||||
modalApi.close();
|
||||
};
|
||||
|
||||
// 取消选择
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
modalApi.close();
|
||||
// 确保在动画结束后再重置数据
|
||||
setTimeout(() => {
|
||||
resetData();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClosed = () => {
|
||||
emit('closed');
|
||||
resetData();
|
||||
};
|
||||
|
||||
// 弹窗配置
|
||||
const [ModalComponent, modalApi] = useVbenModal({
|
||||
title: props.title,
|
||||
onCancel: handleCancel,
|
||||
onClosed: handleClosed,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 递归处理部门树节点
|
||||
const processDeptNode = (node: any): DeptTreeNode => {
|
||||
return {
|
||||
key: String(node.id),
|
||||
title: `${node.name} (${node.id})`,
|
||||
children: node.children?.map((child: any) => processDeptNode(child)),
|
||||
};
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComponent class="w-[1000px]" key="user-select-modal">
|
||||
<Spin :spinning="loading">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :span="6">
|
||||
<div class="h-[500px] overflow-auto rounded border border-gray-200">
|
||||
<div class="border-b border-gray-200 p-2">
|
||||
<Input
|
||||
v-model:value="deptSearchKeys"
|
||||
placeholder="搜索部门"
|
||||
allow-clear
|
||||
@input="(e) => handleDeptSearch(e.target?.value ?? '')"
|
||||
/>
|
||||
</div>
|
||||
<Tree
|
||||
:tree-data="filteredDeptTree"
|
||||
:expanded-keys="expandedKeys"
|
||||
:selected-keys="selectedDeptId ? [String(selectedDeptId)] : []"
|
||||
@select="handleDeptSelect"
|
||||
@expand="handleExpand"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="18">
|
||||
<Transfer
|
||||
:row-key="(record) => String(record.id)"
|
||||
:data-source="transferDataSource"
|
||||
v-model:target-keys="selectedUserIds"
|
||||
:titles="['未选', '已选']"
|
||||
:show-search="true"
|
||||
:show-select-all="true"
|
||||
:filter-option="filterOption"
|
||||
@change="handleUserChange"
|
||||
@search="handleUserSearch"
|
||||
>
|
||||
<template #render="item">
|
||||
<span>{{ item?.nickname }} ({{ item?.username }})</span>
|
||||
</template>
|
||||
|
||||
<template #footer="{ direction }">
|
||||
<div v-if="direction === 'left'">
|
||||
<Pagination
|
||||
v-model:current="leftListState.pagination.current"
|
||||
v-model:page-size="leftListState.pagination.pageSize"
|
||||
:total="leftListState.pagination.total"
|
||||
:show-size-changer="true"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
size="small"
|
||||
@change="handleLeftPaginationChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="direction === 'right'">
|
||||
<Pagination
|
||||
v-model:current="rightListState.pagination.current"
|
||||
v-model:page-size="rightListState.pagination.pageSize"
|
||||
:total="rightListState.pagination.total"
|
||||
:show-size-changer="true"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
size="small"
|
||||
@change="handleRightPaginationChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Transfer>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
<template #footer>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="selectedUserIds.length === 0"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
<Button @click="handleCancel">{{ cancelText }}</Button>
|
||||
</template>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.ant-transfer) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 300px !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-search) {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-body) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-content) {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-content-item) {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-operation) {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-transfer-list-footer) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination) {
|
||||
margin: 8px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination-options) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination-options-size-changer) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user