init project

This commit is contained in:
caiyuchao
2025-05-16 14:52:30 +08:00
commit 1d6f7521c4
1496 changed files with 134863 additions and 0 deletions

View 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>

View File

@@ -0,0 +1 @@
export { default as ContentWrap } from './content-wrap.vue';

View 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>

View 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>

View 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>

View 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';

View 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 };

View 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 @puhui999from 5.0extra: () => 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 @puhui999from 5.0emits: ['register'] 事件
export default Description;
</script>

View File

@@ -0,0 +1,3 @@
export { default as Description } from './description.vue';
export * from './typing';
export { useDescription } from './use-description';

View File

@@ -0,0 +1,27 @@
import type { DescriptionsProps } from 'ant-design-vue';
import type { CSSProperties, VNode } from 'vue';
// TODO @puhui999【content】这个纠结下1vben2.0 是 renderhttps://doc.vvbin.cn/components/desc.html#usage 2
// TODO @puhui999vben2.0 还有 sapn【done】、labelMinWidth、contentMinWidth
// TODO @puhui999【hidden】这个纠结下1vben2.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 @puhui999vben2.0 还有 title【done】、bordered【done】d、useCollapse、collapseOptions
// TODO @puhui999from 5.0bordered 默认为 true
// TODO @puhui999from 5.0column 默认为 lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4
// TODO @puhui999from 5.0size 默认为 small有 'default', 'middle', 'small', undefined
// TODO @puhui999from 5.0useCollapse 默认为 true
export interface DescriptionsOptions {
data?: Record<string, any>; // 数据
schema?: DescriptionItemSchema[]; // 描述项配置
componentProps?: DescriptionsProps; // antd Descriptions 组件参数
}

View 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】纠结下1vben2.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;
}

View 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>

View File

@@ -0,0 +1 @@
export { default as DictTag } from './dict-tag.vue';

View 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>

View File

@@ -0,0 +1 @@
export { default as DocAlert } from './doc-alert.vue';

View File

@@ -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>

View File

@@ -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();
}
}
})()}
</>
);
},
});
};

View File

@@ -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} />
);
},
});
};

View 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();
});
};

View File

@@ -0,0 +1,3 @@
export { useApiSelect } from './components/use-api-select';
export { useFormCreateDesigner } from './helpers';

View 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 };

View 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';

View File

@@ -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,
]);
},
};
};

View File

@@ -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: '是否只读' },
]);
},
};
};

View File

@@ -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,
]);
},
};
};

View File

@@ -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,
},
]);
},
};
};

View File

@@ -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,
},
]);
},
};
};

View File

@@ -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',
},
]);
},
};
};

View 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 @dhb52MenuList、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[]; // 事件配置
}

View 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>

View File

@@ -0,0 +1 @@
export { default as IFrame } from './iframe.vue';

View File

@@ -0,0 +1,2 @@
export { default as TableAction } from './table-action.vue';
export * from './typing';

View 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>

View 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;
}

View File

@@ -0,0 +1 @@
export { default as TableToolbar } from './table-toolbar.vue';

View File

@@ -0,0 +1,79 @@
<!-- add by puhui999vxe 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>

View 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>

View 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));
}
}
});
};

View 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>

View File

@@ -0,0 +1 @@
export { default as Tinymce } from './editor.vue';

View 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';

View 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>

View 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);
}

View 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>

View File

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

View File

@@ -0,0 +1,8 @@
export enum UploadResultStatus {
DONE = 'done',
ERROR = 'error',
SUCCESS = 'success',
UPLOADING = 'uploading',
}
export type UploadListType = 'picture' | 'picture-card' | 'text';

View 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;
}

View File

@@ -0,0 +1 @@
export { default as UserSelectModal } from './user-select-modal.vue';

View File

@@ -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>