init
This commit is contained in:
1172
apps/web-ele/src/components/cron-tab/cron-tab.vue
Normal file
1172
apps/web-ele/src/components/cron-tab/cron-tab.vue
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web-ele/src/components/cron-tab/index.ts
Normal file
1
apps/web-ele/src/components/cron-tab/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CronTab } from './cron-tab.vue';
|
||||
165
apps/web-ele/src/components/cropper/cropper-avatar.vue
Normal file
165
apps/web-ele/src/components/cropper/cropper-avatar.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<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 { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
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 || '');
|
||||
// TODO @puhui999:这个有办法去掉么?
|
||||
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 });
|
||||
ElMessage.success($t('ui.cropper.uploadSuccess'));
|
||||
}
|
||||
|
||||
const closeModal = () => modalApi.close();
|
||||
const openModal = () => modalApi.open();
|
||||
|
||||
defineExpose({
|
||||
closeModal,
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO @puhui999:html 部分,看看有没办法和 web-antd/src/components/cropper/cropper-avatar.vue 风格更接近 -->
|
||||
<!-- 头像容器 -->
|
||||
<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>
|
||||
<!-- 上传按钮 -->
|
||||
<ElButton
|
||||
v-if="showBtn"
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
@click="openModal"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
{{ btnText ? btnText : $t('ui.cropper.selectImage') }}
|
||||
</ElButton>
|
||||
|
||||
<CropperModal
|
||||
:size="size"
|
||||
:src="sourceValue"
|
||||
:upload-api="uploadApi"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* TODO @puhui999:要类似 web-antd/src/components/cropper/cropper-avatar.vue 减少 scss,通过 tindwind 么? */
|
||||
.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>
|
||||
379
apps/web-ele/src/components/cropper/cropper-modal.vue
Normal file
379
apps/web-ele/src/components/cropper/cropper-modal.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<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 {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElMessage,
|
||||
ElSpace,
|
||||
ElTooltip,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
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) {
|
||||
ElMessage.warning('未选择图片');
|
||||
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`">
|
||||
<ElUpload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="[]"
|
||||
accept="image/*"
|
||||
>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.selectImage')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton size="small" type="primary">
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</ElUpload>
|
||||
<ElSpace>
|
||||
<ElTooltip :content="$t('ui.cropper.btn_reset')" placement="bottom">
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_rotate_left')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_rotate_right')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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-right-outlined]"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_scale_x')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_scale_y')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_zoom_in')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_zoom_out')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
: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>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</ElSpace>
|
||||
</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`">
|
||||
<ElAvatar :src="previewSource" size="large" />
|
||||
<ElAvatar :size="48" :src="previewSource" />
|
||||
<ElAvatar :size="64" :src="previewSource" />
|
||||
<ElAvatar :size="80" :src="previewSource" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/* TODO @puhui999:要类似 web-antd/src/components/cropper/cropper-avatar.vue 减少 scss,通过 tindwind 么? */
|
||||
.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>
|
||||
174
apps/web-ele/src/components/cropper/cropper.vue
Normal file
174
apps/web-ele/src/components/cropper/cropper.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<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);
|
||||
|
||||
// TODO @puhui999:这个有办法去掉么?
|
||||
const prefixCls = 'cropper-image';
|
||||
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${`${props.height}`.replace(/px/, '')}px` };
|
||||
});
|
||||
|
||||
onMounted(init);
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy();
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef);
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true;
|
||||
realTimeCropped();
|
||||
emit('ready', cropper.value);
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
...props.options,
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCropped() {
|
||||
props.realTimePreview && cropped();
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function cropped() {
|
||||
if (!cropper.value) {
|
||||
return;
|
||||
}
|
||||
const imgInfo = cropper.value.getData();
|
||||
const canvas = props.circled
|
||||
? getRoundedCanvas()
|
||||
: cropper.value.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
const fileReader: FileReader = new FileReader();
|
||||
fileReader.readAsDataURL(blob);
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError');
|
||||
};
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas();
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
const width = sourceCanvas.width;
|
||||
const height = sourceCanvas.height;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
context.globalCompositeOperation = 'destination-in';
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.min(width, height) / 2,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
true,
|
||||
);
|
||||
context.fill();
|
||||
return canvas;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper-image {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/web-ele/src/components/cropper/index.ts
Normal file
3
apps/web-ele/src/components/cropper/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CropperAvatar } from './cropper-avatar.vue';
|
||||
export { default as CropperImage } from './cropper.vue';
|
||||
export type { CropperType } from './typing';
|
||||
68
apps/web-ele/src/components/cropper/typing.ts
Normal file
68
apps/web-ele/src/components/cropper/typing.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type Cropper from 'cropperjs';
|
||||
import type { ButtonProps } from 'element-plus';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
export interface apiFunParams {
|
||||
file: Blob;
|
||||
filename: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string;
|
||||
imgInfo: Cropper.Data;
|
||||
}
|
||||
|
||||
export interface CropperProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
circled?: boolean;
|
||||
realTimePreview?: boolean;
|
||||
height?: number | string;
|
||||
crossorigin?: '' | 'anonymous' | 'use-credentials' | undefined;
|
||||
imageStyle?: CSSProperties;
|
||||
options?: Cropper.Options;
|
||||
}
|
||||
|
||||
export interface CropperAvatarProps {
|
||||
width?: number | string;
|
||||
value?: string;
|
||||
showBtn?: boolean;
|
||||
btnProps?: ButtonProps;
|
||||
btnText?: string;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CropperModalProps {
|
||||
circled?: boolean;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
src?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const defaultOptions: Cropper.Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true,
|
||||
};
|
||||
|
||||
export type { Cropper as CropperType };
|
||||
80
apps/web-ele/src/components/db-hst/dropdown.ts
Normal file
80
apps/web-ele/src/components/db-hst/dropdown.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
let currentDropdownEl: HTMLElement | null = null
|
||||
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
|
||||
const isDisabled = ref<any>(false)
|
||||
const closeDropdown = () => {
|
||||
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
|
||||
currentDropdownEl = null
|
||||
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick, true)
|
||||
currentOnDocClick = null
|
||||
}
|
||||
|
||||
const openDropdown = (
|
||||
td: HTMLTableCellElement,
|
||||
value: unknown,
|
||||
source: string[],
|
||||
onSelect: (opt: string) => void,
|
||||
isOptionDisabled?: (opt: string) => boolean,
|
||||
) => {
|
||||
closeDropdown()
|
||||
const menu = document.createElement('div')
|
||||
menu.className = 'ht-dropdown-menu'
|
||||
const frag = document.createDocumentFragment()
|
||||
for (const opt of source) {
|
||||
const item = document.createElement('div')
|
||||
item.className = 'ht-dropdown-item'
|
||||
item.textContent = opt
|
||||
if (String(value) === String(opt)) item.classList.add('is-selected')
|
||||
const disabled = isDisabled.value && isOptionDisabled?.(opt) === true
|
||||
if (disabled) { item.classList.add('is-disabled'); item.setAttribute('aria-disabled', 'true') }
|
||||
item.onclick = (e) => { e.stopPropagation(); if (disabled) return; onSelect(opt); closeDropdown() }
|
||||
frag.appendChild(item)
|
||||
}
|
||||
menu.appendChild(frag)
|
||||
const rect = td.getBoundingClientRect()
|
||||
menu.style.visibility = 'hidden'
|
||||
document.body.appendChild(menu)
|
||||
const menuWidth = menu.offsetWidth
|
||||
const menuHeight = menu.offsetHeight
|
||||
const viewportRight = window.scrollX + window.innerWidth
|
||||
const viewportBottom = window.scrollY + window.innerHeight
|
||||
const left = Math.min(rect.left + window.scrollX, viewportRight - menuWidth - 8)
|
||||
let top = rect.bottom + window.scrollY
|
||||
let placement = 'bottom'
|
||||
if (top + menuHeight > viewportBottom - 8) { top = rect.top + window.scrollY - menuHeight; placement = 'top' }
|
||||
menu.style.left = `${Math.max(left, 8)}px`
|
||||
menu.style.top = `${top}px`
|
||||
menu.classList.add(placement === 'top' ? 'is-top' : 'is-bottom')
|
||||
menu.style.visibility = ''
|
||||
currentDropdownEl = menu
|
||||
currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() }
|
||||
document.addEventListener('click', currentOnDocClick, true)
|
||||
}
|
||||
export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
|
||||
td.innerHTML = ''
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'ht-cell-dropdown'
|
||||
const valueEl = document.createElement('span')
|
||||
valueEl.className = 'ht-cell-value'
|
||||
valueEl.textContent = value ?? ''
|
||||
const caretEl = document.createElement('span')
|
||||
caretEl.className = 'ht-cell-caret'
|
||||
wrapper.appendChild(valueEl)
|
||||
wrapper.appendChild(caretEl)
|
||||
td.appendChild(wrapper)
|
||||
const source: string[] = Array.isArray(cellProperties?.source)
|
||||
? cellProperties.source
|
||||
: Array.isArray(cellProperties?.customDropdownSource)
|
||||
? cellProperties.customDropdownSource
|
||||
: []
|
||||
let disabledSet = new Set<string>()
|
||||
if (isDisabled.value) {
|
||||
const colValues = instance.getSourceDataAtCol(column) as unknown[]
|
||||
const currentStr = String(value ?? '')
|
||||
disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v)))
|
||||
disabledSet.delete(currentStr)
|
||||
}
|
||||
wrapper.onclick = (e) => { e.stopPropagation(); openDropdown(td, value, source, (opt) => instance.setDataAtCell(row, column, opt), (opt) => isDisabled.value && disabledSet.has(String(opt))) }
|
||||
return td
|
||||
}
|
||||
1
apps/web-ele/src/components/db-hst/index.ts
Normal file
1
apps/web-ele/src/components/db-hst/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DbHst } from './index.vue';
|
||||
348
apps/web-ele/src/components/db-hst/index.vue
Normal file
348
apps/web-ele/src/components/db-hst/index.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch, computed } from 'vue'
|
||||
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
|
||||
import Handsontable from "handsontable";
|
||||
import { HotTable } from '@handsontable/vue3'
|
||||
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'
|
||||
import { registerAllModules } from 'handsontable/registry'
|
||||
import 'handsontable/styles/handsontable.css'
|
||||
import 'handsontable/styles/ht-theme-main.css'
|
||||
registerAllModules()
|
||||
registerLanguageDictionary(zhCN)
|
||||
import { handlerDropdownRenderer } from './dropdown'
|
||||
import { handlerTableRenderer } from './table'
|
||||
import { computeCodeColWidth,codeRenderer } from './tree'
|
||||
// import { sourceDataObject } from './mockData'
|
||||
// const language = ref('zh-CN')
|
||||
defineOptions({ name: 'DbHst' });
|
||||
const componentProps = defineProps<{ settings?: any }>()
|
||||
// 导入和注册插件和单元格类型
|
||||
// import { registerCellType, NumericCellType } from 'handsontable/cellTypes';
|
||||
// import { registerPlugin, UndoRedo } from 'handsontable/plugins';
|
||||
// registerCellType(NumericCellType);
|
||||
// registerPlugin(UndoRedo);
|
||||
// const tableHeight = computed(() => componentProps.height ?? 0)
|
||||
// const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill('')))
|
||||
const hotTableComponent = ref<any>(null)
|
||||
|
||||
const codeColWidth = ref<number>(120)
|
||||
|
||||
|
||||
// const colHeaders = ref<string[]>([])
|
||||
let defaultSettings = {
|
||||
themeName: 'ht-theme-main',
|
||||
language: 'zh-CN',
|
||||
// data: sourceDataObject,
|
||||
// colWidths: [100, 120, 100, 100, 100, 100],
|
||||
// rowHeights: [30, 30, 30, 30, 30, 30],
|
||||
colWidths: 120, // 固定列宽
|
||||
// colWidths(index) {
|
||||
// return (index + 1) * 40;
|
||||
// },
|
||||
// colWidths: undefined,
|
||||
rowHeights: '23px', // 固定行高
|
||||
wordWrap: false,// 禁止单元格内容自动换行
|
||||
|
||||
//manualColumnMove: true,
|
||||
manualColumnResize: true,
|
||||
autoRowSize: false,
|
||||
autoColumnSize: false,
|
||||
|
||||
renderAllRows: false,
|
||||
viewportColumnRenderingOffset: 12,//渲染列数
|
||||
viewportRowRenderingOffset: 12,//渲染行数
|
||||
// colHeaders: componentProps.colHeaders ?? [],
|
||||
rowHeaders: false,
|
||||
// columns: componentProps.columns ?? [],
|
||||
autoWrapRow: true,
|
||||
autoWrapCol: true,
|
||||
width: '100%',
|
||||
// height: 'auto',
|
||||
// height: tableHeight.value,
|
||||
// height: 200,
|
||||
// stretchH: 'all',
|
||||
// loading: true,
|
||||
//contextMenu: true,
|
||||
// dialog: true,
|
||||
// dialog: {
|
||||
// content: 'This dialog can be controlled programmatically.',
|
||||
// closable: true,
|
||||
// contentBackground: true,
|
||||
// background: 'semi-transparent',
|
||||
// },
|
||||
licenseKey: '424fc-f3b67-5905b-a191b-9b809',
|
||||
// 如果使用第一行作为列头(colHeaders: false),添加以下配置
|
||||
// cells: function(row: number, col: number) {
|
||||
// const cellProperties: any = {};
|
||||
|
||||
// // 如果 colHeaders 为 false,将第一行设置为列头样式
|
||||
// if (row === 0) {
|
||||
// cellProperties.readOnly = true; // 不可编辑
|
||||
// cellProperties.className = 'custom-header-row'; // 自定义样式类
|
||||
// cellProperties.renderer = function(instance: any, td: HTMLTableCellElement, row: number, col: number, prop: any, value: any, cellProperties: any) {
|
||||
// Handsontable.renderers.TextRenderer.apply(this, arguments as any);
|
||||
// td.style.fontWeight = 'bold';
|
||||
// td.style.backgroundColor = '#f5f5f5';
|
||||
// td.style.textAlign = 'center';
|
||||
// td.style.borderBottom = '2px solid #ddd';
|
||||
// };
|
||||
// }
|
||||
|
||||
// return cellProperties;
|
||||
// },
|
||||
modifyColWidth: (width: number, col: number) => {
|
||||
const hot = hotInstance.value
|
||||
if (!hot) return width
|
||||
const codeCol = hot.propToCol('code')
|
||||
// console.log('modifyColWidth',codeCol,width)
|
||||
return col === codeCol ? (codeColWidth.value ?? width) : width
|
||||
},
|
||||
afterChange: (changes: any, source: string) => {
|
||||
if (!changes || !hotInstance.value) return
|
||||
if (source !== 'edit' && source !== 'Autofill' && source !== 'UndoRedo') return
|
||||
const hot = hotInstance.value
|
||||
const codeCol = hot.propToCol('code')
|
||||
const hasCodeEdit = changes.some((c: any) => c && (c[1] === 'code' || c[1] === codeCol))
|
||||
// console.log('afterChange',changes,hasCodeEdit, codeCol)
|
||||
if (!hasCodeEdit) return
|
||||
codeColWidth.value = computeCodeColWidth(hot)
|
||||
// console.log('afterChange',codeColWidth.value)
|
||||
hot.render()
|
||||
// console.log('afterChange',codeColWidth.value)
|
||||
},
|
||||
}
|
||||
// 合并外部 settings 和默认配置
|
||||
let hotSettings = {}
|
||||
// 保留必要的回调函数
|
||||
|
||||
const hotInstance = ref<any>(null)
|
||||
|
||||
onMounted(() => {
|
||||
hotInstance.value = hotTableComponent.value?.hotInstance
|
||||
})
|
||||
onUnmounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => componentProps.settings,
|
||||
(newSettings) => {
|
||||
if (!newSettings) return
|
||||
const merged = {
|
||||
...defaultSettings,
|
||||
...newSettings,
|
||||
}
|
||||
Object.assign(hotSettings, merged)
|
||||
hotSettings = merged
|
||||
// console.log(merged)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadData = (rows: any[][]) => {
|
||||
|
||||
if (!hotInstance.value) return
|
||||
// hotInstance.value.loadData(rows.length === 0?defaultData.value:rows)
|
||||
hotInstance.value.loadData(rows)
|
||||
console.log('Source Data:', hotInstance.value.getSourceData());
|
||||
}
|
||||
|
||||
const updateCodeColWidth = () => {
|
||||
if (!hotInstance.value) return
|
||||
codeColWidth.value = computeCodeColWidth(hotInstance.value)
|
||||
hotInstance.value.render()
|
||||
}
|
||||
defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth })
|
||||
|
||||
|
||||
Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer);
|
||||
Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hot-table ref="hotTableComponent" :settings="hotSettings"></hot-table>
|
||||
<!-- <div id="hot-dialog-container" style="display:none">
|
||||
<div class="ht-dialog-content">
|
||||
<h3>执行操作</h3>
|
||||
<el-table :data="gridData">
|
||||
<el-table-column property="date" label="Date" width="150" />
|
||||
<el-table-column property="name" label="Name" width="200" />
|
||||
<el-table-column property="address" label="Address" />
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="el-button el-button--default el-button--small btn-cancel">取消</button>
|
||||
<button class="el-button el-button--primary el-button--small btn-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- <el-popover
|
||||
ref="popoverRef"
|
||||
:virtual-ref="popoverButtonRef"
|
||||
v-model:visible="isPopoverOpen"
|
||||
trigger="manual"
|
||||
virtual-triggering
|
||||
width="auto"
|
||||
>
|
||||
<el-table :data="gridData" size="small" @row-click="onClickOutside" border>
|
||||
<el-table-column width="150" property="date" label="date" />
|
||||
<el-table-column width="100" property="name" label="name" />
|
||||
<el-table-column width="300" property="address" label="address" />
|
||||
</el-table>
|
||||
</el-popover> -->
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
/* 禁止单元格内容换行 */
|
||||
.handsontable td {
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 自定义滚动条样式 */
|
||||
/* .ht_master .wtHolder::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.ht_master .wtHolder::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 6px;
|
||||
border: 3px solid #ffffff;
|
||||
}
|
||||
|
||||
.ht_master .wtHolder::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555;
|
||||
} */
|
||||
|
||||
.ht_master .wtHolder{
|
||||
/* overflow: hidden !important; */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #a6a8ac #ecf0f1;
|
||||
}
|
||||
/* dropdown */
|
||||
.ht-cell-dropdown { display: flex; align-items: center; justify-content: space-between; width: 100%; position: relative; box-sizing: border-box; height: 100%; }
|
||||
.ht-cell-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ht-cell-value:empty::after { content: "\200b"; }
|
||||
.ht-cell-caret { position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #979797; }
|
||||
.ht-dropdown-menu { position: absolute; background: #fff; border: 1px solid var(--el-border-color-light); border-radius: 8px; box-shadow: 0 10px 20px rgba(0,0,0,0.1); min-width: 160px; max-height:300px; overflow: auto; z-index: 10000; }
|
||||
.ht-dropdown-menu.is-bottom { margin-top: 4px; }
|
||||
.ht-dropdown-menu.is-top { margin-bottom: 4px; }
|
||||
.ht-dropdown-item { padding: 8px 12px; cursor: pointer; font-size: 13px; }
|
||||
.ht-dropdown-item:hover { background-color: #f5f7fa; }
|
||||
.ht-dropdown-item.is-selected { background-color: #eef3ff; }
|
||||
.ht-dropdown-item.is-disabled { color: #c0c4cc; cursor: not-allowed; }
|
||||
/* tree */
|
||||
.handsontable .text-relative span.ht_nestingLevel_empty{
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
order: -2;
|
||||
}
|
||||
.handsontable .text-relative span:last-child {
|
||||
padding-left: calc(var(--ht-icon-size) + 5px);
|
||||
}
|
||||
/* table */
|
||||
|
||||
/* 自定义下拉渲染样式 */
|
||||
.hot-cell-dropdown { display: flex; align-items: center; gap: 6px; padding: 0 6px; }
|
||||
.hot-dropdown-display { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hot-dropdown-text { font-size: 12px; color: #1f2328; }
|
||||
.hot-dropdown-placeholder { font-size: 12px; color: #a0a0a0; }
|
||||
|
||||
.hot-dropdown-trigger {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 0;
|
||||
}
|
||||
.hot-dropdown-trigger::after {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cpath d='m21 21-4.35-4.35'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-color: currentColor;
|
||||
}
|
||||
.hot-dropdown-trigger::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hot-dropdown { position: fixed; z-index: 10000; background: #fff; border: 1px solid #e5e7eb; box-shadow: 0 8px 24px rgba(0,0,0,0.12); border-radius: 6px; max-height: 260px; overflow: hidden; will-change: top, left; display: flex; flex-direction: column; }
|
||||
.hot-dropdown--up { border-top-left-radius: 6px; border-top-right-radius: 6px; }
|
||||
.hot-dropdown--down { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; }
|
||||
.hot-dropdown-search { padding: 0px; border-bottom: 1px solid #e5e7eb; background: #fff; position: sticky; top: 0; z-index: 1; }
|
||||
.hot-dropdown-search-input { width: 100%; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; outline: none; transition: border-color 0.2s; }
|
||||
.hot-dropdown-search-input:focus { border-color: #3b82f6; }
|
||||
.hot-dropdown-table-wrapper { overflow: auto; flex: 1; }
|
||||
.hot-dropdown-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.hot-dropdown-table thead th { position: sticky; top: 0; background: #f9fafb; font-weight: 600; color: #374151; padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: left; }
|
||||
.hot-dropdown-table tbody td { padding: 8px; border-bottom: 1px solid #f3f4f6; color: #374151; }
|
||||
.hot-dropdown-row { cursor: pointer; }
|
||||
.hot-dropdown-row:hover { background: #f3f4f6; }
|
||||
|
||||
/** 指引线line */
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
order: -2;
|
||||
}
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:last-child {
|
||||
padding-left: calc(var(--ht-icon-size) + 5px);
|
||||
}
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::before{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -13px;
|
||||
height: 26px;
|
||||
width: 1px;
|
||||
border-left: 1px solid #ababab;
|
||||
}
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::after{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
border-top: 1px solid #ababab;
|
||||
}
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
/* 最后一个 ht_nestingLevel_empty(rowHeader 前面的那个) */
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty + .rowHeader {
|
||||
/* 通过相邻选择器反向选择 */
|
||||
padding-left: 10px !important
|
||||
}
|
||||
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::before {
|
||||
/* 你的样式 */
|
||||
/* height: 0px; */
|
||||
}
|
||||
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::after {
|
||||
/* 你的样式 */
|
||||
width: 0px !important;
|
||||
}
|
||||
|
||||
/* 或者用这个:选择后面不是 ht_nestingLevel_empty 的那个 */
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::before {
|
||||
/* height: 13px; */
|
||||
}
|
||||
|
||||
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::after {
|
||||
/* 你的特殊样式 */
|
||||
|
||||
}
|
||||
</style>
|
||||
95
apps/web-ele/src/components/db-hst/mockData.ts
Normal file
95
apps/web-ele/src/components/db-hst/mockData.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
interface Album {
|
||||
code?: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
label: string;
|
||||
level?: string;
|
||||
__children?: Album[];
|
||||
}
|
||||
|
||||
interface MusicAward {
|
||||
code?: string;
|
||||
category: string;
|
||||
artist?: string | null;
|
||||
title?: string | null;
|
||||
label?: string | null;
|
||||
level?: string;
|
||||
__children: Album[];
|
||||
}
|
||||
|
||||
// 音乐类型
|
||||
const categories = [
|
||||
'Best Rock Performance',
|
||||
'Best Metal Performance',
|
||||
'Best Pop Performance',
|
||||
'Best Jazz Performance',
|
||||
'Best Classical Performance',
|
||||
'Best Electronic Performance',
|
||||
'Best Hip Hop Performance',
|
||||
'Best Country Performance',
|
||||
'Best R&B Performance',
|
||||
'Best Alternative Performance'
|
||||
]
|
||||
|
||||
// 艺术家名字
|
||||
const artists = [
|
||||
'Alabama Shakes', 'Florence & The Machine', 'Foo Fighters', 'Elle King', 'Wolf Alice',
|
||||
'Ghost', 'August Burns Red', 'Lamb Of God', 'Sevendust', 'Slipknot',
|
||||
'Muse', 'James Bay', 'Death Cab For Cutie', 'Highly Suspect', 'Arctic Monkeys',
|
||||
'The Killers', 'Imagine Dragons', 'Twenty One Pilots', 'Panic! At The Disco', 'Fall Out Boy',
|
||||
'Paramore', 'Green Day', 'Blink-182', 'My Chemical Romance', 'Linkin Park',
|
||||
'Coldplay', 'Radiohead', 'The Strokes', 'Kings of Leon', 'The Black Keys'
|
||||
]
|
||||
|
||||
// 歌曲标题
|
||||
const titles = [
|
||||
"Don't Wanna Fight", 'What Kind Of Man', 'Something From Nothing', "Ex's & Oh's", 'Moaning Lisa Smile',
|
||||
'Cirice', 'Identity', '512', 'Thank You', 'Custer',
|
||||
'Drones', 'Chaos And The Calm', 'Kintsugi', 'Mister Asylum', 'The Gray Chapter',
|
||||
'Believer', 'Thunder', 'Radioactive', 'Demons', 'Warriors',
|
||||
'High Hopes', 'Hey Look Ma I Made It', 'Victorious', 'King of the Clouds', 'Roaring 20s',
|
||||
'Misery Business', 'Still Into You', 'Ain\'t It Fun', 'Hard Times', 'Rose-Colored Boy'
|
||||
]
|
||||
|
||||
// 唱片公司
|
||||
const labels = [
|
||||
'ATO Records', 'Republic', 'RCA Records', 'Warner Bros. Records', 'Atlantic',
|
||||
'Loma Vista Recordings', 'Fearless Records', 'Epic Records', '7Bros Records', 'Roadrunner Records',
|
||||
'300 Entertainment', 'Columbia Records', 'Interscope Records', 'Capitol Records', 'Universal Music',
|
||||
'Sony Music', 'EMI Records', 'Def Jam', 'Island Records', 'Elektra Records'
|
||||
]
|
||||
|
||||
// 生成随机数据
|
||||
const generateMockData = (count: number): MusicAward[] => {
|
||||
const data: MusicAward[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const categoryIndex = i % categories.length
|
||||
const childrenCount = Math.floor(Math.random() * 8) + 3 // 3-10个子项
|
||||
|
||||
const children: Album[] = []
|
||||
for (let j = 0; j < childrenCount; j++) {
|
||||
children.push({
|
||||
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}-${j + 1}`,
|
||||
title: titles[Math.floor(Math.random() * titles.length)],
|
||||
artist: artists[Math.floor(Math.random() * artists.length)],
|
||||
label: labels[Math.floor(Math.random() * labels.length)],
|
||||
level: `${i}-${j + 1}`,
|
||||
})
|
||||
}
|
||||
|
||||
data.push({
|
||||
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}`,
|
||||
category: categories[categoryIndex],
|
||||
artist: null,
|
||||
title: null,
|
||||
label: null,
|
||||
level: String(i),
|
||||
__children: children,
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
export function sourceData(row: number){return generateMockData(row ?? 10)}
|
||||
export const sourceDataObject = generateMockData(10)
|
||||
152
apps/web-ele/src/components/db-hst/sort.ts
Normal file
152
apps/web-ele/src/components/db-hst/sort.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
export const manualRowMoveRenderer = (
|
||||
instance: any,
|
||||
td: HTMLTableCellElement,
|
||||
row: number,
|
||||
_col: number,
|
||||
_prop: string,
|
||||
_value: any,
|
||||
_cellProperties: any
|
||||
) => {
|
||||
// 清空单元格内容
|
||||
td.innerHTML = ''
|
||||
td.classList.add('ht__manualRowMove')
|
||||
|
||||
// 创建拖拽手柄元素
|
||||
const dragHandle = document.createElement('div')
|
||||
dragHandle.className = 'manualRowMover'
|
||||
dragHandle.innerHTML = '☰'
|
||||
dragHandle.style.cursor = 'move'
|
||||
dragHandle.style.textAlign = 'center'
|
||||
dragHandle.style.userSelect = 'none'
|
||||
dragHandle.style.width = '100%'
|
||||
dragHandle.style.height = '100%'
|
||||
dragHandle.style.display = 'flex'
|
||||
dragHandle.style.alignItems = 'center'
|
||||
dragHandle.style.justifyContent = 'center'
|
||||
|
||||
let isDragging = false
|
||||
let startY = 0
|
||||
let currentRow = row
|
||||
|
||||
// 鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
isDragging = true
|
||||
startY = e.clientY
|
||||
currentRow = row
|
||||
|
||||
td.classList.add('on-moving--rows', 'show-ui')
|
||||
instance.selectRows(row)
|
||||
|
||||
// 创建拖拽指示器
|
||||
const guideLine = document.createElement('div')
|
||||
guideLine.className = 'ht__manualRowMove--guideline'
|
||||
guideLine.style.cssText = `
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #1976d2;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
`
|
||||
|
||||
// 添加到表格容器而不是 body,避免触发页面重排
|
||||
const tableContainer = instance.rootElement.parentElement || instance.rootElement
|
||||
tableContainer.style.position = tableContainer.style.position || 'relative'
|
||||
tableContainer.appendChild(guideLine)
|
||||
|
||||
// 鼠标移动
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const rowHeight = 23 // Handsontable 默认行高
|
||||
const rowsToMove = Math.round(deltaY / rowHeight)
|
||||
const targetRow = Math.max(0, Math.min(instance.countRows() - 1, row + rowsToMove))
|
||||
|
||||
// 更新指示线位置
|
||||
const tableRect = instance.rootElement.getBoundingClientRect()
|
||||
const targetRowElement = instance.getCell(targetRow, 0)
|
||||
if (targetRowElement) {
|
||||
const targetRect = targetRowElement.getBoundingClientRect()
|
||||
guideLine.style.top = `${targetRect.top}px`
|
||||
guideLine.style.left = `${tableRect.left}px`
|
||||
guideLine.style.width = `${tableRect.width}px`
|
||||
}
|
||||
|
||||
currentRow = targetRow
|
||||
}
|
||||
|
||||
// 鼠标释放结束拖拽
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
|
||||
isDragging = false
|
||||
td.classList.remove('on-moving--rows', 'show-ui')
|
||||
|
||||
// 移除指示线
|
||||
guideLine.remove()
|
||||
|
||||
// 执行行移动
|
||||
if (currentRow !== row) {
|
||||
const manualRowMovePlugin = instance.getPlugin('manualRowMove')
|
||||
const nestedRowsPlugin = instance.getPlugin('nestedRows')
|
||||
|
||||
if (manualRowMovePlugin && manualRowMovePlugin.isEnabled()) {
|
||||
// 如果启用了 nestedRows 插件,使用 dragRows 方法
|
||||
if (nestedRowsPlugin && nestedRowsPlugin.isEnabled()) {
|
||||
// dragRows 方法的 targetRow 是移除前的位置,不需要调整
|
||||
console.log(`[nestedRows] 移动行 ${row} 到 ${currentRow}`)
|
||||
manualRowMovePlugin.dragRows([row], currentRow)
|
||||
} else {
|
||||
// moveRow 方法需要修复向下拖拽的偏移问题
|
||||
// 当向下移动时,由于先移除源行,后面的索引会减1,所以目标索引也需要减1
|
||||
let targetIndex = currentRow
|
||||
if (currentRow > row) {
|
||||
targetIndex = currentRow - 1
|
||||
}
|
||||
console.log(`[普通模式] 移动行 ${row} 到 ${targetIndex} (原目标: ${currentRow})`)
|
||||
manualRowMovePlugin.moveRow(row, targetIndex)
|
||||
}
|
||||
|
||||
instance.render()
|
||||
}
|
||||
}
|
||||
|
||||
// 取消选中效果
|
||||
instance.deselectCell()
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// 绑定拖拽事件
|
||||
dragHandle.addEventListener('mousedown', handleMouseDown)
|
||||
|
||||
// 悬停效果
|
||||
dragHandle.addEventListener('mouseenter', () => {
|
||||
if (!isDragging) {
|
||||
dragHandle.style.backgroundColor = '#f0f0f0'
|
||||
}
|
||||
})
|
||||
|
||||
dragHandle.addEventListener('mouseleave', () => {
|
||||
if (!isDragging) {
|
||||
dragHandle.style.backgroundColor = ''
|
||||
}
|
||||
})
|
||||
|
||||
td.appendChild(dragHandle)
|
||||
|
||||
return td
|
||||
}
|
||||
201
apps/web-ele/src/components/db-hst/table.ts
Normal file
201
apps/web-ele/src/components/db-hst/table.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
let currentDropdownEl: HTMLElement | null = null
|
||||
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
|
||||
let currentAnchor: { instance: any; row: number; col: number; td: HTMLTableCellElement | null } | null = null
|
||||
|
||||
const clamp = (x: number, min: number, max: number) => (x < min ? min : x > max ? max : x)
|
||||
|
||||
/**
|
||||
* 创建表格数据结构的辅助函数
|
||||
* @param dataSource 数据源数组
|
||||
* @param fieldKeys 字段键数组,按顺序对应表格列
|
||||
* @param getLabelFn 获取标签的函数
|
||||
* @returns 格式化后的表格行HTML和数据属性
|
||||
*/
|
||||
export function createTableDataStructure(
|
||||
dataSource: any[],
|
||||
fieldKeys: string[],
|
||||
getLabelFn?: (item: any) => string
|
||||
) {
|
||||
const getLabel = getLabelFn ?? ((x: any) => x?.name ?? '')
|
||||
|
||||
return dataSource.map(item => {
|
||||
// 动态生成单元格
|
||||
const cells = fieldKeys.map(key => `<td>${String(item?.[key] ?? '')}</td>`).join('')
|
||||
|
||||
// 动态生成 data 属性
|
||||
const dataAttrs = fieldKeys
|
||||
.map(key => `data-${key.toLowerCase()}="${String(item?.[key] ?? '')}"`)
|
||||
.join(' ')
|
||||
|
||||
return {
|
||||
html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" ${dataAttrs}>${cells}</tr>`,
|
||||
data: item
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectedCoords = (inst: any): { row: number; col: number } | null => {
|
||||
const r = inst?.getSelectedRangeLast?.()
|
||||
if (!r) return null
|
||||
return { row: r.to.row, col: r.to.col }
|
||||
}
|
||||
|
||||
const getTargetCellRect = (inst: any, fallbackTD: HTMLTableCellElement | null): DOMRect | null => {
|
||||
const sel = getSelectedCoords(inst)
|
||||
if (sel) {
|
||||
const td = inst.getCell(sel.row, sel.col, true) as HTMLTableCellElement | null
|
||||
if (td) return td.getBoundingClientRect()
|
||||
}
|
||||
return fallbackTD ? fallbackTD.getBoundingClientRect() : null
|
||||
}
|
||||
|
||||
const positionDropdown = (inst: any) => {
|
||||
if (!currentDropdownEl || !inst) return
|
||||
const containerRect = inst.rootElement?.getBoundingClientRect?.()
|
||||
const cellRect = getTargetCellRect(inst, currentAnchor?.td ?? null)
|
||||
if (!containerRect || !cellRect) return
|
||||
const minWidth = Math.max(cellRect.width, 220)
|
||||
currentDropdownEl.style.minWidth = `${minWidth}px`
|
||||
const ddRect = currentDropdownEl.getBoundingClientRect()
|
||||
const spaceBelow = containerRect.bottom - cellRect.bottom
|
||||
const spaceAbove = cellRect.top - containerRect.top
|
||||
const openUp = spaceBelow < ddRect.height && spaceAbove > spaceBelow
|
||||
currentDropdownEl.classList.remove('hot-dropdown--up', 'hot-dropdown--down')
|
||||
currentDropdownEl.classList.add(openUp ? 'hot-dropdown--up' : 'hot-dropdown--down')
|
||||
const maxH = Math.max(0, Math.min(260, (openUp ? spaceAbove : spaceBelow)))
|
||||
currentDropdownEl.style.maxHeight = `${maxH}px`
|
||||
const dropdownW = Math.max(ddRect.width || minWidth, minWidth)
|
||||
const left = clamp(
|
||||
Math.round(cellRect.left),
|
||||
Math.round(containerRect.left ),
|
||||
Math.round(containerRect.right - dropdownW )
|
||||
)
|
||||
const ddh = Math.min(ddRect.height, maxH)
|
||||
const topCandidate = openUp ? Math.round(cellRect.top - ddh) : Math.round(cellRect.bottom)
|
||||
const top = clamp(
|
||||
topCandidate,
|
||||
Math.round(containerRect.top),
|
||||
Math.round(containerRect.bottom - ddh)
|
||||
)
|
||||
currentDropdownEl.style.left = `${left}px`
|
||||
currentDropdownEl.style.top = `${top}px`
|
||||
}
|
||||
|
||||
|
||||
export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
|
||||
td.innerHTML = ''
|
||||
td.classList.add('hot-cell-dropdown')
|
||||
|
||||
const createEl = (tag: string, className?: string, text?: string) => {
|
||||
const el = document.createElement(tag)
|
||||
if (className) el.className = className
|
||||
if (text !== undefined) el.textContent = text
|
||||
return el
|
||||
}
|
||||
|
||||
const labelFn: ((x: any) => string) | undefined = cellProperties.customGetLabel
|
||||
|
||||
const display = createEl('div', 'hot-dropdown-display')
|
||||
const labelText = typeof value === 'string' ? value : labelFn?.(value) ?? (value && typeof value === 'object' ? String(value.name ?? '') : '')
|
||||
if (labelText && labelText.length > 0) display.appendChild(createEl('span', 'hot-dropdown-text', labelText))
|
||||
else display.appendChild(createEl('span', 'hot-dropdown-placeholder', '选择'))
|
||||
|
||||
const trigger = createEl('button', 'hot-dropdown-trigger', '▼') as HTMLButtonElement
|
||||
trigger.type = 'button'
|
||||
|
||||
const buildDropdown = () => {
|
||||
const src = cellProperties.customDropdownSource as any[] | undefined
|
||||
const headers: string[] | undefined = cellProperties.customTableHeaders
|
||||
const dropdown = createEl('div', 'hot-dropdown')
|
||||
const getLabel = labelFn ?? ((x: any) => x?.name ?? '')
|
||||
|
||||
// 创建搜索栏
|
||||
const searchContainer = createEl('div', 'hot-dropdown-search')
|
||||
const searchInput = createEl('input', 'hot-dropdown-search-input') as HTMLInputElement
|
||||
searchInput.type = 'text'
|
||||
searchInput.placeholder = '搜索...'
|
||||
searchContainer.appendChild(searchInput)
|
||||
|
||||
const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : ''
|
||||
|
||||
// 使用自定义字段键或默认字段键
|
||||
const fieldKeys = cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit', 'taxRate', 'priceExTax', 'priceInTax', 'priceExTaxComp', 'priceInTaxComp', 'calcBase']
|
||||
|
||||
const rowsHtml = Array.isArray(src)
|
||||
? createTableDataStructure(src, fieldKeys, labelFn).map(row => row.html).join('')
|
||||
: ''
|
||||
|
||||
const tableEl = createEl('div', 'hot-dropdown-table-wrapper')
|
||||
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody>${rowsHtml}</tbody></table>`
|
||||
|
||||
dropdown.appendChild(searchContainer)
|
||||
dropdown.appendChild(tableEl)
|
||||
|
||||
const tbody = dropdown.querySelector('tbody') as HTMLTableSectionElement
|
||||
const allRows = Array.from(tbody.querySelectorAll('tr')) as HTMLTableRowElement[]
|
||||
|
||||
// 搜索功能 - 动态搜索所有字段
|
||||
const searchFieldKeys = cellProperties.customSearchFields || cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit']
|
||||
searchInput.addEventListener('input', (ev) => {
|
||||
ev.stopPropagation()
|
||||
const keyword = searchInput.value.toLowerCase().trim()
|
||||
allRows.forEach(tr => {
|
||||
const matches = searchFieldKeys.some((key: string) => {
|
||||
const value = (tr.dataset[key.toLowerCase()] ?? '').toLowerCase()
|
||||
return value.includes(keyword)
|
||||
})
|
||||
tr.style.display = matches ? '' : 'none'
|
||||
})
|
||||
})
|
||||
|
||||
// 阻止搜索框点击事件冒泡
|
||||
searchInput.addEventListener('click', (ev) => ev.stopPropagation())
|
||||
|
||||
tbody.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null
|
||||
if (!tr) return
|
||||
const next = tr.dataset.label ?? ''
|
||||
instance.setDataAtCell(row, column, next)
|
||||
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
|
||||
currentDropdownEl = null
|
||||
if (currentOnDocClick) {
|
||||
document.removeEventListener('click', currentOnDocClick)
|
||||
currentOnDocClick = null
|
||||
}
|
||||
})
|
||||
|
||||
// 自动聚焦搜索框
|
||||
setTimeout(() => searchInput.focus(), 50)
|
||||
|
||||
return dropdown
|
||||
}
|
||||
|
||||
const openDropdown = () => {
|
||||
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
|
||||
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick)
|
||||
|
||||
const dropdown = buildDropdown()
|
||||
document.body.appendChild(dropdown)
|
||||
currentDropdownEl = dropdown
|
||||
currentAnchor = { instance, row, col: column, td }
|
||||
positionDropdown(instance)
|
||||
currentOnDocClick = (e: MouseEvent) => {
|
||||
if (!dropdown.contains(e.target as Node)) {
|
||||
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
|
||||
currentDropdownEl = null
|
||||
currentAnchor = null
|
||||
document.removeEventListener('click', currentOnDocClick!)
|
||||
currentOnDocClick = null
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', currentOnDocClick)
|
||||
}
|
||||
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
|
||||
display.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
|
||||
|
||||
td.appendChild(display)
|
||||
td.appendChild(trigger)
|
||||
return td
|
||||
}
|
||||
183
apps/web-ele/src/components/db-hst/tree.ts
Normal file
183
apps/web-ele/src/components/db-hst/tree.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
export const codeRenderer = (
|
||||
hot: any,
|
||||
TD: HTMLTableCellElement,
|
||||
row: number,
|
||||
col: number,
|
||||
prop: any,
|
||||
value: any,
|
||||
cellProperties: any
|
||||
) => {
|
||||
while (TD.firstChild) TD.removeChild(TD.firstChild)
|
||||
const nestedRowsPlugin = hot.getPlugin('nestedRows')
|
||||
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
|
||||
const rowObj = hot.getSourceDataAtRow(physicalRow)
|
||||
const container = document.createElement('div')
|
||||
container.style.display = 'flex'
|
||||
container.style.alignItems = 'center'
|
||||
container.style.gap = '6px'
|
||||
const level = nestedRowsPlugin?.dataManager.getRowLevel(physicalRow) ?? 0
|
||||
for (let i = 0; i < (level || 0); i++) {
|
||||
const spacer = document.createElement('span')
|
||||
spacer.className = 'ht_nestingLevel_empty'
|
||||
container.appendChild(spacer)
|
||||
}
|
||||
if (rowObj && Array.isArray(rowObj.__children) && rowObj.__children.length > 0) {
|
||||
|
||||
const isCollapsed = nestedRowsPlugin?.collapsingUI.areChildrenCollapsed(physicalRow) ?? false
|
||||
const btn = document.createElement('div')
|
||||
btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
|
||||
btn.addEventListener('mousedown', (ev) => {
|
||||
if (ev.button !== 0) return
|
||||
ev.stopPropagation()
|
||||
ev.preventDefault()
|
||||
if (!nestedRowsPlugin) return
|
||||
const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow)
|
||||
if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(physicalRow)
|
||||
else nestedRowsPlugin.collapsingUI.collapseChildren(physicalRow)
|
||||
})
|
||||
container.appendChild(btn)
|
||||
}/*else{
|
||||
container.classList.add('text-relative')
|
||||
}*/
|
||||
const text = document.createElement('span')
|
||||
text.textContent = value == null ? '' : String(value)
|
||||
text.classList.add('rowHeader')
|
||||
//解决右键行头触发上下文菜单事件
|
||||
text.addEventListener('contextmenu', (ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
const e = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: (ev as MouseEvent).clientX,
|
||||
clientY: (ev as MouseEvent).clientY
|
||||
})
|
||||
TD.dispatchEvent(e)
|
||||
})
|
||||
container.appendChild(text)
|
||||
TD.appendChild(container)
|
||||
|
||||
return TD
|
||||
}
|
||||
|
||||
export const computeCodeColWidth = (hot: any): number => {
|
||||
// if (!hot) return codeColWidth.value
|
||||
const data = hot.getSourceData() || []
|
||||
const maxLen = data.reduce((m: number, r: any) => {
|
||||
const v = r && r.code != null ? String(r.code) : ''
|
||||
return Math.max(m, v.length)
|
||||
}, 0)
|
||||
const charWidth = 9
|
||||
const basePadding = 24
|
||||
const nested = hot.getPlugin('nestedRows')
|
||||
const levelCount = nested?.dataManager?.cache?.levelCount ?? 0
|
||||
const rootEl = hot.rootElement as HTMLElement
|
||||
const styles = rootEl ? getComputedStyle(rootEl) : null
|
||||
const iconSizeStr = styles ? styles.getPropertyValue('--ht-icon-size') : ''
|
||||
const iconSize = Number.parseFloat(iconSizeStr) || 16
|
||||
const indicatorWidth = 5
|
||||
const gap = 6
|
||||
const nestedPadding = levelCount * indicatorWidth + iconSize + gap
|
||||
const width = Math.ceil(maxLen * charWidth) + basePadding + nestedPadding
|
||||
return Math.min(Math.max(80, width), 480)
|
||||
}
|
||||
|
||||
// 工具函数:解析 level 字符串
|
||||
const parseLevel = (level: string): number[] =>
|
||||
level?.split('-').map(n => Number(n)).filter(n => !Number.isNaN(n)) ?? []
|
||||
|
||||
// 工具函数:根据 level 获取容器和索引
|
||||
const getContainerAndIndexByLevel = (data: any[], level: string) => {
|
||||
const seg = parseLevel(level)
|
||||
if (seg.length === 0) return null
|
||||
if (seg.length === 1) return { container: data, index: seg[0], parentLevel: null }
|
||||
|
||||
let parent = data[seg[0]]
|
||||
for (let i = 1; i < seg.length - 1; i++) {
|
||||
if (!Array.isArray(parent.__children)) parent.__children = []
|
||||
parent = parent.__children[seg[i] - 1]
|
||||
if (!parent) return null
|
||||
}
|
||||
|
||||
const container = Array.isArray(parent.__children) ? parent.__children : (parent.__children = [])
|
||||
const index = seg[seg.length - 1] - 1
|
||||
const parentLevel = seg.slice(0, -1).join('-')
|
||||
return { container, index, parentLevel }
|
||||
}
|
||||
|
||||
// 工具函数:根据 level 查找节点
|
||||
const findNodeByLevel = (data: any[], level: string) => {
|
||||
const loc = getContainerAndIndexByLevel(data, level)
|
||||
return loc ? (loc.container[loc.index] ?? null) : null
|
||||
}
|
||||
|
||||
// 工具函数:重新索引 level
|
||||
const reindexLevels = (container: any[], parentLevel: string | null): void => {
|
||||
container.forEach((row, i) => {
|
||||
const currentLevel = parentLevel == null ? String(i) : `${parentLevel}-${i + 1}`
|
||||
row.level = currentLevel
|
||||
if (Array.isArray(row.__children) && row.__children.length > 0) {
|
||||
reindexLevels(row.__children, currentLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 统一的行操作处理函数
|
||||
export const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child' | 'delete') => {
|
||||
const selected = hot.getSelected()
|
||||
if (!selected || selected.length === 0) return
|
||||
|
||||
const row = selected[0][0]
|
||||
const nestedRowsPlugin = hot.getPlugin('nestedRows')
|
||||
if (!nestedRowsPlugin) return
|
||||
|
||||
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
|
||||
const rowData = hot.getSourceDataAtRow(physicalRow)
|
||||
const currentLevel = String(rowData.level ?? '')
|
||||
const data = JSON.parse(JSON.stringify(hot.getSettings().data))
|
||||
if (type === 'delete') {
|
||||
// 删除行
|
||||
const loc = getContainerAndIndexByLevel(data, currentLevel)
|
||||
if (!loc) return
|
||||
const { container, index, parentLevel } = loc
|
||||
container.splice(index, 1)
|
||||
reindexLevels(container, parentLevel)
|
||||
} else {
|
||||
// 根据 columns 配置动态生成 newRow 对象结构
|
||||
const columns = hot.getSettings().columns || []
|
||||
const newRow: any = {
|
||||
level: null,
|
||||
__children: []
|
||||
}
|
||||
|
||||
// 根据 columns 的 data 字段生成对象结构
|
||||
columns.forEach((col: any) => {
|
||||
if (col.data && col.data !== 'level' && col.data !== '__children') {
|
||||
newRow[col.data] = null
|
||||
}
|
||||
})
|
||||
|
||||
if (type === 'child') {
|
||||
// 添加子行
|
||||
const parentNode = findNodeByLevel(data, currentLevel)
|
||||
if (!parentNode) return
|
||||
if (!Array.isArray(parentNode.__children)) parentNode.__children = []
|
||||
const nextIndex = parentNode.__children.length + 1
|
||||
newRow.level = `${currentLevel}-${nextIndex}`
|
||||
parentNode.__children.push(newRow)
|
||||
} else {
|
||||
// 在上方或下方插入行
|
||||
const loc = getContainerAndIndexByLevel(data, currentLevel)
|
||||
if (!loc) return
|
||||
const { container, index, parentLevel } = loc
|
||||
const insertIndex = type === 'above' ? Math.max(index, 0) : Math.max(index + 1, 0)
|
||||
container.splice(insertIndex, 0, newRow)
|
||||
reindexLevels(container, parentLevel)
|
||||
}
|
||||
}
|
||||
hot.updateSettings({ data })
|
||||
// nestedRowsPlugin.headersUI.updateRowHeaderWidth()
|
||||
hot.render()
|
||||
}
|
||||
268
apps/web-ele/src/components/db-tree/contextMenu.ts
Normal file
268
apps/web-ele/src/components/db-tree/contextMenu.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
|
||||
|
||||
interface NodeBase<T> { id: string; label: string; children?: T[] }
|
||||
type NodeType<T> = T & NodeBase<T>
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
|
||||
interface MenuItem { key: string; text: string; disabled?: boolean }
|
||||
|
||||
interface ContextMenuConfig<T> {
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
treeRef: Ref<TreeV2Instance | undefined>;
|
||||
expandedKeysRef: Ref<string[]>;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
startEdit: (node: NodeType<T>) => void;
|
||||
}
|
||||
|
||||
interface ContextMenuContext<T> {
|
||||
createNode: (prefix: string) => NodeType<T>;
|
||||
setData: (next: NodeType<T>[]) => void;
|
||||
expandNode: (id: string) => void;
|
||||
setCurrentKey: (id: string) => void;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
startEdit: (node: NodeType<T>) => void;
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
}
|
||||
|
||||
interface LevelConfig {
|
||||
depth: number
|
||||
addKey?: string
|
||||
addText?: string
|
||||
allowDelete?: boolean
|
||||
}
|
||||
|
||||
interface HierarchyConfig {
|
||||
rootKey: string
|
||||
rootText: string
|
||||
levels: LevelConfig[]
|
||||
}
|
||||
|
||||
interface ContextMenuHandler<T> {
|
||||
getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
|
||||
execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点深度
|
||||
*/
|
||||
const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return -1
|
||||
let depth = 0
|
||||
let p = target.parent
|
||||
while (p) {
|
||||
depth += 1
|
||||
const pt = ctx.locate(p.id)
|
||||
p = pt?.parent ?? null
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级化上下文菜单处理器
|
||||
*/
|
||||
class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
constructor(private config: HierarchyConfig) {}
|
||||
|
||||
getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
// 空白区域右键 - 添加根节点
|
||||
if (!node) {
|
||||
return [{ key: this.config.rootKey, text: this.config.rootText }]
|
||||
}
|
||||
|
||||
const depth = getDepth(node, ctx)
|
||||
const levelConfig = this.config.levels.find(l => l.depth === depth)
|
||||
|
||||
if (!levelConfig) {
|
||||
// 未配置的层级,只显示删除
|
||||
return [{ key: 'delete', text: '删除' }]
|
||||
}
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
// 添加子级菜单项
|
||||
if (levelConfig.addKey && levelConfig.addText) {
|
||||
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
|
||||
}
|
||||
|
||||
// 删除菜单项
|
||||
if (levelConfig.allowDelete !== false) {
|
||||
items.push({ key: 'delete', text: '删除' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
// 添加根节点
|
||||
if (!node && cmd === this.config.rootKey) {
|
||||
const next = ctx.createNode('root')
|
||||
next.label = this.config.rootText.replace('添加', '')
|
||||
ctx.setData([...ctx.dataRef.value, next])
|
||||
return
|
||||
}
|
||||
|
||||
if (!node) return
|
||||
|
||||
// 删除节点
|
||||
if (cmd === 'delete') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
target.container.splice(target.index, 1)
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
return
|
||||
}
|
||||
|
||||
// 查找匹配的层级配置
|
||||
const depth = getDepth(node, ctx)
|
||||
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
|
||||
|
||||
if (levelConfig) {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
|
||||
const next = ctx.createNode(node.id)
|
||||
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
|
||||
|
||||
target.node.children = target.node.children || []
|
||||
target.node.children.push(next)
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
ctx.expandNode(target.node.id)
|
||||
ctx.setCurrentKey(next.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认上下文菜单处理器
|
||||
*/
|
||||
class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
if (!node) return [{ key: 'add-root', text: '添加根目录' }]
|
||||
return [
|
||||
{ key: 'add-sibling', text: '添加目录' },
|
||||
{ key: 'add-child', text: '添加子目录' },
|
||||
{ key: 'rename', text: '重命名' },
|
||||
{ key: 'delete', text: '删除' },
|
||||
]
|
||||
}
|
||||
|
||||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
if (!node && cmd === 'add-root') {
|
||||
ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
|
||||
return
|
||||
}
|
||||
if (!node) return
|
||||
if (cmd === 'add-sibling') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
const prefix = target.parent ? target.parent.id : 'node'
|
||||
target.container.push(ctx.createNode(prefix))
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
return
|
||||
}
|
||||
if (cmd === 'add-child') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
target.node.children = target.node.children || []
|
||||
target.node.children.push(ctx.createNode(target.node.id))
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
ctx.expandNode(target.node.id)
|
||||
ctx.setCurrentKey(target.node.id)
|
||||
return
|
||||
}
|
||||
if (cmd === 'rename') { ctx.startEdit(node); return }
|
||||
if (cmd === 'delete') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
target.container.splice(target.index, 1)
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DbTreeContextMenu<T> {
|
||||
dropdownRef = ref<DropdownInstance>()
|
||||
position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
|
||||
triggerRef = ref({ getBoundingClientRect: () => this.position.value })
|
||||
currentNode = ref<NodeType<T> | null>(null)
|
||||
|
||||
private config: ContextMenuConfig<T>
|
||||
private handler: ContextMenuHandler<T>
|
||||
|
||||
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
|
||||
this.config = config
|
||||
this.handler = handler ?? new DefaultContextMenuHandler<T>()
|
||||
}
|
||||
|
||||
private createNode = (prefix: string): NodeType<T> => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const id = `${prefix}-${suffix}`
|
||||
return { id, label: '新目录' } as NodeType<T>
|
||||
}
|
||||
|
||||
private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
|
||||
private expandNode = (id: string) => {
|
||||
const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
|
||||
this.config.expandedKeysRef.value = keys
|
||||
this.config.treeRef.value?.setExpandedKeys(keys)
|
||||
}
|
||||
private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
|
||||
private ctx = (): ContextMenuContext<T> => ({
|
||||
createNode: this.createNode,
|
||||
setData: this.setData,
|
||||
expandNode: this.expandNode,
|
||||
setCurrentKey: this.setCurrentKey,
|
||||
locate: this.config.locate,
|
||||
startEdit: this.config.startEdit,
|
||||
dataRef: this.config.dataRef,
|
||||
})
|
||||
|
||||
getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
|
||||
|
||||
openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
|
||||
const { clientX, clientY } = event
|
||||
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
|
||||
event.preventDefault()
|
||||
this.currentNode.value = nodeData
|
||||
this.dropdownRef.value?.handleOpen()
|
||||
}
|
||||
|
||||
openBlankContextMenu = (event: MouseEvent) => {
|
||||
const { clientX, clientY } = event
|
||||
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
|
||||
event.preventDefault()
|
||||
this.currentNode.value = null
|
||||
this.dropdownRef.value?.handleOpen()
|
||||
}
|
||||
|
||||
closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
|
||||
|
||||
onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
|
||||
onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建层级化的上下文菜单处理器
|
||||
* @param config 层级配置
|
||||
* @returns ContextMenuHandler
|
||||
*/
|
||||
const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
|
||||
return new HierarchyContextMenuHandler<T>(config)
|
||||
}
|
||||
|
||||
const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
|
||||
// 如果传入的是 HierarchyConfig,自动创建 HierarchyContextMenuHandler
|
||||
if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
|
||||
return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
|
||||
}
|
||||
return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
|
||||
}
|
||||
|
||||
export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
|
||||
export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
|
||||
|
||||
export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)
|
||||
102
apps/web-ele/src/components/db-tree/draggable.ts
Normal file
102
apps/web-ele/src/components/db-tree/draggable.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { TreeV2Instance } from 'element-plus'
|
||||
|
||||
type NodeType<T> = T & { id: string; label: string; children?: NodeType<T>[] }
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
|
||||
type DropType = 'before' | 'after' | 'inside'
|
||||
|
||||
const getDropType = (e: DragEvent): DropType => {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const offset = e.clientY - rect.top
|
||||
if (offset < rect.height * 0.25) return 'before'
|
||||
if (offset > rect.height * 0.75) return 'after'
|
||||
return 'inside'
|
||||
}
|
||||
|
||||
export const useDragAndDrop = <T>(params: {
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
treeRef: Ref<TreeV2Instance | undefined>;
|
||||
expandedKeysRef: Ref<string[]>;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
}) => {
|
||||
const { dataRef, treeRef, expandedKeysRef, locate } = params
|
||||
const draggingId = ref<string | null>(null)
|
||||
const dropState = ref<{ id: string | null; type: DropType | null }>({ id: null, type: null })
|
||||
|
||||
const contains = (sourceId: string, targetId: string) => {
|
||||
const src = locate(sourceId)
|
||||
if (!src) return false
|
||||
const stack: NodeType<T>[] = src.node.children ? [...src.node.children] : []
|
||||
while (stack.length) {
|
||||
const n = stack.pop()!
|
||||
if (n.id === targetId) return true
|
||||
if (n.children && n.children.length) stack.push(...n.children)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getNodeStyles = (node: NodeType<T>) => {
|
||||
const base: Record<string, string> = { display: 'block', width: '100%'}
|
||||
if (dropState.value.id === node.id && dropState.value.type) {
|
||||
if (dropState.value.type === 'before') base.borderTop = '2px solid #409eff'
|
||||
else if (dropState.value.type === 'after') base.borderBottom = '2px solid #409eff'
|
||||
else base.background = 'rgba(64,158,255,0.15)'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const onDragStart = (node: NodeType<T>, e: DragEvent) => {
|
||||
draggingId.value = node.id
|
||||
e.dataTransfer?.setData('text/plain', node.id)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const onDragOver = (node: NodeType<T>, e: DragEvent) => {
|
||||
if (!draggingId.value) return
|
||||
if (draggingId.value === node.id) { dropState.value = { id: null, type: null }; return }
|
||||
const type = getDropType(e)
|
||||
dropState.value = { id: node.id, type }
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const onDrop = (node: NodeType<T>, e: DragEvent) => {
|
||||
if (!draggingId.value) return
|
||||
const sourceId = draggingId.value
|
||||
const type = getDropType(e)
|
||||
if (sourceId === node.id) { onDragEnd(); return }
|
||||
if (type === 'inside' && contains(sourceId, node.id)) { onDragEnd(); return }
|
||||
const src = locate(sourceId)
|
||||
const tgt = locate(node.id)
|
||||
if (!src || !tgt) { onDragEnd(); return }
|
||||
src.container.splice(src.index, 1)
|
||||
if (type === 'inside') {
|
||||
const nextChildren = tgt.node.children ? [...tgt.node.children] : []
|
||||
nextChildren.push(src.node as NodeType<T>)
|
||||
tgt.node.children = nextChildren
|
||||
expandedKeysRef.value = Array.from(new Set([...expandedKeysRef.value, tgt.node.id]))
|
||||
treeRef.value?.setExpandedKeys(expandedKeysRef.value)
|
||||
treeRef.value?.setCurrentKey?.(src.node.id)
|
||||
} else {
|
||||
let insertIndex = tgt.index
|
||||
if (type === 'after') insertIndex = tgt.index + 1
|
||||
const same = src.container === tgt.container
|
||||
if (same && src.index < tgt.index && type === 'before') insertIndex -= 1
|
||||
if (same && src.index < tgt.index && type === 'after') insertIndex -= 1
|
||||
tgt.container.splice(insertIndex, 0, src.node)
|
||||
treeRef.value?.setCurrentKey?.(src.node.id)
|
||||
}
|
||||
dataRef.value = [...dataRef.value]
|
||||
onDragEnd()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggingId.value = null
|
||||
dropState.value = { id: null, type: null }
|
||||
}
|
||||
|
||||
return { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd }
|
||||
}
|
||||
|
||||
1
apps/web-ele/src/components/db-tree/index.ts
Normal file
1
apps/web-ele/src/components/db-tree/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DbTree } from './index.vue';
|
||||
321
apps/web-ele/src/components/db-tree/index.vue
Normal file
321
apps/web-ele/src/components/db-tree/index.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="db-tree-container">
|
||||
<ElInput
|
||||
v-if="isSearchEnabled"
|
||||
v-model="query"
|
||||
style="width: 100%"
|
||||
placeholder="请输入关键字"
|
||||
@input="onQueryChanged"
|
||||
/>
|
||||
<ElTreeV2
|
||||
class="treeLine-2"
|
||||
:indent="0"
|
||||
ref="treeRef"
|
||||
style="max-width: 600px"
|
||||
:data="data"
|
||||
:props="props"
|
||||
:filter-method="filterMethod"
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:height="treeHeight"
|
||||
@node-expand="onNodeExpand"
|
||||
@node-collapse="onNodeCollapse"
|
||||
@contextmenu="openBlankContextMenu"
|
||||
highlight-current
|
||||
scrollbar-always-on
|
||||
>
|
||||
<template #default="{ node, data: nodeData }">
|
||||
<!-- 根据层级生成占位符,level 1 不需要占位 -->
|
||||
<span v-for="i in (node.level - 1)" :key="i" class="node_nestingLevel_empty"></span>
|
||||
<div class="node-content-wrapper" :style="{ paddingLeft: node.isLeaf ? '0px' : '11px' }">
|
||||
<span class="node-icon-wrapper">
|
||||
<IconifyIcon
|
||||
v-if="!node.isLeaf"
|
||||
:icon="node.expanded ? 'ep:remove' : 'ep:circle-plus'"
|
||||
class="custom-expand-icon"
|
||||
/>
|
||||
</span>
|
||||
<template v-if="editingId === nodeData.id">
|
||||
<ElInput
|
||||
:id="`edit-${nodeData.id}`"
|
||||
v-model="editingLabel"
|
||||
@blur="saveEdit"
|
||||
@keydown.enter.prevent="saveEdit"
|
||||
@keydown.esc.prevent="cancelEdit"
|
||||
@click.stop
|
||||
size="small"
|
||||
class="node-edit-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="node-label"
|
||||
:style="getNodeStyles(nodeData)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(nodeData, $event)"
|
||||
@dragover.prevent="onDragOver(nodeData, $event)"
|
||||
@drop.prevent="onDrop(nodeData, $event)"
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop="(e) => openContextMenu(e, nodeData)"
|
||||
@click="onNodeSingleClick(nodeData, $event)"
|
||||
@dblclick.stop="onNodeDblClick(nodeData, $event)"
|
||||
>
|
||||
{{ nodeData.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElTreeV2>
|
||||
<ElDropdown
|
||||
ref="dropdownRef"
|
||||
:virtual-ref="triggerRef"
|
||||
:show-arrow="false"
|
||||
virtual-triggering
|
||||
trigger="contextmenu"
|
||||
placement="bottom-start"
|
||||
@command="onGlobalCommand"
|
||||
>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="item in getMenuItems()"
|
||||
:key="item.key"
|
||||
:command="item.key"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
{{ item.text }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick, computed, watch } from 'vue'
|
||||
import { ElTreeV2, ElDropdown, ElDropdownMenu, ElDropdownItem, ElInput } from 'element-plus'
|
||||
import type { TreeNodeData, TreeV2Instance } from 'element-plus'
|
||||
import { IconifyIcon } from '@vben/icons'
|
||||
import { useContextMenu } from './contextMenu'
|
||||
import type { ContextMenuHandler, HierarchyConfig } from './contextMenu'
|
||||
import { useInlineEdit } from './inlineEdit'
|
||||
import { useDragAndDrop } from './draggable'
|
||||
|
||||
defineOptions({ name: 'DbTree' });
|
||||
const componentProps = defineProps<{ height?: number; data?: Tree[]; defaultExpandedKeys?: number | string | string[]; contextMenuHandler?: ContextMenuHandler<Tree> | HierarchyConfig; search?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'select', node: Tree): void }>()
|
||||
|
||||
interface Tree {
|
||||
id: string
|
||||
label: string
|
||||
children?: Tree[]
|
||||
}
|
||||
|
||||
const query = ref('')
|
||||
const treeRef = ref<TreeV2Instance>()
|
||||
const data = ref<Tree[]>(componentProps.data ?? [])
|
||||
const expandedKeys = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
() => componentProps.data,
|
||||
(val) => { data.value = Array.isArray(val) ? [...val] : [] }
|
||||
)
|
||||
|
||||
const treeHeight = computed(() => componentProps.height ?? 400)
|
||||
const isSearchEnabled = computed(() => componentProps.search ?? true)
|
||||
const props = {
|
||||
value: 'id',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
//默认展开层级
|
||||
const defaultExpandedKeys = computed(() => {
|
||||
const param = componentProps.defaultExpandedKeys
|
||||
if (param === undefined) return []
|
||||
if (Array.isArray(param)) return param
|
||||
let level = 0
|
||||
if (typeof param === 'number') {
|
||||
level = Number.isFinite(param) && param > 0 ? Math.floor(param) : 0
|
||||
} else if (typeof param === 'string') {
|
||||
const n = parseInt(param, 10)
|
||||
level = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
|
||||
}
|
||||
if (level <= 0) return []
|
||||
const keys: string[] = []
|
||||
const stack: Array<{ node: Tree; depth: number }> = []
|
||||
for (const n of data.value) stack.push({ node: n, depth: 1 })
|
||||
while (stack.length) {
|
||||
const { node, depth } = stack.pop()!
|
||||
const children = node.children ?? []
|
||||
if (children.length && depth <= level) keys.push(node.id)
|
||||
for (const c of children) stack.push({ node: c, depth: depth + 1 })
|
||||
}
|
||||
return Array.from(new Set(keys))
|
||||
})
|
||||
|
||||
watch(
|
||||
[data, () => componentProps.defaultExpandedKeys],
|
||||
() => {
|
||||
const param = componentProps.defaultExpandedKeys
|
||||
if (param === undefined) return
|
||||
const keys = defaultExpandedKeys.value
|
||||
expandedKeys.value = [...keys]
|
||||
nextTick(() => {
|
||||
treeRef.value?.setExpandedKeys(expandedKeys.value)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onQueryChanged = (query: string) => {
|
||||
treeRef.value!.filter(query)
|
||||
}
|
||||
const filterMethod = (query: string, node: TreeNodeData) =>
|
||||
node.label!.includes(query)
|
||||
|
||||
type LocateResult = { node: Tree; parent: Tree | null; container: Tree[]; index: number }
|
||||
|
||||
const locate = (id: string): LocateResult | null => {
|
||||
const stack: { children: Tree[]; parent: Tree | null }[] = [{ children: data.value, parent: null }]
|
||||
while (stack.length) {
|
||||
const { children, parent } = stack.pop()!
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const n = children[i]
|
||||
if (n.id === id) return { node: n, parent, container: children, index: i }
|
||||
if (n.children && n.children.length) stack.push({ children: n.children, parent: n })
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit } = useInlineEdit<Tree>({ dataRef: data, locate })
|
||||
const { dropdownRef, position, triggerRef, currentNode, openContextMenu, openBlankContextMenu, closeContextMenu, onGlobalCommand, getMenuItems } = useContextMenu<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate, startEdit }, componentProps.contextMenuHandler)
|
||||
const { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd } = useDragAndDrop<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate })
|
||||
|
||||
//防止误触发
|
||||
let clickTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const clickDelayMs = 250
|
||||
const triggerSingleClick = (node: Tree) => {
|
||||
closeContextMenu()
|
||||
emit('select', node)
|
||||
}
|
||||
const onNodeSingleClick = (node: Tree, e: MouseEvent) => {
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
}
|
||||
clickTimer = setTimeout(() => {
|
||||
triggerSingleClick(node)
|
||||
clickTimer = null
|
||||
}, clickDelayMs)
|
||||
}
|
||||
const onNodeDblClick = (node: Tree, e: MouseEvent) => {
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
}
|
||||
startEdit(node)
|
||||
}
|
||||
|
||||
const onNodeExpand = (data: TreeNodeData) => {
|
||||
const key = (data as any)[props.value] as string
|
||||
if (!key) return
|
||||
if (!expandedKeys.value.includes(key)) {
|
||||
expandedKeys.value = [...expandedKeys.value, key]
|
||||
}
|
||||
}
|
||||
|
||||
const onNodeCollapse = (data: TreeNodeData) => {
|
||||
const key = (data as any)[props.value] as string
|
||||
if (!key) return
|
||||
if (expandedKeys.value.includes(key)) {
|
||||
expandedKeys.value = expandedKeys.value.filter((k) => k !== key)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.db-tree-container {
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// gap: 8px;
|
||||
}
|
||||
.treeLine-2 {
|
||||
.node_nestingLevel_empty {
|
||||
display: inline-block;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.node_nestingLevel_empty::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
height: 26px;
|
||||
width: 1px;
|
||||
border-left: 1px solid #ababab;
|
||||
}
|
||||
|
||||
// 横线 - 从竖线连接到节点
|
||||
.node_nestingLevel_empty::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
width: 13px;
|
||||
height: 1px;
|
||||
border-top: 1px solid #ababab;
|
||||
}
|
||||
.node-content-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.node-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
display: inline-block;
|
||||
line-height: 1.0;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
// font-size: 11px;
|
||||
}
|
||||
|
||||
.node-edit-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
|
||||
.node_nestingLevel_empty:has(+ .node_nestingLevel_empty)::after {
|
||||
/* 你的样式 */
|
||||
width: 0px;
|
||||
}
|
||||
:deep(.el-tree-node){
|
||||
left: -9px !important;
|
||||
}
|
||||
:deep(.el-tree-node__expand-icon){
|
||||
display: none !important;
|
||||
}
|
||||
// :deep(.el-tree-node__content){
|
||||
// display: none !important;
|
||||
// }
|
||||
}
|
||||
</style>
|
||||
45
apps/web-ele/src/components/db-tree/inlineEdit.ts
Normal file
45
apps/web-ele/src/components/db-tree/inlineEdit.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
type NodeType<T> = T & { id: string; label: string; children?: T[] }
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
|
||||
export const useInlineEdit = <T>(params: {
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
}) => {
|
||||
const { dataRef, locate } = params
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingLabel = ref('')
|
||||
const editingOriginalLabel = ref('')
|
||||
|
||||
const startEdit = (nodeData: NodeType<T>) => {
|
||||
editingId.value = nodeData.id
|
||||
editingLabel.value = nodeData.label
|
||||
editingOriginalLabel.value = nodeData.label
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`edit-${nodeData.id}`) as HTMLInputElement | null
|
||||
el?.focus()
|
||||
el?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!editingId.value) return
|
||||
const target = locate(editingId.value)
|
||||
if (!target) { editingId.value = null; return }
|
||||
const next = editingLabel.value.trim()
|
||||
if (next) target.node.label = next
|
||||
dataRef.value = [...dataRef.value]
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
if (!editingId.value) return
|
||||
editingLabel.value = editingOriginalLabel.value
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
return { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit }
|
||||
}
|
||||
|
||||
169
apps/web-ele/src/components/description/description.vue
Normal file
169
apps/web-ele/src/components/description/description.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="tsx">
|
||||
import type { CSSProperties, PropType, Slots } from 'vue';
|
||||
|
||||
import type { DescriptionItemSchema, DescriptionProps } from './typing';
|
||||
|
||||
import { computed, defineComponent, ref, unref, useAttrs } from 'vue';
|
||||
|
||||
import { get, getNestedValue, isFunction } from '@vben/utils';
|
||||
|
||||
import { ElDescriptions, ElDescriptionsItem } from 'element-plus';
|
||||
|
||||
const props = {
|
||||
border: { default: true, type: Boolean },
|
||||
column: {
|
||||
default: () => {
|
||||
return { lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4 };
|
||||
},
|
||||
type: [Number, Object],
|
||||
},
|
||||
data: { type: Object },
|
||||
schema: {
|
||||
default: () => [],
|
||||
type: Array as PropType<DescriptionItemSchema[]>,
|
||||
},
|
||||
size: {
|
||||
default: 'default',
|
||||
type: String,
|
||||
validator: (v: string) =>
|
||||
['default', 'middle', 'small', undefined].includes(v),
|
||||
},
|
||||
title: { default: '', type: String },
|
||||
direction: { default: 'horizontal', type: String },
|
||||
};
|
||||
|
||||
function getSlot(slots: Slots, slot: string, data?: any) {
|
||||
if (!slots || !Reflect.has(slots, slot)) {
|
||||
return null;
|
||||
}
|
||||
if (!isFunction(slots[slot])) {
|
||||
console.error(`${slot} is not a function!`);
|
||||
return null;
|
||||
}
|
||||
const slotFn = slots[slot];
|
||||
if (!slotFn) return null;
|
||||
return slotFn({ data });
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Description',
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const propsRef = ref<null | Partial<DescriptionProps>>(null);
|
||||
|
||||
const prefixCls = 'description';
|
||||
const attrs = useAttrs();
|
||||
|
||||
const getMergeProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
...(unref(propsRef) as any),
|
||||
} as DescriptionProps;
|
||||
});
|
||||
|
||||
const getProps = computed(() => {
|
||||
const opt = {
|
||||
...unref(getMergeProps),
|
||||
};
|
||||
return opt as DescriptionProps;
|
||||
});
|
||||
|
||||
const getDescriptionsProps = computed(() => {
|
||||
return { ...unref(attrs), ...unref(getProps) } as DescriptionProps;
|
||||
});
|
||||
|
||||
// 防止换行
|
||||
function renderLabel({
|
||||
label,
|
||||
labelMinWidth,
|
||||
labelStyle,
|
||||
}: DescriptionItemSchema) {
|
||||
if (!labelStyle && !labelMinWidth) {
|
||||
return label;
|
||||
}
|
||||
|
||||
const labelStyles: CSSProperties = {
|
||||
...labelStyle,
|
||||
minWidth: `${labelMinWidth}px `,
|
||||
};
|
||||
return <div style={labelStyles}>{label}</div>;
|
||||
}
|
||||
|
||||
function renderItem() {
|
||||
const { data, schema } = unref(getProps);
|
||||
return unref(schema)
|
||||
.map((item) => {
|
||||
const { contentMinWidth, field, render, show, span } = item;
|
||||
|
||||
if (show && isFunction(show) && !show(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContent() {
|
||||
const _data = unref(getProps)?.data;
|
||||
if (!_data) {
|
||||
return null;
|
||||
}
|
||||
const getField = field.includes('.')
|
||||
? (getNestedValue(_data, field) ?? get(_data, field))
|
||||
: get(_data, field);
|
||||
// if (
|
||||
// getField &&
|
||||
// !Object.prototype.hasOwnProperty.call(toRefs(_data), field)
|
||||
// ) {
|
||||
// return isFunction(render) ? render('', _data) : (getField ?? '');
|
||||
// }
|
||||
return isFunction(render)
|
||||
? render(getField, _data)
|
||||
: (getField ?? '');
|
||||
}
|
||||
|
||||
const width = contentMinWidth;
|
||||
return (
|
||||
<ElDescriptionsItem key={field} span={span}>
|
||||
{{
|
||||
label: () => {
|
||||
return renderLabel(item);
|
||||
},
|
||||
default: () => {
|
||||
if (item.slot) {
|
||||
return getSlot(slots, item.slot, data);
|
||||
}
|
||||
if (!contentMinWidth) {
|
||||
return getContent();
|
||||
}
|
||||
const style: CSSProperties = {
|
||||
minWidth: `${width}px`,
|
||||
};
|
||||
return <div style={style}>{getContent()}</div>;
|
||||
},
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
);
|
||||
})
|
||||
.filter((item) => !!item);
|
||||
}
|
||||
|
||||
function renderDesc() {
|
||||
const extraSlot = getSlot(slots, 'extra');
|
||||
const slotsObj: any = {
|
||||
default: () => renderItem(),
|
||||
};
|
||||
if (extraSlot) {
|
||||
slotsObj.extra = () => extraSlot;
|
||||
}
|
||||
return (
|
||||
<ElDescriptions
|
||||
class={`${prefixCls}`}
|
||||
title={unref(getMergeProps).title}
|
||||
{...(unref(getDescriptionsProps) as any)}
|
||||
>
|
||||
{slotsObj}
|
||||
</ElDescriptions>
|
||||
);
|
||||
}
|
||||
|
||||
return () => renderDesc();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
3
apps/web-ele/src/components/description/index.ts
Normal file
3
apps/web-ele/src/components/description/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Description } from './description.vue';
|
||||
export * from './typing';
|
||||
export { useDescription } from './use-description';
|
||||
30
apps/web-ele/src/components/description/typing.ts
Normal file
30
apps/web-ele/src/components/description/typing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DescriptionProps as ElDescriptionProps } from 'element-plus';
|
||||
import type { JSX } from 'vue/jsx-runtime';
|
||||
|
||||
import type { CSSProperties, VNode } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
export interface DescriptionItemSchema {
|
||||
labelMinWidth?: number;
|
||||
contentMinWidth?: number;
|
||||
labelStyle?: CSSProperties; // 自定义标签样式
|
||||
field: string; // 对应 data 中的字段名
|
||||
label: JSX.Element | string | VNode; // 内容的描述
|
||||
span?: number; // 包含列的数量
|
||||
show?: (...arg: any) => boolean; // 是否显示
|
||||
slot?: string; // 插槽名称
|
||||
render?: (
|
||||
val: any,
|
||||
data?: Recordable<any>,
|
||||
) => Element | JSX.Element | number | string | undefined | VNode; // 自定义需要展示的内容
|
||||
}
|
||||
|
||||
export interface DescriptionProps extends ElDescriptionProps {
|
||||
schema: DescriptionItemSchema[]; // 描述项配置
|
||||
data: Recordable<any>; // 数据
|
||||
}
|
||||
|
||||
export interface DescInstance {
|
||||
setDescProps(descProps: Partial<DescriptionProps>): void;
|
||||
}
|
||||
31
apps/web-ele/src/components/description/use-description.ts
Normal file
31
apps/web-ele/src/components/description/use-description.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { DescInstance, DescriptionProps } from './typing';
|
||||
|
||||
import { h, reactive } from 'vue';
|
||||
|
||||
import Description from './description.vue';
|
||||
|
||||
export function useDescription(options?: Partial<DescriptionProps>) {
|
||||
const propsState = reactive<Partial<DescriptionProps>>(options || {});
|
||||
|
||||
const api: DescInstance = {
|
||||
setDescProps: (descProps: Partial<DescriptionProps>): void => {
|
||||
Object.assign(propsState, descProps);
|
||||
},
|
||||
};
|
||||
|
||||
// 创建一个包装组件,将 propsState 合并到 props 中
|
||||
const DescriptionWrapper: Component = {
|
||||
name: 'UseDescription',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => {
|
||||
// @ts-ignore - 避免类型实例化过深
|
||||
return h(Description, { ...propsState, ...attrs }, slots);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [DescriptionWrapper, api] as const;
|
||||
}
|
||||
75
apps/web-ele/src/components/dict-tag/dict-tag.vue
Normal file
75
apps/web-ele/src/components/dict-tag/dict-tag.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from '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 @xingyu:这个要适配掉 ele 版本里么?
|
||||
import { getDictObj } from '@vben/hooks';
|
||||
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
interface DictTagProps {
|
||||
type: string; // 字典类型
|
||||
value: any; // 字典值
|
||||
icon?: string; // 图标
|
||||
}
|
||||
|
||||
const props = defineProps<DictTagProps>();
|
||||
|
||||
/** 获取字典标签 */
|
||||
const dictTag = computed(() => {
|
||||
const defaultDict = {
|
||||
label: '',
|
||||
colorType: 'primary',
|
||||
};
|
||||
// 校验参数有效性
|
||||
if (!props.type || props.value === undefined || props.value === null) {
|
||||
return defaultDict;
|
||||
}
|
||||
|
||||
// 获取字典对象
|
||||
const dict = getDictObj(props.type, String(props.value));
|
||||
if (!dict) {
|
||||
return defaultDict;
|
||||
}
|
||||
|
||||
// 处理颜色类型
|
||||
let colorType = dict.colorType;
|
||||
switch (colorType) {
|
||||
case 'danger': {
|
||||
colorType = 'danger';
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
colorType = 'info';
|
||||
break;
|
||||
}
|
||||
case 'primary': {
|
||||
colorType = 'primary';
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
colorType = 'success';
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
colorType = 'warning';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (!colorType) {
|
||||
colorType = 'primary';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: dict.label || '',
|
||||
colorType,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag v-if="dictTag.label" :type="dictTag.colorType as any">
|
||||
{{ dictTag.label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
1
apps/web-ele/src/components/dict-tag/index.ts
Normal file
1
apps/web-ele/src/components/dict-tag/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DictTag } from './dict-tag.vue';
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- 数据字典 Select 选择器 -->
|
||||
<script lang="ts" setup>
|
||||
import type { DictSelectProps } from '../typing';
|
||||
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElOption,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const props = withDefaults(defineProps<DictSelectProps>(), {
|
||||
valueType: 'str',
|
||||
selectType: 'select',
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
/** 获得字典配置 */
|
||||
const getDictOption = computed(() => {
|
||||
switch (props.valueType) {
|
||||
case 'bool': {
|
||||
return getDictOptions(props.dictType, 'boolean');
|
||||
}
|
||||
case 'int': {
|
||||
return getDictOptions(props.dictType);
|
||||
}
|
||||
case 'str': {
|
||||
return getDictOptions(props.dictType);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect v-if="selectType === 'select'" class="w-1/1" v-bind="attrs">
|
||||
<ElOption
|
||||
v-for="(dict, index) in getDictOption"
|
||||
:key="index"
|
||||
:value="dict.value"
|
||||
:label="dict.label"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElRadioGroup v-if="selectType === 'radio'" class="w-1/1" v-bind="attrs">
|
||||
<ElRadio
|
||||
v-for="(dict, index) in getDictOption"
|
||||
:key="index"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<ElCheckboxGroup
|
||||
v-if="selectType === 'checkbox'"
|
||||
class="w-1/1"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="(dict, index) in getDictOption"
|
||||
:key="index"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { ApiSelectProps } from '#/components/form-create/typing';
|
||||
|
||||
import { defineComponent, onMounted, ref, useAttrs } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElOption,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export function 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',
|
||||
},
|
||||
// 返回值类型(用于部门选择器等):id 返回 ID,name 返回名称
|
||||
returnType: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
},
|
||||
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) => {
|
||||
const label = parseExpression(item, props.labelField);
|
||||
let value = parseExpression(item, props.valueField);
|
||||
|
||||
// 根据 returnType 决定返回值
|
||||
// 如果设置了 returnType 为 'name',则返回 label 作为 value
|
||||
if (props.returnType === 'name') {
|
||||
value = label;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
};
|
||||
});
|
||||
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 (
|
||||
<ElSelect
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
multiple
|
||||
{...attrs}
|
||||
// 远程搜索
|
||||
filterable={props.remote}
|
||||
remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElOption key={index} label={item.label} value={item.value} />
|
||||
),
|
||||
)}
|
||||
</ElSelect>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ElSelect
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
{...attrs}
|
||||
// 远程搜索
|
||||
filterable={props.remote}
|
||||
remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElOption key={index} label={item.label} value={item.value} />
|
||||
),
|
||||
)}
|
||||
</ElSelect>
|
||||
);
|
||||
};
|
||||
const buildCheckbox = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<ElCheckboxGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElCheckbox key={index} label={item.value}>
|
||||
{item.label}
|
||||
</ElCheckbox>
|
||||
),
|
||||
)}
|
||||
</ElCheckboxGroup>
|
||||
);
|
||||
};
|
||||
const buildRadio = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<ElRadioGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElRadio key={index} label={item.value}>
|
||||
{item.label}
|
||||
</ElRadio>
|
||||
),
|
||||
)}
|
||||
</ElRadioGroup>
|
||||
);
|
||||
};
|
||||
return () => (
|
||||
<>
|
||||
{(() => {
|
||||
switch (props.selectType) {
|
||||
case 'checkbox': {
|
||||
return buildCheckbox();
|
||||
}
|
||||
case 'radio': {
|
||||
return buildRadio();
|
||||
}
|
||||
case 'select': {
|
||||
return buildSelect();
|
||||
}
|
||||
default: {
|
||||
return buildSelect();
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import ImageUpload from '#/components/upload/image-upload.vue';
|
||||
|
||||
export function useImagesUpload() {
|
||||
return defineComponent({
|
||||
name: 'ImagesUpload',
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maxNumber: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
// TODO: @puhui999:@dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递
|
||||
return (props: { maxNumber?: number; multiple?: boolean }) => (
|
||||
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
253
apps/web-ele/src/components/form-create/helpers.ts
Normal file
253
apps/web-ele/src/components/form-create/helpers.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { Rule } from '@form-create/element-ui';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Menu } from '#/components/form-create/typing';
|
||||
|
||||
import { isRef, nextTick, onMounted } from 'vue';
|
||||
|
||||
import formCreate from '@form-create/element-ui';
|
||||
|
||||
import { apiSelectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
import {
|
||||
useDictSelectRule,
|
||||
useEditorRule,
|
||||
useSelectRule,
|
||||
useUploadFileRule,
|
||||
useUploadImageRule,
|
||||
useUploadImagesRule,
|
||||
} from './rules';
|
||||
|
||||
/** 编码表单 Conf */
|
||||
export function encodeConf(designerRef: any) {
|
||||
// 关联案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/pulls/834/
|
||||
return formCreate.toJson(designerRef.value.getOption());
|
||||
}
|
||||
|
||||
/** 解码表单 Conf */
|
||||
export function decodeConf(conf: string) {
|
||||
return formCreate.parseJson(conf);
|
||||
}
|
||||
|
||||
/** 编码表单 Fields */
|
||||
export function encodeFields(designerRef: any) {
|
||||
const rule = designerRef.value.getRule();
|
||||
const fields: string[] = [];
|
||||
rule.forEach((item: any) => {
|
||||
fields.push(formCreate.toJson(item));
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** 解码表单 Fields */
|
||||
export function decodeFields(fields: string[]) {
|
||||
const rule: Rule[] = [];
|
||||
fields.forEach((item) => {
|
||||
rule.push(formCreate.parseJson(item));
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** 设置表单的 Conf 和 Fields,适用 FcDesigner 场景 */
|
||||
export function setConfAndFields(
|
||||
designerRef: any,
|
||||
conf: string,
|
||||
fields: string | string[],
|
||||
) {
|
||||
designerRef.value.setOption(decodeConf(conf));
|
||||
// 处理 fields 参数类型,确保传入 decodeFields 的是 string[] 类型
|
||||
const fieldsArray = Array.isArray(fields) ? fields : [fields];
|
||||
designerRef.value.setRule(decodeFields(fieldsArray));
|
||||
}
|
||||
|
||||
/** 设置表单的 Conf 和 Fields,适用 form-create 场景 */
|
||||
export function setConfAndFields2(
|
||||
detailPreview: any,
|
||||
conf: string,
|
||||
fields: string[],
|
||||
value?: any,
|
||||
) {
|
||||
if (isRef(detailPreview)) {
|
||||
detailPreview = detailPreview.value;
|
||||
}
|
||||
detailPreview.option = decodeConf(conf);
|
||||
detailPreview.rule = decodeFields(fields);
|
||||
if (value) {
|
||||
detailPreview.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeRequiredRule() {
|
||||
return {
|
||||
type: 'Required',
|
||||
field: 'formCreate$required',
|
||||
title: '是否必填',
|
||||
};
|
||||
}
|
||||
|
||||
export function 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 function 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 async function useFormCreateDesigner(designer: Ref) {
|
||||
const editorRule = useEditorRule();
|
||||
const uploadFileRule = useUploadFileRule();
|
||||
const uploadImageRule = useUploadImageRule();
|
||||
const uploadImagesRule = useUploadImagesRule();
|
||||
|
||||
/** 构建表单组件 */
|
||||
function 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',
|
||||
props: [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'returnType',
|
||||
title: '返回值类型',
|
||||
value: 'id',
|
||||
options: [
|
||||
{ label: '部门编号', value: 'id' },
|
||||
{ label: '部门名称', value: 'name' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dictSelectRule = useDictSelectRule();
|
||||
const apiSelectRule0 = useSelectRule({
|
||||
name: 'ApiSelect',
|
||||
label: '接口选择器',
|
||||
icon: 'icon-json',
|
||||
props: [...apiSelectRule],
|
||||
event: ['click', 'change', 'visibleChange', 'clear', 'blur', 'focus'],
|
||||
});
|
||||
|
||||
/** 构建系统字段菜单 */
|
||||
function buildSystemMenu() {
|
||||
// 移除自带的下拉选择器组件,使用 currencySelectRule 替代
|
||||
// designer.value?.removeMenuItem('select')
|
||||
// designer.value?.removeMenuItem('radio')
|
||||
// designer.value?.removeMenuItem('checkbox')
|
||||
const components = [
|
||||
userSelectRule,
|
||||
deptSelectRule,
|
||||
dictSelectRule,
|
||||
apiSelectRule0,
|
||||
];
|
||||
const menu: Menu = {
|
||||
name: 'system',
|
||||
title: '系统字段',
|
||||
list: components.map((component) => {
|
||||
// 插入组件规则
|
||||
designer.value?.addComponent(component);
|
||||
// 插入拖拽按钮到 `system` 分类下
|
||||
return {
|
||||
icon: component.icon,
|
||||
name: component.name,
|
||||
label: component.label,
|
||||
};
|
||||
}),
|
||||
};
|
||||
designer.value?.addMenu(menu);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
buildFormComponents();
|
||||
buildSystemMenu();
|
||||
});
|
||||
}
|
||||
3
apps/web-ele/src/components/form-create/index.ts
Normal file
3
apps/web-ele/src/components/form-create/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useApiSelect } from './components/use-api-select';
|
||||
|
||||
export * from './helpers';
|
||||
182
apps/web-ele/src/components/form-create/rules/data.ts
Normal file
182
apps/web-ele/src/components/form-create/rules/data.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
const selectRule = [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'selectType',
|
||||
title: '选择器类型',
|
||||
value: 'select',
|
||||
options: [
|
||||
{ label: '下拉框', value: 'select' },
|
||||
{ label: '单选框', value: 'radio' },
|
||||
{ label: '多选框', value: 'checkbox' },
|
||||
],
|
||||
// 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性
|
||||
control: [
|
||||
{
|
||||
value: 'select',
|
||||
condition: '==',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
'multiple',
|
||||
'clearable',
|
||||
'collapseTags',
|
||||
'multipleLimit',
|
||||
'allowCreate',
|
||||
'filterable',
|
||||
'noMatchText',
|
||||
'remote',
|
||||
'remoteMethod',
|
||||
'reserveKeyword',
|
||||
'defaultFirstOption',
|
||||
'automaticDropdown',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'filterable',
|
||||
title: '是否可搜索',
|
||||
},
|
||||
{ type: 'switch', field: 'multiple', title: '是否多选' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
},
|
||||
{ type: 'switch', field: 'clearable', title: '是否可以清空选项' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'collapseTags',
|
||||
title: '多选时是否将选中值按文字的形式展示',
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'multipleLimit',
|
||||
title: '多选时用户最多可以选择的项目数,为 0 则不限制',
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'autocomplete',
|
||||
title: 'autocomplete 属性',
|
||||
},
|
||||
{ type: 'input', field: 'placeholder', title: '占位符' },
|
||||
{ type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
|
||||
{
|
||||
type: 'input',
|
||||
field: 'noMatchText',
|
||||
title: '搜索条件无匹配时显示的文字',
|
||||
},
|
||||
{ type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'reserveKeyword',
|
||||
title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'defaultFirstOption',
|
||||
title: '在输入框按下回车,选择第一个匹配项',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'popperAppendToBody',
|
||||
title: '是否将弹出框插入至 body 元素',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'automaticDropdown',
|
||||
title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单',
|
||||
},
|
||||
];
|
||||
|
||||
const apiSelectRule = [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'url',
|
||||
title: 'url 地址',
|
||||
props: {
|
||||
placeholder: '/system/user/simple-list',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'method',
|
||||
title: '请求类型',
|
||||
value: 'GET',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
],
|
||||
control: [
|
||||
{
|
||||
value: 'GET',
|
||||
condition: '!=',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'data',
|
||||
title: '请求参数 JSON 格式',
|
||||
props: {
|
||||
autosize: true, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
||||
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, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
||||
rows: { minRows: 2, maxRows: 6 },
|
||||
type: 'textarea',
|
||||
placeholder: `
|
||||
function (data) {
|
||||
console.log(data)
|
||||
return data.list.map(item=> ({label: item.nickname,value: item.id}))
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'remote',
|
||||
info: '是否可搜索',
|
||||
title: '其中的选项是否从服务器远程加载',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'remoteField',
|
||||
title: '请求参数',
|
||||
info: '远程请求时请求携带的参数名称,如:name',
|
||||
},
|
||||
];
|
||||
|
||||
export { apiSelectRule, selectRule };
|
||||
6
apps/web-ele/src/components/form-create/rules/index.ts
Normal file
6
apps/web-ele/src/components/form-create/rules/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useDictSelectRule } from './use-dict-select';
|
||||
export { useEditorRule } from './use-editor-rule';
|
||||
export { useSelectRule } from './use-select-rule';
|
||||
export { useUploadFileRule } from './use-upload-file-rule';
|
||||
export { useUploadImageRule } from './use-upload-image-rule';
|
||||
export { useUploadImagesRule } from './use-upload-images-rule';
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SystemDictTypeApi } from '#/api/system/dict/type';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import { getSimpleDictTypeList } from '#/api/system/dict/type';
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/** 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule */
|
||||
export function useDictSelectRule() {
|
||||
const label = '字典选择器';
|
||||
const name = 'DictSelect';
|
||||
const rules = cloneDeep(selectRule);
|
||||
const dictOptions = ref<{ label: string; value: string }[]>([]); // 字典类型下拉数据
|
||||
onMounted(async () => {
|
||||
const data = await getSimpleDictTypeList();
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
dictOptions.value =
|
||||
data?.map((item: 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,
|
||||
// TODO @puhui999:vben 版本里,这里有个 modelField: 'value', 需要添加么?
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'dictType',
|
||||
title: '字典类型',
|
||||
value: '',
|
||||
options: dictOptions.value,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'valueType',
|
||||
title: '字典值类型',
|
||||
value: 'str',
|
||||
options: [
|
||||
{ label: '数字', value: 'int' },
|
||||
{ label: '字符串', value: 'str' },
|
||||
{ label: '布尔值', value: 'bool' },
|
||||
],
|
||||
},
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export function useEditorRule() {
|
||||
const label = '富文本';
|
||||
const name = 'Tinymce';
|
||||
return {
|
||||
icon: 'icon-editor',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '高度',
|
||||
},
|
||||
{ type: 'switch', field: 'readonly', title: '是否只读' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SelectRuleOption } from '#/components/form-create/typing';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/**
|
||||
* 通用选择器规则 hook
|
||||
*
|
||||
* @param option 规则配置
|
||||
*/
|
||||
export function useSelectRule(option: SelectRuleOption) {
|
||||
const label = option.label;
|
||||
const name = option.name;
|
||||
const rules = cloneDeep(selectRule);
|
||||
return {
|
||||
icon: option.icon,
|
||||
label,
|
||||
name,
|
||||
event: option.event,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
if (!option.props) {
|
||||
option.props = [];
|
||||
}
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
...option.props,
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export function useUploadFileRule() {
|
||||
const label = '文件上传';
|
||||
const name = 'FileUpload';
|
||||
return {
|
||||
icon: 'icon-upload',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '文件类型',
|
||||
value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
|
||||
options: [
|
||||
{ label: 'doc', value: 'doc' },
|
||||
{ label: 'xls', value: 'xls' },
|
||||
{ label: 'ppt', value: 'ppt' },
|
||||
{ label: 'txt', value: 'txt' },
|
||||
{ label: 'pdf', value: 'pdf' },
|
||||
],
|
||||
props: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'autoUpload',
|
||||
title: '是否在选取文件后立即进行上传',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'isShowTip',
|
||||
title: '是否显示提示',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'limit',
|
||||
title: '数量限制',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export function 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: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '组件高度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'width',
|
||||
title: '组件宽度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'borderradius',
|
||||
title: '组件边框圆角',
|
||||
value: '8px',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否显示删除按钮',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'showBtnText',
|
||||
title: '是否显示按钮文字',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export function 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',
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
39
apps/web-ele/src/components/form-create/typing.ts
Normal file
39
apps/web-ele/src/components/form-create/typing.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** 数据字典 Select 选择器组件 Props 类型 */
|
||||
export interface DictSelectProps {
|
||||
dictType: string; // 字典类型
|
||||
valueType?: 'bool' | 'int' | 'str'; // 字典值类型
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 通用 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[]; // 事件配置
|
||||
}
|
||||
3
apps/web-ele/src/components/markdown-view/index.ts
Normal file
3
apps/web-ele/src/components/markdown-view/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MarkdownView } from './markdown-view.vue';
|
||||
|
||||
export * from './typing';
|
||||
206
apps/web-ele/src/components/markdown-view/markdown-view.vue
Normal file
206
apps/web-ele/src/components/markdown-view/markdown-view.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import type { MarkdownViewProps } from './typing';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { MarkdownIt } from '@vben/plugins/markmap';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
import 'highlight.js/styles/vs2015.min.css';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps<MarkdownViewProps>();
|
||||
|
||||
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const md = new MarkdownIt({
|
||||
highlight(str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`;
|
||||
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
|
||||
} catch {}
|
||||
}
|
||||
return ``;
|
||||
},
|
||||
});
|
||||
|
||||
/** 渲染 markdown */
|
||||
const renderedMarkdown = computed(() => {
|
||||
return md.render(props.content);
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 添加 copy 监听
|
||||
contentRef.value?.addEventListener('click', (e: any) => {
|
||||
if (e.target.id === 'copy') {
|
||||
copy(e.target?.dataset?.copy);
|
||||
ElMessage.success('复制成功!');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-view {
|
||||
max-width: 100%;
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6rem;
|
||||
color: #3b3e55;
|
||||
text-align: left;
|
||||
letter-spacing: 0;
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
width: auto;
|
||||
padding-top: 20px;
|
||||
border-radius: 6px;
|
||||
|
||||
@media screen and (min-width: 1536px) {
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1536px) and (min-width: 1024px) {
|
||||
width: calc(100vw - 400px - 64px - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) and (min-width: 768px) {
|
||||
width: calc(100vw - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
width: calc(100vw - 16px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
p,
|
||||
code.hljs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
//margin-bottom: 1rem !important;
|
||||
margin: 0;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* 标题通用格式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 24px 0 8px;
|
||||
font-weight: 600;
|
||||
color: #3b3e55;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* 列表(有序,无序) */
|
||||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-CG600);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0 0 20px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol > li {
|
||||
margin-bottom: 1rem;
|
||||
list-style-type: decimal;
|
||||
// 表达式,修复有序列表序号展示不全的问题
|
||||
// &:nth-child(n + 10) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
|
||||
// &:nth-child(n + 100) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
}
|
||||
|
||||
ul > li {
|
||||
margin-right: 11px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-G900);
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol ul,
|
||||
ol ul > li,
|
||||
ul ul,
|
||||
ul ul li {
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 6px;
|
||||
// list-style: circle;
|
||||
font-size: 16px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul ul ul,
|
||||
ul ul ul li,
|
||||
ol ol,
|
||||
ol ol > li,
|
||||
ol ul ul,
|
||||
ol ul ul > li,
|
||||
ul ol,
|
||||
ul ol > li {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/web-ele/src/components/markdown-view/typing.ts
Normal file
3
apps/web-ele/src/components/markdown-view/typing.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type MarkdownViewProps = {
|
||||
content: string;
|
||||
};
|
||||
3
apps/web-ele/src/components/operate-log/index.ts
Normal file
3
apps/web-ele/src/components/operate-log/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as OperateLog } from './operate-log.vue';
|
||||
|
||||
export type { OperateLogProps } from './typing';
|
||||
50
apps/web-ele/src/components/operate-log/operate-log.vue
Normal file
50
apps/web-ele/src/components/operate-log/operate-log.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { OperateLogProps } from './typing';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel, getDictObj } from '@vben/hooks';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { ElTag, ElTimeline, ElTimelineItem } from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'OperateLogV2' });
|
||||
|
||||
withDefaults(defineProps<OperateLogProps>(), {
|
||||
logList: () => [],
|
||||
});
|
||||
|
||||
function getUserTypeColor(userType: number) {
|
||||
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
||||
if (dict && dict.colorType) {
|
||||
return `hsl(var(--${dict.colorType}))`;
|
||||
}
|
||||
return 'hsl(var(--primary))';
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<ElTimeline>
|
||||
<ElTimelineItem
|
||||
v-for="log in logList"
|
||||
:key="log.id"
|
||||
:color="getUserTypeColor(log.userType)"
|
||||
>
|
||||
<template #dot>
|
||||
<p
|
||||
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
|
||||
class="absolute left-1 top-0 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white"
|
||||
>
|
||||
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
|
||||
</p>
|
||||
</template>
|
||||
<p class="ml-2">{{ formatDateTime(log.createTime) }}</p>
|
||||
<p class="ml-2 mt-2">
|
||||
<ElTag :color="getUserTypeColor(log.userType)">
|
||||
{{ log.userName }}
|
||||
</ElTag>
|
||||
{{ log.action }}
|
||||
</p>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</div>
|
||||
</template>
|
||||
5
apps/web-ele/src/components/operate-log/typing.ts
Normal file
5
apps/web-ele/src/components/operate-log/typing.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { SystemOperateLogApi } from '#/api/system/operate-log';
|
||||
|
||||
export interface OperateLogProps {
|
||||
logList: SystemOperateLogApi.OperateLog[]; // 操作日志列表
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ShortcutDateRangePicker } from './shortcut-date-range-picker.vue';
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { ElDatePicker, ElRadio, ElRadioGroup } from 'element-plus';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils/rangePickerProps';
|
||||
|
||||
/** 快捷日期范围选择组件 */
|
||||
defineOptions({ name: 'ShortcutDateRangePicker' });
|
||||
|
||||
const emits = defineEmits<{
|
||||
change: [times: [Dayjs, Dayjs]];
|
||||
}>();
|
||||
|
||||
const times = ref<[Dayjs, Dayjs]>(); // 日期范围
|
||||
|
||||
const rangePickerProps = getRangePickerDefaultProps();
|
||||
|
||||
const timeRangeOptions = [
|
||||
{
|
||||
label: '昨天',
|
||||
value: () => [
|
||||
dayjs().subtract(1, 'day').startOf('day'),
|
||||
dayjs().subtract(1, 'day').endOf('day'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '最近 7 天',
|
||||
value: () => [
|
||||
dayjs().subtract(7, 'day').startOf('day'),
|
||||
dayjs().endOf('day'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '最近 30 天',
|
||||
value: () => [
|
||||
dayjs().subtract(30, 'day').startOf('day'),
|
||||
dayjs().endOf('day'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const timeRangeType = ref(timeRangeOptions[1]!.label); // 默认选中"最近 7 天"
|
||||
|
||||
/** 设置时间范围 */
|
||||
function setTimes() {
|
||||
// 根据选中的选项设置时间范围
|
||||
const selectedOption = timeRangeOptions.find(
|
||||
(option) => option.label === timeRangeType.value,
|
||||
);
|
||||
if (selectedOption) {
|
||||
times.value = selectedOption.value() as [Dayjs, Dayjs];
|
||||
}
|
||||
}
|
||||
|
||||
/** 快捷日期单选按钮选中 */
|
||||
async function handleShortcutDaysChange() {
|
||||
// 设置时间范围
|
||||
setTimes();
|
||||
// 触发时间范围选中事件
|
||||
emitDateRangePicker();
|
||||
}
|
||||
|
||||
/** 日期范围改变 */
|
||||
function handleDateRangeChange() {
|
||||
emitDateRangePicker();
|
||||
}
|
||||
|
||||
/** 触发时间范围选中事件 */
|
||||
function emitDateRangePicker() {
|
||||
if (times.value && times.value.length === 2) {
|
||||
emits('change', times.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
handleShortcutDaysChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElRadioGroup v-model="timeRangeType" @change="handleShortcutDaysChange">
|
||||
<ElRadio
|
||||
v-for="option in timeRangeOptions"
|
||||
:key="option.label"
|
||||
:value="option.label"
|
||||
>
|
||||
{{ option.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<ElDatePicker
|
||||
v-model="times as any"
|
||||
type="daterange"
|
||||
:shortcuts="rangePickerProps.shortcuts"
|
||||
:format="rangePickerProps.format"
|
||||
:value-format="rangePickerProps.valueFormat"
|
||||
:start-placeholder="rangePickerProps.startPlaceholder"
|
||||
:end-placeholder="rangePickerProps.endPlaceholder"
|
||||
:default-time="rangePickerProps.defaultTime as any"
|
||||
class="!w-[215px]"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
16
apps/web-ele/src/components/table-action/icons.ts
Normal file
16
apps/web-ele/src/components/table-action/icons.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const ACTION_ICON = {
|
||||
DOWNLOAD: 'lucide:download',
|
||||
UPLOAD: 'lucide:upload',
|
||||
ADD: 'lucide:plus',
|
||||
EDIT: 'lucide:edit',
|
||||
DELETE: 'lucide:trash-2',
|
||||
REFRESH: 'lucide:refresh-cw',
|
||||
SEARCH: 'lucide:search',
|
||||
FILTER: 'lucide:filter',
|
||||
MORE: 'lucide:ellipsis-vertical',
|
||||
VIEW: 'lucide:eye',
|
||||
COPY: 'lucide:copy',
|
||||
CLOSE: 'lucide:x',
|
||||
BOOK: 'lucide:book',
|
||||
AUDIT: 'lucide:file-check',
|
||||
};
|
||||
4
apps/web-ele/src/components/table-action/index.ts
Normal file
4
apps/web-ele/src/components/table-action/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './icons';
|
||||
|
||||
export { default as TableAction } from './table-action.vue';
|
||||
export * from './typing';
|
||||
294
apps/web-ele/src/components/table-action/table-action.vue
Normal file
294
apps/web-ele/src/components/table-action/table-action.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<!-- add by 星语:参考 vben2 的方式,增加 TableAction 组件 -->
|
||||
<script setup lang="ts">
|
||||
// TODO @xingyu:要不要和 apps/web-antd/src/components/table-action/table-action.vue 代码风格,进一步风格对齐?现在每个方法,会有一些差异
|
||||
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 {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElPopconfirm,
|
||||
ElSpace,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
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);
|
||||
}
|
||||
if (isIfShow) {
|
||||
isIfShow =
|
||||
hasAccessByCodes(action.auth || []) || (action.auth || []).length === 0;
|
||||
}
|
||||
return isIfShow;
|
||||
}
|
||||
|
||||
/** 处理按钮 actions */
|
||||
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 {
|
||||
type: action.type || 'primary',
|
||||
...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(command: any) {
|
||||
const action = getDropdownList.value[command];
|
||||
if (action.onClick && isFunction(action.onClick)) {
|
||||
action.onClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-actions">
|
||||
<ElSpace
|
||||
:size="
|
||||
getActions?.some((item: ActionItem) => item.type === 'text') ? 0 : 8
|
||||
"
|
||||
>
|
||||
<template v-for="(action, index) in getActions" :key="index">
|
||||
<ElPopconfirm
|
||||
v-if="action.popConfirm"
|
||||
v-bind="getPopConfirmProps(action.popConfirm)"
|
||||
>
|
||||
<template v-if="action.popConfirm.icon" #icon>
|
||||
<IconifyIcon :icon="action.popConfirm.icon" />
|
||||
</template>
|
||||
<template #reference>
|
||||
<ElTooltip
|
||||
v-if="
|
||||
action.tooltip &&
|
||||
((typeof action.tooltip === 'string' && action.tooltip) ||
|
||||
(typeof action.tooltip === 'object' &&
|
||||
action.tooltip.content))
|
||||
"
|
||||
v-bind="
|
||||
typeof action.tooltip === 'string'
|
||||
? { content: action.tooltip }
|
||||
: { ...action.tooltip }
|
||||
"
|
||||
>
|
||||
<ElButton v-bind="getButtonProps(action)">
|
||||
<template v-if="action.icon">
|
||||
<IconifyIcon :icon="action.icon" class="mr-1" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElButton v-else v-bind="getButtonProps(action)">
|
||||
<template v-if="action.icon">
|
||||
<IconifyIcon :icon="action.icon" class="mr-1" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
<ElTooltip
|
||||
v-else-if="
|
||||
action.tooltip &&
|
||||
((typeof action.tooltip === 'string' && action.tooltip) ||
|
||||
(typeof action.tooltip === 'object' && action.tooltip.content))
|
||||
"
|
||||
v-bind="
|
||||
typeof action.tooltip === 'string'
|
||||
? { content: action.tooltip }
|
||||
: { ...action.tooltip }
|
||||
"
|
||||
>
|
||||
<ElButton v-bind="getButtonProps(action)" @click="action.onClick">
|
||||
<template v-if="action.icon">
|
||||
<IconifyIcon :icon="action.icon" class="mr-1" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElButton
|
||||
v-else
|
||||
v-bind="getButtonProps(action)"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<template v-if="action.icon">
|
||||
<IconifyIcon :icon="action.icon" class="mr-1" />
|
||||
</template>
|
||||
{{ action.label }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElSpace>
|
||||
|
||||
<ElDropdown v-if="getDropdownList.length > 0" @command="handleMenuClick">
|
||||
<slot name="more">
|
||||
<ElButton :type="getDropdownList[0].type" link>
|
||||
{{ $t('page.action.more') }}
|
||||
<IconifyIcon icon="lucide:ellipsis-vertical" class="ml-1" />
|
||||
</ElButton>
|
||||
</slot>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="(action, index) in getDropdownList"
|
||||
:key="index"
|
||||
:command="index"
|
||||
:disabled="action.disabled"
|
||||
>
|
||||
<template v-if="action.popConfirm">
|
||||
<ElPopconfirm v-bind="getPopConfirmProps(action.popConfirm)">
|
||||
<template v-if="action.popConfirm.icon" #icon>
|
||||
<IconifyIcon :icon="action.popConfirm.icon" />
|
||||
</template>
|
||||
<template #reference>
|
||||
<div>
|
||||
<IconifyIcon v-if="action.icon" :icon="action.icon" />
|
||||
<span :class="action.icon ? 'ml-1' : ''">
|
||||
{{ action.text }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<IconifyIcon v-if="action.icon" :icon="action.icon" />
|
||||
{{ action.label }}
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.table-actions {
|
||||
.el-button--text {
|
||||
padding: 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.el-button .iconify + span,
|
||||
.el-button 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;
|
||||
}
|
||||
}
|
||||
|
||||
.el-popconfirm {
|
||||
.el-popconfirm__action {
|
||||
.el-button {
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
apps/web-ele/src/components/table-action/typing.ts
Normal file
36
apps/web-ele/src/components/table-action/typing.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ButtonProps } from 'element-plus';
|
||||
|
||||
export type ButtonType =
|
||||
| 'danger'
|
||||
| 'default'
|
||||
| 'info'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'text'
|
||||
| 'warning';
|
||||
|
||||
export interface PopConfirm {
|
||||
title: string;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
confirm: () => void;
|
||||
cancel?: () => void;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionItem extends Partial<ButtonProps> {
|
||||
onClick?: () => void;
|
||||
type?: ButtonType;
|
||||
label?: string;
|
||||
color?: 'error' | 'success' | 'warning';
|
||||
icon?: string;
|
||||
popConfirm?: PopConfirm;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
// 权限编码控制是否显示
|
||||
auth?: string[];
|
||||
// 业务控制是否显示
|
||||
ifShow?: ((action: ActionItem) => boolean) | boolean;
|
||||
tooltip?: string | { [key: string]: any; content?: string };
|
||||
}
|
||||
344
apps/web-ele/src/components/tinymce/editor.vue
Normal file
344
apps/web-ele/src/components/tinymce/editor.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<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 {
|
||||
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 = withDefaults(defineProps<TinymacProps>(), {
|
||||
height: 400,
|
||||
width: 'auto',
|
||||
options: () => ({}),
|
||||
plugins: defaultPlugins,
|
||||
toolbar: defaultToolbar,
|
||||
showImageUpload: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
interface TinymacProps {
|
||||
options?: Partial<InitOptions>;
|
||||
toolbar?: string;
|
||||
plugins?: string;
|
||||
height?: number | string;
|
||||
width?: number | string;
|
||||
showImageUpload?: boolean;
|
||||
}
|
||||
|
||||
/** 外部使用 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: any) => {
|
||||
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: EditorType) => {
|
||||
editorRef.value = editor;
|
||||
editor.on('init', (e: any) => initSetup(e));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** 监听 options.readonly 是否只读 */
|
||||
const disabled = computed(() => props.options.readonly ?? false);
|
||||
watch(
|
||||
() => props.options,
|
||||
(options) => {
|
||||
const getDisabled = options && Reflect.get(options, 'readonly');
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
editor.mode.set(getDisabled ? 'readonly' : 'design');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!initOptions.value.inline) {
|
||||
tinymceId.value = buildShortUUID('tiny-vue');
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initEditor();
|
||||
setEditorMode();
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
setEditorMode();
|
||||
});
|
||||
|
||||
function setEditorMode() {
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
const mode = props.options.readonly ? 'readonly' : 'design';
|
||||
editor.mode.set(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
const editor = unref(editorRef);
|
||||
editor?.destroy();
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const el = unref(elRef);
|
||||
if (el) {
|
||||
el.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
function initSetup(e: any) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const value = modelValue.value || '';
|
||||
|
||||
editor.setContent(value);
|
||||
bindModelHandlers(editor);
|
||||
bindHandlers(e, attrs, unref(editorRef));
|
||||
}
|
||||
|
||||
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
|
||||
if (
|
||||
editor &&
|
||||
typeof val === 'string' &&
|
||||
val !== prevVal &&
|
||||
val !== editor.getContent({ format: attrs.outputFormat })
|
||||
) {
|
||||
editor.setContent(val);
|
||||
}
|
||||
}
|
||||
|
||||
function bindModelHandlers(editor: any) {
|
||||
const modelEvents = attrs.modelEvents ?? null;
|
||||
const normalizedEvents = Array.isArray(modelEvents)
|
||||
? modelEvents.join(' ')
|
||||
: modelEvents;
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(val, prevVal) => {
|
||||
setValue(editor, val, prevVal);
|
||||
},
|
||||
);
|
||||
|
||||
editor.on(normalizedEvents || 'change keyup undo redo', () => {
|
||||
const content = editor.getContent({ format: attrs.outputFormat });
|
||||
emit('change', content);
|
||||
});
|
||||
|
||||
editor.on('FullscreenStateChanged', (e: any) => {
|
||||
fullscreen.value = e.state;
|
||||
});
|
||||
}
|
||||
|
||||
function getUploadingImgName(name: string) {
|
||||
return `[uploading:${name}]`;
|
||||
}
|
||||
|
||||
function handleImageUploading(name: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
|
||||
const content = editor?.getContent() ?? '';
|
||||
setValue(editor, content);
|
||||
}
|
||||
|
||||
function handleDone(name: string, url: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const content = editor?.getContent() ?? '';
|
||||
const val =
|
||||
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
|
||||
setValue(editor, val);
|
||||
}
|
||||
|
||||
function handleError(name: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const content = editor?.getContent() ?? '';
|
||||
const val = content?.replace(getUploadingImgName(name), '') ?? '';
|
||||
setValue(editor, val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ width: containerWidth }" class="app-tinymce">
|
||||
<ImgUpload
|
||||
v-if="showImageUpload"
|
||||
v-show="editorRef"
|
||||
:disabled="disabled"
|
||||
:fullscreen="fullscreen"
|
||||
@done="handleDone"
|
||||
@error="handleError"
|
||||
@uploading="handleImageUploading"
|
||||
/>
|
||||
<Editor
|
||||
v-if="!initOptions.inline && init"
|
||||
v-model="modelValue"
|
||||
:init="initOptions"
|
||||
:style="{ visibility: 'hidden', zIndex: 3000 }"
|
||||
:tinymce-script-src="tinymceScriptSrc"
|
||||
license-key="gpl"
|
||||
/>
|
||||
<slot v-else></slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.tox.tox-silver-sink.tox-tinymce-aux {
|
||||
z-index: 2025; /* 由于 vben modal/drawer 的 zIndex 为 2000,需要调整 z-index(默认 1300)超过它,避免遮挡 */
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-tinymce {
|
||||
position: relative;
|
||||
line-height: normal;
|
||||
|
||||
:deep(.textarea) {
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏右上角 tinymce upgrade 按钮 */
|
||||
:deep(.tox-promotion) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
85
apps/web-ele/src/components/tinymce/helper.ts
Normal file
85
apps/web-ele/src/components/tinymce/helper.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const validEvents = new Set([
|
||||
'onActivate',
|
||||
'onAddUndo',
|
||||
'onBeforeAddUndo',
|
||||
'onBeforeExecCommand',
|
||||
'onBeforeGetContent',
|
||||
'onBeforePaste',
|
||||
'onBeforeRenderUI',
|
||||
'onBeforeSetContent',
|
||||
'onBlur',
|
||||
'onChange',
|
||||
'onClearUndos',
|
||||
'onClick',
|
||||
'onContextMenu',
|
||||
'onCopy',
|
||||
'onCut',
|
||||
'onDblclick',
|
||||
'onDeactivate',
|
||||
'onDirty',
|
||||
'onDrag',
|
||||
'onDragDrop',
|
||||
'onDragEnd',
|
||||
'onDragGesture',
|
||||
'onDragOver',
|
||||
'onDrop',
|
||||
'onExecCommand',
|
||||
'onFocus',
|
||||
'onFocusIn',
|
||||
'onFocusOut',
|
||||
'onGetContent',
|
||||
'onHide',
|
||||
'onInit',
|
||||
'onKeyDown',
|
||||
'onKeyPress',
|
||||
'onKeyUp',
|
||||
'onLoadContent',
|
||||
'onMouseDown',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
'onMouseMove',
|
||||
'onMouseOut',
|
||||
'onMouseOver',
|
||||
'onMouseUp',
|
||||
'onNodeChange',
|
||||
'onObjectResized',
|
||||
'onObjectResizeStart',
|
||||
'onObjectSelected',
|
||||
'onPaste',
|
||||
'onPostProcess',
|
||||
'onPostRender',
|
||||
'onPreProcess',
|
||||
'onProgressState',
|
||||
'onRedo',
|
||||
'onRemove',
|
||||
'onReset',
|
||||
'onSaveContent',
|
||||
'onSelectionChange',
|
||||
'onSetAttrib',
|
||||
'onSetContent',
|
||||
'onShow',
|
||||
'onSubmit',
|
||||
'onUndo',
|
||||
'onVisualAid',
|
||||
]);
|
||||
|
||||
const isValidKey = (key: string) => validEvents.has(key);
|
||||
|
||||
export const bindHandlers = (
|
||||
initEvent: Event,
|
||||
listeners: any,
|
||||
editor: any,
|
||||
): void => {
|
||||
Object.keys(listeners)
|
||||
.filter((element) => isValidKey(element))
|
||||
.forEach((key: string) => {
|
||||
const handler = listeners[key];
|
||||
if (typeof handler === 'function') {
|
||||
if (key === 'onInit') {
|
||||
handler(initEvent, editor);
|
||||
} else {
|
||||
editor.on(key.slice(2), (e: any) => handler(e, editor));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
88
apps/web-ele/src/components/tinymce/img-upload.vue
Normal file
88
apps/web-ele/src/components/tinymce/img-upload.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadRequestOptions } from 'element-plus';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElUpload } from 'element-plus';
|
||||
|
||||
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(options: UploadRequestOptions) {
|
||||
// 1. emit 上传中
|
||||
const file = options.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);
|
||||
// 调用上传成功回调
|
||||
options?.onSuccess(url);
|
||||
} catch (error: any) {
|
||||
emit('error', name);
|
||||
// 调用上传失败回调
|
||||
options?.onError(error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
accept=".jpg,.jpeg,.gif,.png,.webp"
|
||||
multiple
|
||||
:http-request="customRequest"
|
||||
>
|
||||
<ElButton type="primary" v-bind="{ ...getButtonProps }">
|
||||
{{ $t('ui.upload.imgUpload') }}
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tinymce-image-upload {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
|
||||
&.fullscreen {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/web-ele/src/components/tinymce/index.ts
Normal file
1
apps/web-ele/src/components/tinymce/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tinymce } from './editor.vue';
|
||||
17
apps/web-ele/src/components/tinymce/tinymce.ts
Normal file
17
apps/web-ele/src/components/tinymce/tinymce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Any plugins you want to setting has to be imported
|
||||
// Detail plugins list see https://www.tiny.cloud/docs/plugins/
|
||||
// Custom builds see https://www.tiny.cloud/download/custom-builds/
|
||||
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
|
||||
|
||||
export const plugins =
|
||||
'preview importcss searchreplace autolink autosave save directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount help emoticons accordion';
|
||||
|
||||
// 和 vben2.0 不同,从 https://www.tiny.cloud/ 拷贝 Vue 部分,然后去掉 importword exportword exportpdf | math 部分,并额外增加最后一行(来自 vben2.0 差异的部分)
|
||||
export const toolbar =
|
||||
'undo redo | accordion accordionremove | \\\n' +
|
||||
' blocks fontfamily fontsize | bold italic underline strikethrough | \\\n' +
|
||||
' align numlist bullist | link image | table media | \\\n' +
|
||||
' lineheight outdent indent | forecolor backcolor removeformat | \\\n' +
|
||||
' charmap emoticons | code fullscreen preview | save print | \\\n' +
|
||||
' pagebreak anchor codesample | ltr rtl | \\\n' +
|
||||
' hr searchreplace alignleft aligncenter alignright blockquote subscript superscript';
|
||||
284
apps/web-ele/src/components/upload/file-upload.vue
Normal file
284
apps/web-ele/src/components/upload/file-upload.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO @puhui999:这个看看怎么和对应的 antd 【代码风格】,保持一致一些;
|
||||
import type {
|
||||
UploadFile,
|
||||
UploadInstance,
|
||||
UploadProps,
|
||||
UploadRawFile,
|
||||
UploadRequestOptions,
|
||||
UploadUserFile,
|
||||
} from 'element-plus';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isString } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElLink, ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import { useUpload } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
autoUpload?: boolean;
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
drag?: boolean;
|
||||
fileSize?: number;
|
||||
fileType?: string[];
|
||||
isShowTip?: boolean;
|
||||
limit?: number;
|
||||
modelValue: string | string[];
|
||||
}>(),
|
||||
{
|
||||
fileType: () => ['doc', 'xls', 'ppt', 'txt', 'pdf'], // 文件类型, 例如['png', 'jpg', 'jpeg']
|
||||
fileSize: 5, // 大小限制(MB)
|
||||
limit: 5, // 数量限制
|
||||
autoUpload: true, // 自动上传
|
||||
drag: false, // 拖拽上传
|
||||
isShowTip: true, // 是否显示提示
|
||||
disabled: false, // 是否禁用上传组件 ==> 非必传(默认为 false)
|
||||
directory: undefined, // 上传目录 ==> 非必传(默认为 undefined)
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
// ========== 上传相关 ==========
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const uploadList = ref<UploadUserFile[]>([]);
|
||||
const fileList = ref<UploadUserFile[]>([]);
|
||||
const uploadNumber = ref<number>(0);
|
||||
|
||||
const { uploadUrl, httpRequest }: any = useUpload(props.directory);
|
||||
|
||||
/** httpRequest 适配 ele */
|
||||
const httpRequest0 = (options: UploadRequestOptions) => {
|
||||
return httpRequest(options.file);
|
||||
};
|
||||
|
||||
// 文件上传之前判断
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
|
||||
if (fileList.value.length >= props.limit) {
|
||||
ElMessage.error(`上传文件数量不能超过${props.limit}个!`);
|
||||
return false;
|
||||
}
|
||||
let fileExtension = '';
|
||||
// eslint-disable-next-line unicorn/prefer-includes
|
||||
if (file.name.lastIndexOf('.') > -1) {
|
||||
fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1);
|
||||
}
|
||||
const isImg = props.fileType.some((type: string) => {
|
||||
// eslint-disable-next-line unicorn/prefer-includes
|
||||
if (file.type.indexOf(type) > -1) return true;
|
||||
// eslint-disable-next-line unicorn/prefer-includes
|
||||
return !!(fileExtension && fileExtension.indexOf(type) > -1);
|
||||
});
|
||||
const isLimit = file.size < props.fileSize * 1024 * 1024;
|
||||
if (!isImg) {
|
||||
ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式!`);
|
||||
return false;
|
||||
}
|
||||
if (!isLimit) {
|
||||
ElMessage.error(`上传文件大小不能超过${props.fileSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
ElMessage.success('正在上传文件,请稍候...');
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 文件上传成功
|
||||
const handleFileSuccess: UploadProps['onSuccess'] = (url: any): void => {
|
||||
ElMessage.success('上传成功');
|
||||
// 删除自身
|
||||
const index = fileList.value.findIndex((item: any) => item.response === url);
|
||||
fileList.value.splice(index, 1);
|
||||
uploadList.value.push({ name: url, url });
|
||||
if (uploadList.value.length === uploadNumber.value) {
|
||||
fileList.value.push(...uploadList.value);
|
||||
uploadList.value = [];
|
||||
uploadNumber.value = 0;
|
||||
emitUpdateModelValue();
|
||||
}
|
||||
};
|
||||
// 文件数超出提示
|
||||
const handleExceed: UploadProps['onExceed'] = (): void => {
|
||||
ElMessage.error(`上传文件数量不能超过${props.limit}个!`);
|
||||
};
|
||||
// 上传错误提示
|
||||
const excelUploadError: UploadProps['onError'] = (): void => {
|
||||
ElMessage.error('导入数据失败,请您重新上传!');
|
||||
// 上传失败时减少计数器,避免后续上传被阻塞
|
||||
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
|
||||
};
|
||||
// 删除上传文件
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
const index = fileList.value.map((f) => f.name).indexOf(file.name);
|
||||
if (index !== -1) {
|
||||
fileList.value.splice(index, 1);
|
||||
emitUpdateModelValue();
|
||||
}
|
||||
};
|
||||
const handlePreview: UploadProps['onPreview'] = (_) => {
|
||||
// console.log(uploadFile);
|
||||
};
|
||||
|
||||
// 监听模型绑定值变动
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: string | string[]) => {
|
||||
if (!val) {
|
||||
fileList.value = []; // fix:处理掉缓存,表单重置后上传组件的内容并没有重置
|
||||
return;
|
||||
}
|
||||
|
||||
fileList.value = []; // 保障数据为空
|
||||
// 情况1:字符串
|
||||
if (isString(val)) {
|
||||
fileList.value.push(
|
||||
...val.split(',').map((url) => ({
|
||||
// eslint-disable-next-line unicorn/prefer-string-slice
|
||||
name: url.substring(url.lastIndexOf('/') + 1),
|
||||
url,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 情况2:数组
|
||||
fileList.value.push(
|
||||
...(val as string[]).map((url) => ({
|
||||
// eslint-disable-next-line unicorn/prefer-string-slice
|
||||
name: url.substring(url.lastIndexOf('/') + 1),
|
||||
url,
|
||||
})),
|
||||
);
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
// 发送文件链接列表更新
|
||||
const emitUpdateModelValue = () => {
|
||||
// 情况1:数组结果
|
||||
let result: string | string[] = fileList.value.map((file) => file.url!);
|
||||
// 情况2:逗号分隔的字符串
|
||||
if (props.limit === 1 || isString(props.modelValue)) {
|
||||
result = result.join(',');
|
||||
}
|
||||
emit('update:modelValue', result);
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="!disabled" class="upload-file">
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="fileList"
|
||||
:action="uploadUrl"
|
||||
:auto-upload="autoUpload"
|
||||
:before-upload="beforeUpload"
|
||||
:disabled="disabled"
|
||||
:drag="drag"
|
||||
:http-request="httpRequest0"
|
||||
:limit="props.limit"
|
||||
:multiple="props.limit > 1"
|
||||
:on-error="excelUploadError"
|
||||
:on-exceed="handleExceed"
|
||||
:on-preview="handlePreview"
|
||||
:on-remove="handleRemove"
|
||||
:on-success="handleFileSuccess"
|
||||
:show-file-list="true"
|
||||
class="upload-file-uploader"
|
||||
name="file"
|
||||
>
|
||||
<ElButton type="primary">
|
||||
<IconifyIcon icon="ep:upload-filled" />
|
||||
选取文件
|
||||
</ElButton>
|
||||
<template v-if="isShowTip" #tip>
|
||||
<div style="font-size: 8px">
|
||||
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
|
||||
</div>
|
||||
<div style="font-size: 8px">
|
||||
格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
|
||||
</div>
|
||||
</template>
|
||||
<template #file="row">
|
||||
<div class="flex items-center">
|
||||
<span>{{ row.file.name }}</span>
|
||||
<div class="ml-10px">
|
||||
<ElLink
|
||||
:href="row.file.url"
|
||||
:underline="false"
|
||||
download
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
下载
|
||||
</ElLink>
|
||||
</div>
|
||||
<div class="ml-10px">
|
||||
<ElButton link type="danger" @click="handleRemove(row.file)">
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</div>
|
||||
|
||||
<!-- 上传操作禁用时 -->
|
||||
<div v-if="disabled" class="upload-file">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="index"
|
||||
class="file-list-item flex items-center"
|
||||
>
|
||||
<span>{{ file.name }}</span>
|
||||
<div class="ml-10px">
|
||||
<ElLink
|
||||
:href="file.url"
|
||||
:underline="false"
|
||||
download
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
下载
|
||||
</ElLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.upload-file-uploader {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:deep(.upload-file-list .el-upload-list__item) {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
line-height: 2;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
:deep(.el-upload-list__item-file-name) {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
:deep(.upload-file-list .ele-upload-list__item-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:deep(.ele-upload-list__item-content-action .el-link) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
419
apps/web-ele/src/components/upload/image-upload.vue
Normal file
419
apps/web-ele/src/components/upload/image-upload.vue
Normal file
@@ -0,0 +1,419 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
UploadFile,
|
||||
UploadProgressEvent,
|
||||
UploadRequestOptions,
|
||||
} from 'element-plus';
|
||||
|
||||
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 { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
defaultImageAccepts,
|
||||
isFunction,
|
||||
isImage,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@vben/utils';
|
||||
|
||||
import { ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
// TODO @xingyu:这个要不要抽时间看看,upload 组件,和 antd 要不要进一步对齐下;(主要是代码风格。微信沟通~~~)
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 组件边框圆角
|
||||
borderradius?: string;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
// 上传框高度
|
||||
height?: number | string;
|
||||
helpText?: string;
|
||||
listType?: UploadListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
modelValue?: string | string[];
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
// 上传框宽度
|
||||
width?: number | string;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccepts,
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: true,
|
||||
width: '',
|
||||
height: '',
|
||||
borderradius: '8px',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(['change', 'update:modelValue', 'delete']);
|
||||
const { accept, helpText, maxNumber, maxSize, width, height, borderradius } =
|
||||
toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
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.SUCCESS,
|
||||
url: item,
|
||||
} as UploadFile;
|
||||
} else if (item && isObject(item)) {
|
||||
const file = item as Record<string, any>;
|
||||
return {
|
||||
uid: file.uid || -i,
|
||||
name: file.name || '',
|
||||
status: UploadResultStatus.SUCCESS,
|
||||
url: file.url,
|
||||
} as UploadFile;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as UploadFile[];
|
||||
}
|
||||
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) {
|
||||
const preview = await getBase64<string>(file.raw!);
|
||||
window.open(preview || '');
|
||||
return;
|
||||
}
|
||||
window.open(file.url);
|
||||
};
|
||||
|
||||
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:modelValue', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = isImage(file.name, accept);
|
||||
if (!isAct) {
|
||||
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
async function customRequest(options: UploadRequestOptions) {
|
||||
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);
|
||||
options.onProgress!({
|
||||
percent,
|
||||
total: e.total || 0,
|
||||
loaded: e.loaded || 0,
|
||||
lengthComputable: true,
|
||||
} as unknown as UploadProgressEvent);
|
||||
};
|
||||
const res = await api?.(options.file, progressEvent);
|
||||
|
||||
// TODO @xingyu:看看有没更好的实现代码。
|
||||
// 更新 fileList 中对应文件的 URL 为服务器返回的真实 URL
|
||||
const uploadedFile = fileList.value.find(
|
||||
(file) => file.uid === (options.file as any).uid,
|
||||
);
|
||||
if (uploadedFile) {
|
||||
const responseData = res?.data || res;
|
||||
uploadedFile.url =
|
||||
props.resultField && responseData[props.resultField]
|
||||
? responseData[props.resultField]
|
||||
: responseData.url || responseData;
|
||||
}
|
||||
|
||||
options.onSuccess!(res);
|
||||
ElMessage.success($t('ui.upload.uploadSuccess'));
|
||||
|
||||
// 更新文件
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
options.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.response?.url || item?.response;
|
||||
});
|
||||
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
||||
if (props.maxNumber === 1) {
|
||||
return list.length > 0 ? list[0] : '';
|
||||
}
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="upload-box"
|
||||
:style="{
|
||||
width: width || '150px',
|
||||
height: height || '150px',
|
||||
borderRadius: borderradius,
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="
|
||||
fileList.length > 0 &&
|
||||
fileList[0] &&
|
||||
fileList[0].status === UploadResultStatus.SUCCESS
|
||||
"
|
||||
>
|
||||
<div class="upload-image-wrapper">
|
||||
<img :src="fileList[0].url" class="upload-image" />
|
||||
<div class="upload-handle">
|
||||
<div class="handle-icon" @click="handlePreview(fileList[0]!)">
|
||||
<IconifyIcon icon="lucide:circle-plus" />
|
||||
<span>详情</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!disabled"
|
||||
class="handle-icon"
|
||||
@click="handleRemove(fileList[0]!)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElUpload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:limit="maxNumber"
|
||||
:multiple="multiple"
|
||||
:on-preview="handlePreview"
|
||||
:on-remove="handleRemove"
|
||||
class="upload"
|
||||
:style="{
|
||||
width: width || '150px',
|
||||
height: height || '150px',
|
||||
borderRadius: borderradius,
|
||||
}"
|
||||
>
|
||||
<div class="upload-content flex flex-col items-center justify-center">
|
||||
<IconifyIcon icon="lucide:plus" />
|
||||
</div>
|
||||
</ElUpload>
|
||||
</template>
|
||||
<!-- TODO @xingyu:相比 antd 来说,EL 有点丑;貌似是这里展示的位置不太对; -->
|
||||
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
|
||||
{{ getStringAccept }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
.upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
background: transparent;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-image-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.upload-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 8px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
|
||||
span {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-image-wrapper:hover .upload-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/web-ele/src/components/upload/index.ts
Normal file
3
apps/web-ele/src/components/upload/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FileUpload } from './file-upload.vue';
|
||||
export { default as ImageUpload } from './image-upload.vue';
|
||||
export { default as InputUpload } from './input-upload.vue';
|
||||
74
apps/web-ele/src/components/upload/input-upload.vue
Normal file
74
apps/web-ele/src/components/upload/input-upload.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { InputProps } from 'element-plus';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { ElCol, ElInput, ElRow } from 'element-plus';
|
||||
|
||||
import FileUpload from './file-upload.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: number | string;
|
||||
fileUploadProps?: FileUploadProps;
|
||||
inputProps?: InputProps;
|
||||
inputType?: 'input' | 'textarea';
|
||||
modelValue?: number | string;
|
||||
textareaProps?: InputProps;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', payload: number | string): void;
|
||||
(e: 'update:value', payload: number | string): void;
|
||||
(e: 'update:modelValue', payload: number | string): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
defaultValue: props.defaultValue,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
function handleReturnText(text: string) {
|
||||
modelValue.value = text;
|
||||
emits('change', modelValue.value);
|
||||
emits('update:value', modelValue.value);
|
||||
emits('update:modelValue', modelValue.value);
|
||||
}
|
||||
|
||||
const inputProps = computed(() => {
|
||||
return {
|
||||
...props.inputProps,
|
||||
value: modelValue.value,
|
||||
};
|
||||
});
|
||||
|
||||
const textareaProps = computed(() => {
|
||||
return {
|
||||
...props.textareaProps,
|
||||
value: modelValue.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fileUploadProps = computed(() => {
|
||||
return {
|
||||
...props.fileUploadProps,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ElRow>
|
||||
<ElCol :span="18">
|
||||
<ElInput v-if="inputType === 'input'" v-bind="inputProps" />
|
||||
<ElInput v-else :row="4" type="textarea" v-bind="textareaProps" />
|
||||
</ElCol>
|
||||
<ElCol :span="6">
|
||||
<FileUpload
|
||||
class="ml-4"
|
||||
v-bind="fileUploadProps"
|
||||
@return-text="handleReturnText"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
77
apps/web-ele/src/components/upload/typing.ts
Normal file
77
apps/web-ele/src/components/upload/typing.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
export type UploadListType = 'picture' | 'picture-card' | 'text';
|
||||
|
||||
export type UploadStatus =
|
||||
| 'error'
|
||||
| 'fail'
|
||||
| 'removed'
|
||||
| 'success'
|
||||
| 'uploading';
|
||||
|
||||
export enum UploadResultStatus {
|
||||
ERROR = 'error',
|
||||
REMOVED = 'removed',
|
||||
SUCCESS = 'success',
|
||||
UPLOADING = 'uploading',
|
||||
}
|
||||
|
||||
export interface CustomUploadFile {
|
||||
uid: number;
|
||||
name: string;
|
||||
status: UploadStatus;
|
||||
url?: string;
|
||||
response?: any;
|
||||
percentage?: number;
|
||||
size?: number;
|
||||
raw?: File;
|
||||
}
|
||||
|
||||
export function convertToUploadStatus(
|
||||
status: UploadResultStatus,
|
||||
): UploadStatus {
|
||||
switch (status) {
|
||||
case UploadResultStatus.ERROR: {
|
||||
return 'fail';
|
||||
}
|
||||
case UploadResultStatus.REMOVED: {
|
||||
return 'removed';
|
||||
}
|
||||
case UploadResultStatus.SUCCESS: {
|
||||
return 'success';
|
||||
}
|
||||
case UploadResultStatus.UPLOADING: {
|
||||
return 'uploading';
|
||||
}
|
||||
default: {
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
// 根据后缀,或者其他
|
||||
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[];
|
||||
}
|
||||
168
apps/web-ele/src/components/upload/use-upload.ts
Normal file
168
apps/web-ele/src/components/upload/use-upload.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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 { 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 function useUpload(directory?: string) {
|
||||
// 后端上传地址
|
||||
const uploadUrl = getUploadUrl();
|
||||
// 是否使用前端直连上传
|
||||
const isClientUpload =
|
||||
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
|
||||
// 重写ElUpload上传方法
|
||||
async function httpRequest(
|
||||
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 function getUploadUrl(): string {
|
||||
return `${apiURL}/infra/file/upload`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
*
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile0(
|
||||
vo: InfraFileApi.FilePresignedUrlRespVO,
|
||||
file: File,
|
||||
): InfraFileApi.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;
|
||||
}
|
||||
Reference in New Issue
Block a user