This commit is contained in:
2025-12-18 16:37:33 +08:00
commit e974bf361d
4183 changed files with 497339 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as CronTab } from './cron-tab.vue';

View 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 @puhui999html 部分看看有没办法和 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>

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

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

View File

@@ -0,0 +1,3 @@
export { default as CropperAvatar } from './cropper-avatar.vue';
export { default as CropperImage } from './cropper.vue';
export type { CropperType } from './typing';

View File

@@ -0,0 +1,68 @@
import type 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 };

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

View File

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

View 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_emptyrowHeader 前面的那个) */
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>

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

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

@@ -0,0 +1,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;
}

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

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

View File

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

View File

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

View File

@@ -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 返回 IDname 返回名称
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();
}
}
})()}
</>
);
},
});
}

View File

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

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

View File

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

View File

@@ -0,0 +1,182 @@
/* eslint-disable no-template-curly-in-string */
const selectRule = [
{
type: 'select',
field: 'selectType',
title: '选择器类型',
value: 'select',
options: [
{ label: '下拉框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '多选框', value: 'checkbox' },
],
// 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性
control: [
{
value: 'select',
condition: '==',
method: 'hidden',
rule: [
'multiple',
'clearable',
'collapseTags',
'multipleLimit',
'allowCreate',
'filterable',
'noMatchText',
'remote',
'remoteMethod',
'reserveKeyword',
'defaultFirstOption',
'automaticDropdown',
],
},
],
},
{
type: 'switch',
field: 'filterable',
title: '是否可搜索',
},
{ type: 'switch', field: 'multiple', title: '是否多选' },
{
type: 'switch',
field: 'disabled',
title: '是否禁用',
},
{ type: 'switch', field: 'clearable', title: '是否可以清空选项' },
{
type: 'switch',
field: 'collapseTags',
title: '多选时是否将选中值按文字的形式展示',
},
{
type: 'inputNumber',
field: 'multipleLimit',
title: '多选时用户最多可以选择的项目数,为 0 则不限制',
props: { min: 0 },
},
{
type: 'input',
field: 'autocomplete',
title: 'autocomplete 属性',
},
{ type: 'input', field: 'placeholder', title: '占位符' },
{ type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
{
type: 'input',
field: 'noMatchText',
title: '搜索条件无匹配时显示的文字',
},
{ type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
{
type: 'switch',
field: 'reserveKeyword',
title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词',
},
{
type: 'switch',
field: 'defaultFirstOption',
title: '在输入框按下回车,选择第一个匹配项',
},
{
type: 'switch',
field: 'popperAppendToBody',
title: '是否将弹出框插入至 body 元素',
value: true,
},
{
type: 'switch',
field: 'automaticDropdown',
title: '对于不可搜索的 Select是否在输入框获得焦点后自动弹出选项菜单',
},
];
const apiSelectRule = [
{
type: 'input',
field: 'url',
title: 'url 地址',
props: {
placeholder: '/system/user/simple-list',
},
},
{
type: 'select',
field: 'method',
title: '请求类型',
value: 'GET',
options: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
],
control: [
{
value: 'GET',
condition: '!=',
method: 'hidden',
rule: [
{
type: 'input',
field: 'data',
title: '请求参数 JSON 格式',
props: {
autosize: true, // 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 };

View File

@@ -0,0 +1,6 @@
export { useDictSelectRule } from './use-dict-select';
export { useEditorRule } from './use-editor-rule';
export { useSelectRule } from './use-select-rule';
export { useUploadFileRule } from './use-upload-file-rule';
export { useUploadImageRule } from './use-upload-image-rule';
export { useUploadImagesRule } from './use-upload-images-rule';

View File

@@ -0,0 +1,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 @puhui999vben 版本里,这里有个 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,
]);
},
};
}

View File

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

View File

@@ -0,0 +1,45 @@
import type { SelectRuleOption } from '#/components/form-create/typing';
import { buildUUID, cloneDeep } from '@vben/utils';
import {
localeProps,
makeRequiredRule,
} from '#/components/form-create/helpers';
import { selectRule } from '#/components/form-create/rules/data';
/**
* 通用选择器规则 hook
*
* @param option 规则配置
*/
export 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,
]);
},
};
}

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
export { default as MarkdownView } from './markdown-view.vue';
export * from './typing';

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

View File

@@ -0,0 +1,3 @@
export type MarkdownViewProps = {
content: string;
};

View File

@@ -0,0 +1,3 @@
export { default as OperateLog } from './operate-log.vue';
export type { OperateLogProps } from './typing';

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

View File

@@ -0,0 +1,5 @@
import type { SystemOperateLogApi } from '#/api/system/operate-log';
export interface OperateLogProps {
logList: SystemOperateLogApi.OperateLog[]; // 操作日志列表
}

View File

@@ -0,0 +1 @@
export { default as ShortcutDateRangePicker } from './shortcut-date-range-picker.vue';

View File

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

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

View File

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

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

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

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

View File

@@ -0,0 +1,85 @@
const validEvents = new Set([
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforePaste',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBlur',
'onChange',
'onClearUndos',
'onClick',
'onContextMenu',
'onCopy',
'onCut',
'onDblclick',
'onDeactivate',
'onDirty',
'onDrag',
'onDragDrop',
'onDragEnd',
'onDragGesture',
'onDragOver',
'onDrop',
'onExecCommand',
'onFocus',
'onFocusIn',
'onFocusOut',
'onGetContent',
'onHide',
'onInit',
'onKeyDown',
'onKeyPress',
'onKeyUp',
'onLoadContent',
'onMouseDown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onNodeChange',
'onObjectResized',
'onObjectResizeStart',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid',
]);
const isValidKey = (key: string) => validEvents.has(key);
export const bindHandlers = (
initEvent: Event,
listeners: any,
editor: any,
): void => {
Object.keys(listeners)
.filter((element) => isValidKey(element))
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.slice(2), (e: any) => handler(e, editor));
}
}
});
};

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,17 @@
// Any plugins you want to setting has to be imported
// Detail plugins list see https://www.tiny.cloud/docs/plugins/
// Custom builds see https://www.tiny.cloud/download/custom-builds/
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
export const plugins =
'preview importcss searchreplace autolink autosave save directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount help emoticons accordion';
// 和 vben2.0 不同,从 https://www.tiny.cloud/ 拷贝 Vue 部分,然后去掉 importword exportword exportpdf | math 部分,并额外增加最后一行(来自 vben2.0 差异的部分)
export const toolbar =
'undo redo | accordion accordionremove | \\\n' +
' blocks fontfamily fontsize | bold italic underline strikethrough | \\\n' +
' align numlist bullist | link image | table media | \\\n' +
' lineheight outdent indent | forecolor backcolor removeformat | \\\n' +
' charmap emoticons | code fullscreen preview | save print | \\\n' +
' pagebreak anchor codesample | ltr rtl | \\\n' +
' hr searchreplace alignleft aligncenter alignright blockquote subscript superscript';

View File

@@ -0,0 +1,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>

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

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

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

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

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