gengx
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# ECharts Plugin
|
||||
|
||||
ECharts 图表插件,预置常用组件和图表类型。
|
||||
|
||||
## 导出
|
||||
|
||||
| 导出 | 类型 | 说明 |
|
||||
| ------------ | ---- | ------------ |
|
||||
| `default` | 对象 | echarts 实例 |
|
||||
| `EchartsUI` | 组件 | 图表容器组件 |
|
||||
| `ECOption` | 类型 | 图表配置类型 |
|
||||
| `useEcharts` | 函数 | 组合式函数 |
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import { EchartsUI, useEcharts, ECOption } from '@vben/plugins/echarts';
|
||||
```
|
||||
|
||||
## 类型
|
||||
|
||||
```ts
|
||||
import type { ECOption } from '@vben/plugins/echarts';
|
||||
```
|
||||
|
||||
## 预置组件
|
||||
|
||||
- TitleComponent
|
||||
- TooltipComponent
|
||||
- GridComponent
|
||||
- LegendComponent
|
||||
- ToolboxComponent
|
||||
- DatasetComponent
|
||||
- TransformComponent
|
||||
|
||||
## 预置图表
|
||||
|
||||
- BarChart
|
||||
- LineChart
|
||||
- PieChart
|
||||
- RadarChart
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
height?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
height: '300px',
|
||||
width: '100%',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-bind="$attrs" :style="{ height, width }"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
DatasetComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent,
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import {
|
||||
LabelLayout,
|
||||
LegacyGridContainLabel,
|
||||
UniversalTransition,
|
||||
} from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
BarChart,
|
||||
LineChart,
|
||||
LabelLayout,
|
||||
LegacyGridContainLabel,
|
||||
UniversalTransition,
|
||||
CanvasRenderer,
|
||||
LegendComponent,
|
||||
ToolboxComponent,
|
||||
]);
|
||||
export type { ECOption } from './types';
|
||||
|
||||
export default echarts;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './echarts';
|
||||
export { default as EchartsUI } from './echarts-ui.vue';
|
||||
export * from './types';
|
||||
export * from './use-echarts';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
LineSeriesOption,
|
||||
PieSeriesOption,
|
||||
RadarSeriesOption,
|
||||
} from 'echarts/charts';
|
||||
import type {
|
||||
DatasetComponentOption,
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TitleComponentOption,
|
||||
ToolboxComponentOption,
|
||||
TooltipComponentOption,
|
||||
} from 'echarts/components';
|
||||
import type { ComposeOption } from 'echarts/core';
|
||||
|
||||
export type ECOption = ComposeOption<
|
||||
| BarSeriesOption
|
||||
| DatasetComponentOption
|
||||
| GridComponentOption
|
||||
| LegendComponentOption
|
||||
| LineSeriesOption
|
||||
| PieSeriesOption
|
||||
| RadarSeriesOption
|
||||
| TitleComponentOption
|
||||
| ToolboxComponentOption
|
||||
| TooltipComponentOption
|
||||
>;
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Nullable } from '@vben/types';
|
||||
|
||||
import type EchartsUI from './echarts-ui.vue';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
ref,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import {
|
||||
tryOnUnmounted,
|
||||
useDebounceFn,
|
||||
useResizeObserver,
|
||||
useTimeoutFn,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
|
||||
import echarts from './echarts';
|
||||
|
||||
type EchartsUIType = typeof EchartsUI | undefined;
|
||||
|
||||
type EchartsThemeType = 'dark' | 'light' | null;
|
||||
|
||||
function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
let cacheOptions: EChartsOption = {};
|
||||
// echarts是否处于激活状态
|
||||
const isActiveRef = ref(false);
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { height, width } = useWindowSize();
|
||||
const resizeHandler: () => void = useDebounceFn(resize, 200);
|
||||
|
||||
const getChartEl = (): HTMLElement | null => {
|
||||
const refValue = chartRef?.value as unknown;
|
||||
if (!refValue) return null;
|
||||
if (refValue instanceof HTMLElement) {
|
||||
return refValue;
|
||||
}
|
||||
const maybeComponent = refValue as { $el?: HTMLElement };
|
||||
return maybeComponent.$el ?? null;
|
||||
};
|
||||
|
||||
onMounted(() => (isActiveRef.value = true));
|
||||
onActivated(() => (isActiveRef.value = true));
|
||||
onDeactivated(() => (isActiveRef.value = false));
|
||||
onBeforeUnmount(() => (isActiveRef.value = false));
|
||||
|
||||
const isElHidden = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return true;
|
||||
return el.offsetHeight === 0 || el.offsetWidth === 0;
|
||||
};
|
||||
|
||||
const getOptions = computed((): EChartsOption => {
|
||||
if (!isDark.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
});
|
||||
|
||||
const initCharts = (t?: EchartsThemeType) => {
|
||||
const el = chartRef?.value?.$el;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
chartInstance = echarts.init(el, t || isDark.value ? 'dark' : null);
|
||||
|
||||
return chartInstance;
|
||||
};
|
||||
|
||||
const renderEcharts = (
|
||||
options: EChartsOption,
|
||||
clear = true,
|
||||
): Promise<Nullable<echarts.ECharts>> => {
|
||||
if (!unref(isActiveRef)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
cacheOptions = options;
|
||||
const currentOptions = {
|
||||
...options,
|
||||
...getOptions.value,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
if (chartRef.value?.offsetHeight === 0) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
useTimeoutFn(() => {
|
||||
if (!chartInstance || chartInstance?.getDom() !== el) {
|
||||
chartInstance?.dispose();
|
||||
const instance = initCharts();
|
||||
if (!instance) return;
|
||||
chartInstance = instance;
|
||||
}
|
||||
clear && chartInstance?.clear();
|
||||
chartInstance?.setOption(currentOptions);
|
||||
resolve(chartInstance);
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateData = (
|
||||
option: EChartsOption,
|
||||
notMerge = false, // false = 合并(保留动画),true = 完全替换
|
||||
lazyUpdate = false, // true 时不立即重绘,适合短时间内多次调用
|
||||
): Promise<echarts.ECharts | null> => {
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
if (!chartInstance) {
|
||||
// 还没初始化 → 当作首次渲染
|
||||
renderEcharts(option).then(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并你原有的全局配置(比如 backgroundColor)
|
||||
const finalOption = {
|
||||
...option,
|
||||
...getOptions.value,
|
||||
};
|
||||
|
||||
chartInstance.setOption(finalOption, {
|
||||
notMerge,
|
||||
lazyUpdate,
|
||||
// silent: true, // 如果追求极致性能可开启(关闭所有事件)
|
||||
});
|
||||
|
||||
resolve(chartInstance);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
return;
|
||||
}
|
||||
chartInstance?.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticIn',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch([width, height], () => {
|
||||
resizeHandler?.();
|
||||
});
|
||||
|
||||
useResizeObserver(chartRef as never, resizeHandler);
|
||||
|
||||
watch([isDark, isActiveRef], () => {
|
||||
if (chartInstance && unref(isActiveRef)) {
|
||||
chartInstance.dispose();
|
||||
initCharts();
|
||||
renderEcharts(cacheOptions);
|
||||
resize();
|
||||
}
|
||||
});
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
// 销毁实例,释放资源
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
return {
|
||||
isActive: isActiveRef,
|
||||
renderEcharts,
|
||||
resize,
|
||||
updateData,
|
||||
getChartInstance: () => chartInstance,
|
||||
};
|
||||
}
|
||||
|
||||
export { useEcharts };
|
||||
|
||||
export type { EchartsUIType };
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './plugins-context';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,26 @@
|
||||
# Motion Plugin
|
||||
|
||||
基于 @vueuse/motion 的动画插件。
|
||||
|
||||
## 导出
|
||||
|
||||
| 导出 | 类型 | 说明 |
|
||||
| ----------------- | ---- | ---------- |
|
||||
| `Motion` | 组件 | 动画组件 |
|
||||
| `MotionGroup` | 组件 | 动画组组件 |
|
||||
| `MotionDirective` | 指令 | 动画指令 |
|
||||
| `MotionPlugin` | 插件 | Vue 插件 |
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import { MotionPlugin, Motion, MotionDirective } from '@vben/plugins/motion';
|
||||
|
||||
app.use(MotionPlugin);
|
||||
```
|
||||
|
||||
## 类型
|
||||
|
||||
```ts
|
||||
import type { MotionOptions, MotionVariants } from '@vben/plugins/motion';
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './types';
|
||||
|
||||
export {
|
||||
MotionComponent as Motion,
|
||||
MotionDirective,
|
||||
MotionGroupComponent as MotionGroup,
|
||||
MotionPlugin,
|
||||
} from '@vueuse/motion';
|
||||
@@ -0,0 +1,26 @@
|
||||
export const MotionPresets = [
|
||||
'fade',
|
||||
'fadeVisible',
|
||||
'fadeVisibleOnce',
|
||||
'rollBottom',
|
||||
'rollLeft',
|
||||
'rollRight',
|
||||
'rollTop',
|
||||
'rollVisibleBottom',
|
||||
'rollVisibleLeft',
|
||||
'rollVisibleRight',
|
||||
'rollVisibleTop',
|
||||
'pop',
|
||||
'popVisible',
|
||||
'popVisibleOnce',
|
||||
'slideBottom',
|
||||
'slideLeft',
|
||||
'slideRight',
|
||||
'slideTop',
|
||||
'slideVisibleBottom',
|
||||
'slideVisibleLeft',
|
||||
'slideVisibleRight',
|
||||
'slideVisibleTop',
|
||||
] as const;
|
||||
|
||||
export type MotionPreset = (typeof MotionPresets)[number];
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { VbenPluginsOptions } from './types';
|
||||
|
||||
let globalPluginsOptions: null | VbenPluginsOptions = null;
|
||||
|
||||
export function providePluginsOptions(options: VbenPluginsOptions) {
|
||||
if (!globalPluginsOptions) {
|
||||
globalPluginsOptions = options;
|
||||
return;
|
||||
}
|
||||
|
||||
globalPluginsOptions = {
|
||||
...globalPluginsOptions,
|
||||
...options,
|
||||
form:
|
||||
globalPluginsOptions.form && options.form
|
||||
? { ...globalPluginsOptions.form, ...options.form }
|
||||
: globalPluginsOptions.form || options.form,
|
||||
modal:
|
||||
globalPluginsOptions.modal && options.modal
|
||||
? { ...globalPluginsOptions.modal, ...options.modal }
|
||||
: globalPluginsOptions.modal || options.modal,
|
||||
message:
|
||||
globalPluginsOptions.message && options.message
|
||||
? { ...globalPluginsOptions.message, ...options.message }
|
||||
: globalPluginsOptions.message || options.message,
|
||||
components: {
|
||||
...globalPluginsOptions.components,
|
||||
...options.components,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function injectPluginsOptions() {
|
||||
return globalPluginsOptions;
|
||||
}
|
||||
|
||||
export function resetPluginsOptions() {
|
||||
globalPluginsOptions = null;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import type { Editor as CoreEditor } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import type { Extensions } from '@tiptap/vue-3';
|
||||
|
||||
import type { ImageUploadOptions, VbenTiptapExtensionOptions } from './types';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { alert } from '@vben-core/popup-ui';
|
||||
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { Color, TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
|
||||
const DEFAULT_ACCEPT = 'image/*';
|
||||
|
||||
function validateFile(
|
||||
file: File,
|
||||
options: ImageUploadOptions,
|
||||
): string | undefined {
|
||||
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
||||
return $t('ui.tiptap.upload.fileTooLarge');
|
||||
}
|
||||
|
||||
const accept = options.accept ?? DEFAULT_ACCEPT;
|
||||
if (accept && accept !== '*/*' && accept !== 'image/*') {
|
||||
const acceptedTypes = accept.split(',').map((t) => t.trim());
|
||||
const isAccepted = acceptedTypes.some((type) => {
|
||||
if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.slice(0, -1));
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
if (!isAccepted) {
|
||||
return $t('ui.tiptap.upload.fileTypeNotAllowed');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handleUploadError(error: unknown, options: ImageUploadOptions): void {
|
||||
if (options.onUploadError) {
|
||||
options.onUploadError(error);
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
alert(message, $t('ui.tiptap.upload.uploadFailed')).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function findPlaceholderPos(doc: ProseMirrorNode, blobUrl: string): number {
|
||||
let found = -1;
|
||||
doc.descendants((node: ProseMirrorNode, offset: number) => {
|
||||
if (found !== -1) return false;
|
||||
if (
|
||||
node.type.name === 'image' &&
|
||||
node.attrs.src === blobUrl &&
|
||||
node.attrs['data-uploading'] === 'true'
|
||||
) {
|
||||
found = offset;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
interface UploadContext {
|
||||
blobUrl: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
function createUploadProcess(
|
||||
editor: CoreEditor,
|
||||
file: File,
|
||||
options: ImageUploadOptions,
|
||||
blobUrlTracker?: Set<string>,
|
||||
pos?: number,
|
||||
): UploadContext {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
blobUrlTracker?.add(blobUrl);
|
||||
const insertPos = pos ?? editor.state.selection.from;
|
||||
|
||||
// Insert placeholder image with blob URL
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(insertPos, {
|
||||
attrs: {
|
||||
'data-upload-progress': 0,
|
||||
'data-uploading': 'true',
|
||||
src: blobUrl,
|
||||
},
|
||||
type: 'image',
|
||||
})
|
||||
.run();
|
||||
|
||||
const nodePos = findPlaceholderPos(editor.state.doc, blobUrl);
|
||||
|
||||
const uploadContext: UploadContext = { blobUrl, pos: nodePos };
|
||||
|
||||
options
|
||||
.upload(file, (percent: number) => {
|
||||
if (editor.isDestroyed) return;
|
||||
|
||||
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
|
||||
if (currentPos === -1) return;
|
||||
|
||||
const node = editor.state.doc.nodeAt(currentPos);
|
||||
if (!node) return;
|
||||
|
||||
const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
...node.attrs,
|
||||
'data-upload-progress': percent,
|
||||
});
|
||||
editor.view.dispatch(transaction);
|
||||
})
|
||||
.then((url: string) => {
|
||||
if (editor.isDestroyed) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
|
||||
|
||||
if (currentPos === -1) {
|
||||
blobUrlTracker?.delete(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = editor.state.doc.nodeAt(currentPos);
|
||||
if (!node) {
|
||||
blobUrlTracker?.delete(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
...node.attrs,
|
||||
'data-upload-progress': null,
|
||||
'data-uploading': null,
|
||||
src: url,
|
||||
});
|
||||
editor.view.dispatch(transaction);
|
||||
blobUrlTracker?.delete(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (editor.isDestroyed) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
|
||||
|
||||
if (currentPos !== -1) {
|
||||
const transaction = editor.state.tr.delete(
|
||||
currentPos,
|
||||
currentPos + (editor.state.doc.nodeAt(currentPos)?.nodeSize ?? 1),
|
||||
);
|
||||
editor.view.dispatch(transaction);
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
blobUrlTracker?.delete(blobUrl);
|
||||
handleUploadError(error, options);
|
||||
});
|
||||
|
||||
return uploadContext;
|
||||
}
|
||||
|
||||
function createCustomImage(
|
||||
imageUpload: ImageUploadOptions,
|
||||
blobUrlTracker?: Set<string>,
|
||||
) {
|
||||
return Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
'data-upload-progress': {
|
||||
default: null,
|
||||
parseHTML: (element) => element.dataset.uploadProgress,
|
||||
renderHTML: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
'data-uploading': {
|
||||
default: null,
|
||||
parseHTML: (element) => element.dataset.uploading,
|
||||
renderHTML: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const isUploading = node.attrs['data-uploading'] === 'true';
|
||||
|
||||
if (!isUploading) {
|
||||
return null as any;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'vben-tiptap-upload-wrapper';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = node.attrs.src;
|
||||
img.className = 'vben-tiptap__image';
|
||||
wrapper.append(img);
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'vben-tiptap-upload-spinner';
|
||||
wrapper.append(spinner);
|
||||
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.className = 'vben-tiptap-upload-progress';
|
||||
const progressFill = document.createElement('div');
|
||||
progressFill.className = 'vben-tiptap-upload-progress-fill';
|
||||
progressBar.append(progressFill);
|
||||
wrapper.append(progressBar);
|
||||
|
||||
const progress = node.attrs['data-upload-progress'];
|
||||
if (progress !== null && progress !== undefined && progress > 0) {
|
||||
spinner.style.display = 'none';
|
||||
progressBar.style.display = '';
|
||||
progressFill.style.width = `${progress}%`;
|
||||
} else {
|
||||
spinner.style.display = '';
|
||||
progressBar.style.display = 'none';
|
||||
}
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
update(updatedNode: ProseMirrorNode) {
|
||||
if (updatedNode.attrs['data-uploading'] !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.src !== img.src) {
|
||||
img.src = updatedNode.attrs.src;
|
||||
}
|
||||
|
||||
const newProgress = updatedNode.attrs['data-upload-progress'];
|
||||
if (
|
||||
newProgress !== null &&
|
||||
newProgress !== undefined &&
|
||||
newProgress > 0
|
||||
) {
|
||||
spinner.style.display = 'none';
|
||||
progressBar.style.display = '';
|
||||
progressFill.style.width = `${newProgress}%`;
|
||||
} else {
|
||||
spinner.style.display = '';
|
||||
progressBar.style.display = 'none';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploadImage:
|
||||
() =>
|
||||
({ editor: cmdEditor }: { editor: CoreEditor }) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = imageUpload.accept ?? DEFAULT_ACCEPT;
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const error = validateFile(file, imageUpload);
|
||||
if (error) {
|
||||
handleUploadError(new Error(error), imageUpload);
|
||||
return;
|
||||
}
|
||||
|
||||
createUploadProcess(cmdEditor, file, imageUpload, blobUrlTracker);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
document.body.append(input);
|
||||
input.click();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('imageUploadDrop'),
|
||||
props: {
|
||||
handleDrop: (view: EditorView, event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files.length) return false;
|
||||
|
||||
const imageFiles = [...event.dataTransfer.files].filter((f) =>
|
||||
f.type.startsWith('image/'),
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Only support single image upload
|
||||
const file = imageFiles[0];
|
||||
if (!file) return false;
|
||||
if (imageFiles.length > 1) {
|
||||
handleUploadError(
|
||||
new Error($t('ui.tiptap.upload.onlySingleImage')),
|
||||
imageUpload,
|
||||
);
|
||||
}
|
||||
|
||||
const error = validateFile(file, imageUpload);
|
||||
if (error) {
|
||||
handleUploadError(new Error(error), imageUpload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
const pos = coordinates?.pos ?? view.state.selection.from;
|
||||
|
||||
createUploadProcess(
|
||||
editor,
|
||||
file,
|
||||
imageUpload,
|
||||
blobUrlTracker,
|
||||
pos,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey('imageUploadPaste'),
|
||||
props: {
|
||||
handlePaste: (_view: EditorView, event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return false;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const imageFile = imageFiles[0];
|
||||
if (!imageFile) return false;
|
||||
if (imageFiles.length > 1) {
|
||||
handleUploadError(
|
||||
new Error($t('ui.tiptap.upload.onlySingleImage')),
|
||||
imageUpload,
|
||||
);
|
||||
}
|
||||
|
||||
const error = validateFile(imageFile, imageUpload);
|
||||
if (error) {
|
||||
handleUploadError(new Error(error), imageUpload);
|
||||
return true;
|
||||
}
|
||||
|
||||
createUploadProcess(
|
||||
editor,
|
||||
imageFile,
|
||||
imageUpload,
|
||||
blobUrlTracker,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createDefaultTiptapExtensions(
|
||||
options: VbenTiptapExtensionOptions = {},
|
||||
): Extensions {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
TextStyle,
|
||||
Color.configure({
|
||||
types: ['textStyle'],
|
||||
}),
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
enableClickSelection: true,
|
||||
openOnClick: false,
|
||||
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
|
||||
}),
|
||||
options.imageUpload
|
||||
? createCustomImage(
|
||||
options.imageUpload,
|
||||
options._blobUrlTracker,
|
||||
).configure({
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: 'vben-tiptap__image',
|
||||
},
|
||||
})
|
||||
: Image.configure({
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: 'vben-tiptap__image',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as VbenTiptapPreview } from './preview.vue';
|
||||
export { default as VbenTiptap } from './tiptap.vue';
|
||||
|
||||
export * from './types';
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { TipTapPreviewProps } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import './style.css';
|
||||
const props = withDefaults(defineProps<TipTapPreviewProps>(), {
|
||||
content: '',
|
||||
minHeight: 160,
|
||||
});
|
||||
const contentMinHeight = computed(() =>
|
||||
typeof props.minHeight === 'number'
|
||||
? `${props.minHeight}px`
|
||||
: props.minHeight,
|
||||
);
|
||||
const previewClass = computed(() =>
|
||||
cn(
|
||||
'vben-tiptap-content',
|
||||
'text-foreground bg-transparent p-0 leading-7',
|
||||
props.class,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
:class="previewClass"
|
||||
:style="{ minHeight: contentMinHeight }"
|
||||
v-html="content"
|
||||
></div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
@reference "@vben/tailwind-config/theme";
|
||||
|
||||
.vben-tiptap-content h1 {
|
||||
@apply text-2xl font-bold leading-[1.4];
|
||||
}
|
||||
|
||||
.vben-tiptap-content h2 {
|
||||
@apply text-xl font-bold leading-[1.45];
|
||||
}
|
||||
|
||||
.vben-tiptap-content h3 {
|
||||
@apply text-lg font-semibold leading-[1.5];
|
||||
}
|
||||
|
||||
.vben-tiptap-content h4 {
|
||||
@apply text-base font-semibold leading-[1.55];
|
||||
}
|
||||
|
||||
.vben-tiptap-content ul {
|
||||
@apply list-disc pl-6;
|
||||
}
|
||||
|
||||
.vben-tiptap-content ol {
|
||||
@apply list-decimal pl-6;
|
||||
}
|
||||
|
||||
.vben-tiptap-content blockquote {
|
||||
@apply border-l-4 border-primary pl-4 text-muted-foreground;
|
||||
}
|
||||
|
||||
.vben-tiptap-content a {
|
||||
@apply text-primary underline decoration-1 underline-offset-[3px];
|
||||
}
|
||||
|
||||
.vben-tiptap-content code {
|
||||
@apply rounded-[0.45rem] border border-border bg-secondary px-[0.35rem] py-[0.15rem] text-[0.9em] text-primary;
|
||||
}
|
||||
|
||||
.vben-tiptap-content pre {
|
||||
@apply overflow-x-auto rounded-[0.9rem] border border-border bg-popover p-4 text-popover-foreground;
|
||||
}
|
||||
|
||||
.vben-tiptap-content pre code {
|
||||
@apply border-none bg-transparent p-0 text-inherit;
|
||||
}
|
||||
|
||||
.vben-tiptap-content img,
|
||||
.vben-tiptap-content .vben-tiptap__image {
|
||||
max-width: min(100%, 640px);
|
||||
}
|
||||
|
||||
/* Image upload states */
|
||||
.vben-tiptap-upload-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.vben-tiptap-upload-wrapper img {
|
||||
display: block;
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.vben-tiptap-upload-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: hsl(var(--card) / 30%);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.vben-tiptap-upload-spinner::after {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
content: '';
|
||||
border: 2px solid hsl(var(--foreground) / 30%);
|
||||
border-top-color: hsl(var(--foreground));
|
||||
border-radius: 50%;
|
||||
animation: vben-tiptap-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.vben-tiptap-upload-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.vben-tiptap-upload-progress-fill {
|
||||
height: 100%;
|
||||
background-color: hsl(var(--primary));
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes vben-tiptap-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
TipTapProps,
|
||||
ToolbarAction,
|
||||
ToolbarMenuItem,
|
||||
VbenTiptapChangeEvent,
|
||||
} from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
|
||||
import { Check, ChevronDown, Eye } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import { VbenIconButton, VbenPopover } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
|
||||
import { createDefaultTiptapExtensions } from './extensions';
|
||||
import Preview from './preview.vue';
|
||||
import { createToolbarGroups } from './toolbar';
|
||||
import { useTiptapToolbar } from './use-tiptap-toolbar';
|
||||
|
||||
import './style.css';
|
||||
const props = withDefaults(defineProps<TipTapProps>(), {
|
||||
editable: true,
|
||||
extensions: undefined,
|
||||
imageUpload: undefined,
|
||||
minHeight: 240,
|
||||
maxHeight: 400,
|
||||
placeholder: $t('ui.tiptap.placeholder'),
|
||||
previewable: true,
|
||||
toolbar: true,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
change: [payload: VbenTiptapChangeEvent];
|
||||
}>();
|
||||
const modelValue = defineModel<string>({ default: '' });
|
||||
const contentMinHeight = computed(() =>
|
||||
typeof props.minHeight === 'number'
|
||||
? `${props.minHeight}px`
|
||||
: props.minHeight,
|
||||
);
|
||||
const contentMaxHeight = computed(() =>
|
||||
typeof props.maxHeight === 'number'
|
||||
? `${props.maxHeight}px`
|
||||
: props.maxHeight,
|
||||
);
|
||||
const tiptapContentClass = cn(
|
||||
'vben-tiptap-content vben-tiptap__content',
|
||||
'text-foreground max-h-(--vben-tiptap-max-height) min-h-(--vben-tiptap-min-height) overflow-auto leading-7 outline-none',
|
||||
);
|
||||
const blobUrlTracker = new Set<string>();
|
||||
const editor = useEditor({
|
||||
content: modelValue.value,
|
||||
editable: props.editable,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: tiptapContentClass,
|
||||
},
|
||||
},
|
||||
extensions:
|
||||
props.extensions ??
|
||||
createDefaultTiptapExtensions({
|
||||
_blobUrlTracker: blobUrlTracker,
|
||||
imageUpload: props.imageUpload,
|
||||
placeholder: props.placeholder,
|
||||
}),
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
if (html !== modelValue.value) {
|
||||
modelValue.value = html;
|
||||
}
|
||||
emit('change', {
|
||||
html,
|
||||
json: editor.getJSON(),
|
||||
text: editor.getText(),
|
||||
});
|
||||
},
|
||||
});
|
||||
const toolbarGroups = computed<ToolbarAction[][]>(() => {
|
||||
// Only show upload toolbar option when using default extensions
|
||||
// (custom extensions may not include the uploadImage command)
|
||||
const effectiveImageUpload = props.extensions ? undefined : props.imageUpload;
|
||||
return createToolbarGroups(effectiveImageUpload);
|
||||
});
|
||||
const previewContent = computed(
|
||||
() => editor.value?.getHTML() ?? modelValue.value,
|
||||
);
|
||||
const [PreviewModal, previewModalApi] = useVbenModal({
|
||||
footer: false,
|
||||
fullscreenButton: false,
|
||||
});
|
||||
const {
|
||||
applyPaletteColor,
|
||||
canRunAction,
|
||||
canRunMenuItem,
|
||||
clearPaletteColor,
|
||||
getActionIndicatorColor,
|
||||
getMenuItemClass,
|
||||
getPaletteCurrentColor,
|
||||
getPaletteSwatchClass,
|
||||
getToolbarButtonClass,
|
||||
isMenuItemActive,
|
||||
runAction,
|
||||
runMenuItem,
|
||||
} = useTiptapToolbar({
|
||||
editable: () => props.editable,
|
||||
editor,
|
||||
});
|
||||
|
||||
const menuOpenState = reactive<Record<string, boolean>>({});
|
||||
|
||||
function getMenuOpen(action: ToolbarAction): boolean {
|
||||
return menuOpenState[action.label] ?? false;
|
||||
}
|
||||
|
||||
function setMenuOpen(action: ToolbarAction, open: boolean) {
|
||||
menuOpenState[action.label] = open;
|
||||
}
|
||||
|
||||
function handleMenuItemClick(action: ToolbarAction, item: ToolbarMenuItem) {
|
||||
runMenuItem(item);
|
||||
setMenuOpen(action, false);
|
||||
}
|
||||
|
||||
function openPreviewModal() {
|
||||
previewModalApi.open();
|
||||
}
|
||||
watch(
|
||||
() => props.editable,
|
||||
(editable) => {
|
||||
editor.value?.setEditable(editable);
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(nextValue = '') => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
const currentValue = editor.value.getHTML();
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
editor.value.commands.setContent(nextValue, {
|
||||
emitUpdate: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
for (const url of blobUrlTracker) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
blobUrlTracker.clear();
|
||||
editor.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="{
|
||||
'--vben-tiptap-min-height': contentMinHeight,
|
||||
'--vben-tiptap-max-height': contentMaxHeight,
|
||||
}"
|
||||
class="vben-tiptap overflow-hidden rounded-xl border border-border bg-card"
|
||||
>
|
||||
<div
|
||||
v-if="toolbar"
|
||||
class="sticky top-0 z-10 flex flex-wrap items-center gap-2 border-b border-border p-2 backdrop-blur-[14px]"
|
||||
>
|
||||
<div
|
||||
v-for="(group, groupIndex) in toolbarGroups"
|
||||
:key="groupIndex"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template v-for="action in group" :key="action.label">
|
||||
<VbenPopover
|
||||
v-if="action.menu || action.palette"
|
||||
:open="action.menu ? getMenuOpen(action) : undefined"
|
||||
:content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
|
||||
content-class="w-auto p-2"
|
||||
@update:open="action.menu ? setMenuOpen(action, $event) : undefined"
|
||||
>
|
||||
<template #trigger>
|
||||
<VbenIconButton
|
||||
:aria-label="action.label"
|
||||
:class="getToolbarButtonClass(action)"
|
||||
:disabled="!canRunAction(action)"
|
||||
:tooltip="action.label"
|
||||
tooltip-side="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<template v-if="action.triggerText">
|
||||
<span class="text-xs font-semibold tracking-wide">
|
||||
{{
|
||||
typeof action.triggerText === 'function'
|
||||
? action.triggerText(editor)
|
||||
: action.triggerText
|
||||
}}
|
||||
</span>
|
||||
<ChevronDown class="size-4 opacity-70" />
|
||||
</template>
|
||||
<component
|
||||
v-else-if="action.icon"
|
||||
:is="action.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<span
|
||||
v-if="getActionIndicatorColor(action)"
|
||||
:style="{ backgroundColor: getActionIndicatorColor(action) }"
|
||||
class="absolute bottom-1 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full shadow-[0_0_0_1px_hsl(var(--card)/0.7)]"
|
||||
></span>
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<div
|
||||
v-if="action.palette"
|
||||
class="flex max-w-52 flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-for="color in action.palette.colors"
|
||||
:key="color"
|
||||
:aria-label="`${action.label}-${color}`"
|
||||
:class="getPaletteSwatchClass(action, color)"
|
||||
:style="{ backgroundColor: color }"
|
||||
type="button"
|
||||
@click="applyPaletteColor(action, color)"
|
||||
>
|
||||
<Check
|
||||
v-if="getPaletteCurrentColor(action) === color"
|
||||
class="size-4 text-white drop-shadow-sm"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="action.palette.clear"
|
||||
class="h-8 w-full rounded-xl border border-border bg-secondary text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
@click="clearPaletteColor(action)"
|
||||
>
|
||||
{{ $t('ui.tiptap.toolbar.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="action.menu" class="flex min-w-32 flex-col gap-1">
|
||||
<button
|
||||
v-for="item in action.menu.items"
|
||||
:key="item.shortLabel"
|
||||
:class="getMenuItemClass(item)"
|
||||
:disabled="!canRunMenuItem(item)"
|
||||
type="button"
|
||||
@click="handleMenuItemClick(action, item)"
|
||||
>
|
||||
<span class="w-7 text-xs font-semibold tracking-wide">
|
||||
{{ item.shortLabel }}
|
||||
</span>
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<Check
|
||||
v-if="isMenuItemActive(item)"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</VbenPopover>
|
||||
<VbenIconButton
|
||||
v-else
|
||||
:aria-label="action.label"
|
||||
:class="getToolbarButtonClass(action)"
|
||||
:disabled="!canRunAction(action)"
|
||||
:tooltip="action.label"
|
||||
tooltip-side="top"
|
||||
@click="runAction(action)"
|
||||
>
|
||||
<component :is="action.icon" class="size-4" />
|
||||
<span
|
||||
v-if="getActionIndicatorColor(action)"
|
||||
:style="{ backgroundColor: getActionIndicatorColor(action) }"
|
||||
class="absolute bottom-1 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full shadow-[0_0_0_1px_hsl(var(--card)/0.7)]"
|
||||
></span>
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<div
|
||||
v-if="groupIndex < toolbarGroups.length - 1"
|
||||
class="ml-1 h-5 w-px bg-border"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="previewable" class="ml-auto flex items-center">
|
||||
<VbenIconButton
|
||||
:aria-label="$t('ui.tiptap.toolbar.preview')"
|
||||
:class="
|
||||
getToolbarButtonClass({
|
||||
action: () => {},
|
||||
label: $t('ui.tiptap.toolbar.preview'),
|
||||
})
|
||||
"
|
||||
:tooltip="$t('ui.tiptap.toolbar.preview')"
|
||||
tooltip-side="top"
|
||||
variant="ghost"
|
||||
@click="openPreviewModal"
|
||||
>
|
||||
<Eye class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent v-if="editor" :editor="editor" class="p-4" />
|
||||
<PreviewModal
|
||||
v-if="previewable"
|
||||
:title="$t('ui.tiptap.toolbar.preview')"
|
||||
class="w-4/5"
|
||||
>
|
||||
<Preview :content="previewContent" :min-height="320" />
|
||||
</PreviewModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vben-tiptap
|
||||
:deep(.vben-tiptap__content p.is-editor-empty:first-child::before) {
|
||||
float: left;
|
||||
height: 0;
|
||||
color: hsl(var(--input-placeholder));
|
||||
pointer-events: none;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
import type {
|
||||
ImageUploadOptions,
|
||||
ToolbarAction,
|
||||
ToolbarMenuItem,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Highlighter,
|
||||
ImagePlus,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
MessageSquareCode,
|
||||
Paintbrush,
|
||||
Redo2,
|
||||
RemoveFormatting,
|
||||
SquareCode,
|
||||
Strikethrough,
|
||||
TextQuote,
|
||||
Underline,
|
||||
Undo2,
|
||||
Unlink2,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { COLOR_PRESETS } from '@vben/preferences';
|
||||
|
||||
import { prompt } from '@vben-core/popup-ui';
|
||||
|
||||
const headingLevels = [1, 2, 3, 4] as const;
|
||||
const editorColorPresets = [
|
||||
'hsl(var(--foreground))',
|
||||
'hsl(var(--warning))',
|
||||
'hsl(var(--success))',
|
||||
'hsl(var(--destructive))',
|
||||
...COLOR_PRESETS.map((item) => item.color),
|
||||
];
|
||||
const editorHighlightPresets = [
|
||||
withAlpha('hsl(var(--warning))', 0.45),
|
||||
withAlpha('hsl(var(--success))', 0.35),
|
||||
withAlpha('hsl(var(--primary))', 0.3),
|
||||
withAlpha('hsl(var(--destructive))', 0.3),
|
||||
...COLOR_PRESETS.map((item) => withAlpha(item.color, 0.4)),
|
||||
];
|
||||
|
||||
function createHeadingMenuItems(): ToolbarMenuItem[] {
|
||||
return [
|
||||
{
|
||||
action: (editor) => editor.chain().focus().setParagraph().run(),
|
||||
can: (editor) => editor.can().chain().focus().setParagraph().run(),
|
||||
isActive: (editor) => editor.isActive('paragraph'),
|
||||
label: $t('ui.tiptap.toolbar.paragraph'),
|
||||
shortLabel: 'P',
|
||||
},
|
||||
...headingLevels.map((level) => ({
|
||||
action: (editor: Editor) =>
|
||||
editor.chain().focus().toggleHeading({ level }).run(),
|
||||
can: (editor: Editor) =>
|
||||
editor.can().chain().focus().toggleHeading({ level }).run(),
|
||||
isActive: (editor: Editor) => editor.isActive('heading', { level }),
|
||||
label: $t(`ui.tiptap.toolbar.heading${level}`),
|
||||
shortLabel: `H${level}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function getHeadingTriggerText(editor?: Editor) {
|
||||
if (editor?.isActive('paragraph')) {
|
||||
return 'P';
|
||||
}
|
||||
|
||||
const level = headingLevels.find((headingLevel) =>
|
||||
editor?.isActive('heading', { level: headingLevel }),
|
||||
);
|
||||
|
||||
return level ? `H${level}` : 'H';
|
||||
}
|
||||
|
||||
function normalizeLinkUrl(url: string) {
|
||||
if (/^(https?:|mailto:|tel:)/i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number) {
|
||||
const normalizedAlpha = Math.min(Math.max(alpha, 0), 1);
|
||||
const hslMatch = color.match(/^hsl\((.+)\)$/);
|
||||
|
||||
if (!hslMatch) {
|
||||
return color;
|
||||
}
|
||||
|
||||
return `hsl(${hslMatch[1]} / ${normalizedAlpha})`;
|
||||
}
|
||||
|
||||
async function handleLinkAction(editor: Editor) {
|
||||
const currentHref = editor.getAttributes('link').href as string | undefined;
|
||||
|
||||
let url: string | undefined;
|
||||
|
||||
try {
|
||||
url = await prompt<string>({
|
||||
componentProps: {
|
||||
placeholder: 'https://example.com',
|
||||
},
|
||||
content: $t('ui.tiptap.prompts.link'),
|
||||
defaultValue: currentHref ?? '',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = (url ?? '').trim();
|
||||
|
||||
if (!nextUrl) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({
|
||||
href: normalizeLinkUrl(nextUrl),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function handleImageAction(editor: Editor) {
|
||||
let url: string | undefined;
|
||||
|
||||
try {
|
||||
url = await prompt<string>({
|
||||
componentProps: {
|
||||
placeholder: 'https://example.com/image.png',
|
||||
},
|
||||
content: $t('ui.tiptap.prompts.image'),
|
||||
defaultValue: '',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = (url ?? '').trim();
|
||||
|
||||
if (!nextUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().setImage({ src: nextUrl }).run();
|
||||
}
|
||||
|
||||
export function createToolbarGroups(
|
||||
imageUpload?: ImageUploadOptions,
|
||||
): ToolbarAction[][] {
|
||||
const headingMenuItems = createHeadingMenuItems();
|
||||
|
||||
return [
|
||||
[
|
||||
{
|
||||
action: (editor) => editor.chain().focus().undo().run(),
|
||||
can: (editor) => editor.can().chain().focus().undo().run(),
|
||||
icon: Undo2,
|
||||
label: $t('ui.tiptap.toolbar.undo'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().redo().run(),
|
||||
can: (editor) => editor.can().chain().focus().redo().run(),
|
||||
icon: Redo2,
|
||||
label: $t('ui.tiptap.toolbar.redo'),
|
||||
},
|
||||
{
|
||||
action: (editor) =>
|
||||
editor.chain().focus().clearNodes().unsetAllMarks().run(),
|
||||
icon: RemoveFormatting,
|
||||
label: $t('ui.tiptap.toolbar.clear'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleBold().run(),
|
||||
active: { name: 'bold' },
|
||||
can: (editor) => editor.can().chain().focus().toggleBold().run(),
|
||||
icon: Bold,
|
||||
label: $t('ui.tiptap.toolbar.bold'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleItalic().run(),
|
||||
active: { name: 'italic' },
|
||||
can: (editor) => editor.can().chain().focus().toggleItalic().run(),
|
||||
icon: Italic,
|
||||
label: $t('ui.tiptap.toolbar.italic'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleUnderline().run(),
|
||||
active: { name: 'underline' },
|
||||
can: (editor) => editor.can().chain().focus().toggleUnderline().run(),
|
||||
icon: Underline,
|
||||
label: $t('ui.tiptap.toolbar.underline'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleStrike().run(),
|
||||
active: { name: 'strike' },
|
||||
can: (editor) => editor.can().chain().focus().toggleStrike().run(),
|
||||
icon: Strikethrough,
|
||||
label: $t('ui.tiptap.toolbar.strike'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleCode().run(),
|
||||
active: { name: 'code' },
|
||||
can: (editor) => editor.can().chain().focus().toggleCode().run(),
|
||||
icon: SquareCode,
|
||||
label: $t('ui.tiptap.toolbar.code'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
action: () => {},
|
||||
can: (editor) =>
|
||||
headingMenuItems.some((item) => (item.can ? item.can(editor) : true)),
|
||||
isActive: (editor) =>
|
||||
headingMenuItems.some((item) => item.isActive?.(editor)),
|
||||
label: $t('ui.tiptap.toolbar.heading'),
|
||||
menu: {
|
||||
items: headingMenuItems,
|
||||
},
|
||||
triggerText: (editor) => getHeadingTriggerText(editor),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleBulletList().run(),
|
||||
active: { name: 'bulletList' },
|
||||
can: (editor) => editor.can().chain().focus().toggleBulletList().run(),
|
||||
icon: List,
|
||||
label: $t('ui.tiptap.toolbar.bulletList'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
||||
active: { name: 'orderedList' },
|
||||
can: (editor) => editor.can().chain().focus().toggleOrderedList().run(),
|
||||
icon: ListOrdered,
|
||||
label: $t('ui.tiptap.toolbar.orderedList'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
||||
active: { name: 'blockquote' },
|
||||
can: (editor) => editor.can().chain().focus().toggleBlockquote().run(),
|
||||
icon: TextQuote,
|
||||
label: $t('ui.tiptap.toolbar.blockquote'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
active: { name: 'codeBlock' },
|
||||
can: (editor) => editor.can().chain().focus().toggleCodeBlock().run(),
|
||||
icon: MessageSquareCode,
|
||||
label: $t('ui.tiptap.toolbar.codeBlock'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
action: (editor) => handleLinkAction(editor),
|
||||
active: { name: 'link' },
|
||||
can: (editor) =>
|
||||
editor.can().chain().focus().extendMarkRange('link').run(),
|
||||
icon: Link2,
|
||||
label: $t('ui.tiptap.toolbar.link'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().unsetLink().run(),
|
||||
can: (editor) => editor.can().chain().focus().unsetLink().run(),
|
||||
icon: Unlink2,
|
||||
isActive: (editor) => editor.isActive('link'),
|
||||
label: $t('ui.tiptap.toolbar.unlink'),
|
||||
},
|
||||
{
|
||||
action: (editor) => handleImageAction(editor),
|
||||
icon: ImagePlus,
|
||||
label: $t('ui.tiptap.toolbar.image'),
|
||||
...(imageUpload
|
||||
? {
|
||||
action: () => {},
|
||||
menu: {
|
||||
items: [
|
||||
{
|
||||
action: (editor) => {
|
||||
if (typeof editor.commands.uploadImage === 'function') {
|
||||
editor.commands.uploadImage();
|
||||
}
|
||||
},
|
||||
label: $t('ui.tiptap.toolbar.imageUpload'),
|
||||
shortLabel: 'UPL',
|
||||
},
|
||||
{
|
||||
action: (editor) => handleImageAction(editor),
|
||||
label: $t('ui.tiptap.toolbar.imageUrl'),
|
||||
shortLabel: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
action: () => {},
|
||||
icon: Paintbrush,
|
||||
indicatorColor: (editor) =>
|
||||
editor.getAttributes('textStyle').color as string | undefined,
|
||||
isActive: (editor) => Boolean(editor.getAttributes('textStyle').color),
|
||||
label: $t('ui.tiptap.toolbar.textColor'),
|
||||
palette: {
|
||||
apply: (editor, color) =>
|
||||
editor.chain().focus().setColor(color).run(),
|
||||
clear: (editor) => editor.chain().focus().unsetColor().run(),
|
||||
colors: editorColorPresets,
|
||||
currentColor: (editor) =>
|
||||
editor.getAttributes('textStyle').color as string | undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: () => {},
|
||||
icon: Highlighter,
|
||||
indicatorColor: (editor) =>
|
||||
(editor.getAttributes('highlight').color as string | undefined) ??
|
||||
'#fef08a',
|
||||
isActive: (editor) => editor.isActive('highlight'),
|
||||
label: $t('ui.tiptap.toolbar.highlightColor'),
|
||||
palette: {
|
||||
apply: (editor, color) =>
|
||||
editor.chain().focus().setHighlight({ color }).run(),
|
||||
clear: (editor) => editor.chain().focus().unsetHighlight().run(),
|
||||
colors: editorHighlightPresets,
|
||||
currentColor: (editor) =>
|
||||
editor.getAttributes('highlight').color as string | undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
action: (editor) => editor.chain().focus().setTextAlign('left').run(),
|
||||
can: (editor) =>
|
||||
editor.can().chain().focus().setTextAlign('left').run(),
|
||||
icon: AlignLeft,
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'left' }),
|
||||
label: $t('ui.tiptap.toolbar.alignLeft'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().setTextAlign('center').run(),
|
||||
can: (editor) =>
|
||||
editor.can().chain().focus().setTextAlign('center').run(),
|
||||
icon: AlignCenter,
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'center' }),
|
||||
label: $t('ui.tiptap.toolbar.alignCenter'),
|
||||
},
|
||||
{
|
||||
action: (editor) => editor.chain().focus().setTextAlign('right').run(),
|
||||
can: (editor) =>
|
||||
editor.can().chain().focus().setTextAlign('right').run(),
|
||||
icon: AlignRight,
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'right' }),
|
||||
label: $t('ui.tiptap.toolbar.alignRight'),
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Extensions, JSONContent } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imageUpload: {
|
||||
uploadImage: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageUploadOptions {
|
||||
/** 允许的文件类型,默认 'image/*' */
|
||||
accept?: string;
|
||||
/** 最大文件大小(字节),默认 5MB */
|
||||
maxSize?: number;
|
||||
/** 上传失败回调,未提供时使用 alert 弹窗提示 */
|
||||
onUploadError?: (error: unknown) => void;
|
||||
/** 上传函数,返回图片 URL,可选 onProgress 回调报告上传进度 */
|
||||
upload: (
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface TipTapProps {
|
||||
editable?: boolean;
|
||||
extensions?: Extensions;
|
||||
imageUpload?: ImageUploadOptions;
|
||||
minHeight?: number | string;
|
||||
maxHeight?: number | string;
|
||||
placeholder?: string;
|
||||
previewable?: boolean;
|
||||
toolbar?: boolean;
|
||||
}
|
||||
|
||||
export interface TipTapPreviewProps {
|
||||
class?: any;
|
||||
content?: string;
|
||||
minHeight?: number | string;
|
||||
}
|
||||
|
||||
export interface VbenTiptapChangeEvent {
|
||||
html: string;
|
||||
json: JSONContent;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface VbenTiptapExtensionOptions {
|
||||
imageUpload?: ImageUploadOptions;
|
||||
/** 内部使用:追踪 blob URL 以便组件销毁时清理 */
|
||||
_blobUrlTracker?: Set<string>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ToolbarAction {
|
||||
action: (editor: Editor) => void;
|
||||
active?: {
|
||||
attrs?: Record<string, unknown>;
|
||||
name: string;
|
||||
};
|
||||
can?: (editor: Editor) => boolean;
|
||||
icon?: Component;
|
||||
indicatorColor?: (editor: Editor) => string | undefined;
|
||||
isActive?: (editor: Editor) => boolean;
|
||||
label: string;
|
||||
menu?: {
|
||||
items: ToolbarMenuItem[];
|
||||
};
|
||||
palette?: {
|
||||
apply: (editor: Editor, color: string) => void;
|
||||
clear?: (editor: Editor) => void;
|
||||
colors: string[];
|
||||
currentColor?: (editor: Editor) => string | undefined;
|
||||
};
|
||||
triggerText?: ((editor?: Editor) => string) | string;
|
||||
}
|
||||
|
||||
export interface ToolbarMenuItem {
|
||||
action: (editor: Editor) => void;
|
||||
can?: (editor: Editor) => boolean;
|
||||
isActive?: (editor: Editor) => boolean;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
import type { ShallowRef } from 'vue';
|
||||
|
||||
import type { ToolbarAction, ToolbarMenuItem } from './types';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface UseTiptapToolbarOptions {
|
||||
editable: () => boolean;
|
||||
editor: Readonly<ShallowRef<Editor | undefined>>;
|
||||
}
|
||||
|
||||
export function useTiptapToolbar(options: UseTiptapToolbarOptions) {
|
||||
const getEditor = () => options.editor.value;
|
||||
|
||||
function getActionIndicatorColor(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !action.indicatorColor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return action.indicatorColor(currentEditor);
|
||||
}
|
||||
|
||||
function getPaletteCurrentColor(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !action.palette?.currentColor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return action.palette.currentColor(currentEditor);
|
||||
}
|
||||
|
||||
function canRunAction(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !options.editable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return action.can ? action.can(currentEditor) : true;
|
||||
}
|
||||
|
||||
function canRunMenuItem(item: ToolbarMenuItem) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !options.editable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.can ? item.can(currentEditor) : true;
|
||||
}
|
||||
|
||||
function isActionActive(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action.isActive) {
|
||||
return action.isActive(currentEditor);
|
||||
}
|
||||
|
||||
if (!action.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentEditor.isActive(action.active.name, action.active.attrs);
|
||||
}
|
||||
|
||||
function isMenuItemActive(item: ToolbarMenuItem, currentEditor?: Editor) {
|
||||
const targetEditor = currentEditor ?? getEditor();
|
||||
|
||||
if (!targetEditor || !item.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.isActive(targetEditor);
|
||||
}
|
||||
|
||||
function runAction(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !options.editable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.menu || action.palette) {
|
||||
return;
|
||||
}
|
||||
|
||||
action.action(currentEditor);
|
||||
}
|
||||
|
||||
function runMenuItem(item: ToolbarMenuItem) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !options.editable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.action(currentEditor);
|
||||
}
|
||||
|
||||
function applyPaletteColor(action: ToolbarAction, color: string) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !action.palette) {
|
||||
return;
|
||||
}
|
||||
|
||||
action.palette.apply(currentEditor, color);
|
||||
}
|
||||
|
||||
function clearPaletteColor(action: ToolbarAction) {
|
||||
const currentEditor = getEditor();
|
||||
|
||||
if (!currentEditor || !action.palette?.clear) {
|
||||
return;
|
||||
}
|
||||
|
||||
action.palette.clear(currentEditor);
|
||||
}
|
||||
|
||||
function getToolbarButtonClass(action: ToolbarAction) {
|
||||
return cn(
|
||||
'text-muted-foreground relative rounded-[10px] border border-transparent bg-transparent shadow-none',
|
||||
'transition-[transform,color,background-color,border-color,box-shadow] duration-200 ease-out',
|
||||
'enabled:hover:border-border enabled:hover:-translate-y-px disabled:opacity-45',
|
||||
'enabled:hover:bg-accent enabled:hover:text-foreground',
|
||||
isActionActive(action) &&
|
||||
'bg-accent border-primary/30 shadow-primary text-primary',
|
||||
);
|
||||
}
|
||||
|
||||
function getPaletteSwatchClass(action: ToolbarAction, color: string) {
|
||||
return cn(
|
||||
'border-border inline-flex size-8 items-center justify-center rounded-full border',
|
||||
'shadow-accent',
|
||||
'transition-[transform,box-shadow,border-color] duration-200 ease-out',
|
||||
'hover:-translate-y-px hover:scale-[1.04]',
|
||||
getPaletteCurrentColor(action) === color &&
|
||||
'border-primary shadow-primary',
|
||||
);
|
||||
}
|
||||
|
||||
function getMenuItemClass(item: ToolbarMenuItem) {
|
||||
return cn(
|
||||
'flex items-center gap-2 rounded-lg p-2 text-left text-sm transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-45',
|
||||
isMenuItemActive(item)
|
||||
? 'bg-accent text-foreground'
|
||||
: 'hover:bg-accent hover:text-foreground text-muted-foreground',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
applyPaletteColor,
|
||||
canRunAction,
|
||||
canRunMenuItem,
|
||||
clearPaletteColor,
|
||||
getActionIndicatorColor,
|
||||
getMenuItemClass,
|
||||
getPaletteCurrentColor,
|
||||
getPaletteSwatchClass,
|
||||
getToolbarButtonClass,
|
||||
isActionActive,
|
||||
isMenuItemActive,
|
||||
runAction,
|
||||
runMenuItem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
export interface VbenPluginsFormOptions {
|
||||
useVbenForm: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
export interface VbenPluginsModalOptions {
|
||||
useVbenModal?: () => any;
|
||||
}
|
||||
|
||||
export interface VbenPluginsMessageOptions {
|
||||
useMessage?: () => any;
|
||||
}
|
||||
|
||||
export interface VbenPluginsComponentsOptions {
|
||||
[key: string]: Component;
|
||||
}
|
||||
|
||||
export interface VbenPluginsOptions {
|
||||
form?: VbenPluginsFormOptions;
|
||||
modal?: VbenPluginsModalOptions;
|
||||
message?: VbenPluginsMessageOptions;
|
||||
components?: VbenPluginsComponentsOptions;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# VXE Table Plugin
|
||||
|
||||
基于 vxe-table 和 vxe-pc-ui 的表格组件插件。
|
||||
|
||||
## 导出
|
||||
|
||||
| 导出 | 类型 | 说明 |
|
||||
| --------------------- | ---- | -------------- |
|
||||
| `setupVbenVxeTable` | 函数 | 初始化配置函数 |
|
||||
| `useVbenVxeGrid` | 函数 | 表格组合式函数 |
|
||||
| `VbenVxeGrid` | 组件 | 表格组件 |
|
||||
| `VxeTableGridColumns` | 类型 | 表格列类型 |
|
||||
| `VxeTableGridOptions` | 类型 | 表格配置类型 |
|
||||
| `VxeGridProps` | 类型 | 表格 Props |
|
||||
| `VxeGridListeners` | 类型 | 表格事件类型 |
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import {
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid,
|
||||
VbenVxeGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
```
|
||||
|
||||
## 初始化
|
||||
|
||||
在应用入口处调用:
|
||||
|
||||
```ts
|
||||
import { setupVbenVxeTable } from '@vben/plugins/vxe-table';
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
|
||||
setupVbenVxeTable({
|
||||
configVxeTable: (vxeUI) => {
|
||||
// 配置 VXE Table
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
```
|
||||
|
||||
## 类型
|
||||
|
||||
```ts
|
||||
import type {
|
||||
VxeTableGridOptions,
|
||||
VxeGridProps,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
```
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { VxeGridInstance } from 'vxe-table';
|
||||
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
FormValues,
|
||||
} from '@vben-core/form-ui';
|
||||
|
||||
import type { VxeGridProps } from './types';
|
||||
import type { ViewedRowHelper } from './viewed-row';
|
||||
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
isBoolean,
|
||||
isFunction,
|
||||
mergeWithArrayOverride,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
function getDefaultState(): VxeGridProps {
|
||||
return {
|
||||
class: '',
|
||||
gridClass: '',
|
||||
gridOptions: {},
|
||||
gridEvents: {},
|
||||
formOptions: undefined,
|
||||
showSearchForm: true,
|
||||
};
|
||||
}
|
||||
|
||||
export class VxeGridApi<
|
||||
T extends Record<string, any> = any,
|
||||
D extends BaseFormComponentType = BaseFormComponentType,
|
||||
P extends Record<string, any> = Record<never, never>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> {
|
||||
public formApi = {} as ExtendedFormApi<TFormValues, D, P, TSubmitValues>;
|
||||
|
||||
// private prevState: null | VxeGridProps = null;
|
||||
public grid = {} as VxeGridInstance<T>;
|
||||
public state: null | VxeGridProps<T, D, P, TFormValues, TSubmitValues> = null;
|
||||
|
||||
public store: Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>;
|
||||
|
||||
/**
|
||||
* 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中)
|
||||
*/
|
||||
public viewedRowHelper: null | ViewedRowHelper<T> = null;
|
||||
|
||||
private isMounted = false;
|
||||
|
||||
private stateHandler: StateHandler;
|
||||
|
||||
constructor(
|
||||
options: VxeGridProps<
|
||||
T,
|
||||
D,
|
||||
P,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
> = {} as VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
|
||||
) {
|
||||
const storeState = { ...options };
|
||||
|
||||
const defaultState = getDefaultState();
|
||||
this.store = new Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>(
|
||||
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<
|
||||
T,
|
||||
D,
|
||||
P,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>,
|
||||
);
|
||||
|
||||
this.store.subscribe((state) => {
|
||||
// this.prevState = this.state;
|
||||
this.state = state;
|
||||
});
|
||||
|
||||
this.state = this.store.state;
|
||||
this.stateHandler = new StateHandler();
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有已读状态
|
||||
*/
|
||||
clearViewedRows() {
|
||||
this.viewedRowHelper?.clearViewed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已读的 key 集合(返回副本,避免外部修改内部状态)
|
||||
*/
|
||||
getViewedKeys(): Set<number | string> {
|
||||
const raw = this.viewedRowHelper?.viewedSet.value;
|
||||
return raw ? new Set(raw) : new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某行是否已读
|
||||
*/
|
||||
isRowViewed(record: T): boolean {
|
||||
return this.viewedRowHelper?.isViewed(record) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量标记行为已读
|
||||
*/
|
||||
markKeysAsViewed(keys: Array<number | string>) {
|
||||
this.viewedRowHelper?.markKeysAsViewed(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记某行为已读
|
||||
*/
|
||||
markRowAsViewed(record: T) {
|
||||
this.viewedRowHelper?.markAsViewed(record);
|
||||
}
|
||||
|
||||
mount(
|
||||
instance: null | VxeGridInstance,
|
||||
formApi: ExtendedFormApi<TFormValues, D, P, TSubmitValues>,
|
||||
) {
|
||||
if (!this.isMounted && instance) {
|
||||
this.grid = instance;
|
||||
this.formApi = formApi;
|
||||
this.stateHandler.setConditionTrue();
|
||||
this.isMounted = true;
|
||||
}
|
||||
}
|
||||
|
||||
async query(params: Record<string, any> = {}) {
|
||||
try {
|
||||
await this.grid.commitProxy('query', toRaw(params));
|
||||
} catch (error) {
|
||||
console.error('Error occurred while querying:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async reload(params: Record<string, any> = {}) {
|
||||
try {
|
||||
await this.grid.commitProxy('reload', toRaw(params));
|
||||
} catch (error) {
|
||||
console.error('Error occurred while reloading:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定 key 的已读状态
|
||||
*/
|
||||
removeViewedKeys(keys: Array<number | string>) {
|
||||
this.viewedRowHelper?.removeKeys(keys);
|
||||
}
|
||||
|
||||
setGridOptions(
|
||||
options: Partial<
|
||||
VxeGridProps<T, D, P, TFormValues, TSubmitValues>['gridOptions']
|
||||
>,
|
||||
) {
|
||||
this.setState({
|
||||
gridOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(isLoading: boolean) {
|
||||
this.setState({
|
||||
gridOptions: {
|
||||
loading: isLoading,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((
|
||||
prev: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
|
||||
) => Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>)
|
||||
| Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState((prev) => {
|
||||
return mergeWithArrayOverride(stateOrFn(prev), prev);
|
||||
});
|
||||
} else {
|
||||
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
|
||||
}
|
||||
}
|
||||
|
||||
toggleSearchForm(show?: boolean) {
|
||||
this.setState({
|
||||
showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm,
|
||||
});
|
||||
// nextTick(() => {
|
||||
// this.grid.recalculate();
|
||||
// });
|
||||
return this.state?.showSearchForm;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
this.viewedRowHelper = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { VxeGridProps, VxeUIExport } from 'vxe-table';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VxeGridApi } from './api';
|
||||
|
||||
import { formatDate, formatDateTime, isFunction } from '@vben/utils';
|
||||
|
||||
export function extendProxyOptions(
|
||||
api: VxeGridApi,
|
||||
options: VxeGridProps,
|
||||
getFormValues: () => Recordable<any>,
|
||||
) {
|
||||
[
|
||||
'query',
|
||||
'querySuccess',
|
||||
'queryError',
|
||||
'queryAll',
|
||||
'queryAllSuccess',
|
||||
'queryAllError',
|
||||
].forEach((key) => {
|
||||
extendProxyOption(key, api, options, getFormValues);
|
||||
});
|
||||
}
|
||||
|
||||
function extendProxyOption(
|
||||
key: string,
|
||||
api: VxeGridApi,
|
||||
options: VxeGridProps,
|
||||
getFormValues: () => Recordable<any>,
|
||||
) {
|
||||
const { proxyConfig } = options;
|
||||
const configFn = (proxyConfig?.ajax as Recordable<any>)?.[key];
|
||||
if (!isFunction(configFn)) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const wrapperFn = async (
|
||||
params: Recordable<any>,
|
||||
customValues: Recordable<any>,
|
||||
...args: Recordable<any>[]
|
||||
) => {
|
||||
const formValues = getFormValues();
|
||||
const data = await configFn(
|
||||
params,
|
||||
{
|
||||
/**
|
||||
* 开启toolbarConfig.refresh功能
|
||||
* 点击刷新按钮 这里的值为PointerEvent 会携带错误参数
|
||||
*/
|
||||
...(customValues instanceof PointerEvent ? {} : customValues),
|
||||
...formValues,
|
||||
},
|
||||
...args,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
api.setState({
|
||||
gridOptions: {
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
[key]: wrapperFn,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function extendsDefaultFormatter(vxeUI: VxeUIExport) {
|
||||
vxeUI.formats.add('formatDate', {
|
||||
tableCellFormatMethod({ cellValue }) {
|
||||
return formatDate(cellValue);
|
||||
},
|
||||
});
|
||||
|
||||
vxeUI.formats.add('formatDateTime', {
|
||||
tableCellFormatMethod({ cellValue }) {
|
||||
return formatDateTime(cellValue);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { setupVbenVxeTable } from './init';
|
||||
export type { VxeTableGridColumns, VxeTableGridOptions } from './types';
|
||||
export * from './use-vxe-grid';
|
||||
|
||||
export { default as VbenVxeGrid } from './use-vxe-grid.vue';
|
||||
export type {
|
||||
VxeGridListeners,
|
||||
VxeGridProps,
|
||||
VxeGridPropTypes,
|
||||
} from 'vxe-table';
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { SetupVxeTable } from './types';
|
||||
|
||||
import { defineComponent, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import {
|
||||
VxeButton,
|
||||
VxeCheckbox,
|
||||
VxeIcon,
|
||||
VxeInput,
|
||||
VxeLoading,
|
||||
VxeModal,
|
||||
VxeNumberInput,
|
||||
VxePager,
|
||||
VxeRadioGroup,
|
||||
VxeSelect,
|
||||
VxeTooltip,
|
||||
VxeUI,
|
||||
VxeUpload,
|
||||
} from 'vxe-pc-ui';
|
||||
import enUS from 'vxe-pc-ui/lib/language/en-US'; // 导入默认的语言
|
||||
import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
|
||||
import {
|
||||
VxeColgroup,
|
||||
VxeColumn,
|
||||
VxeGrid,
|
||||
VxeTable,
|
||||
VxeToolbar,
|
||||
} from 'vxe-table';
|
||||
|
||||
import { injectPluginsOptions } from '../plugins-context';
|
||||
import { extendsDefaultFormatter } from './extends'; // 是否加载过
|
||||
|
||||
// 是否加载过
|
||||
let isInit = false;
|
||||
|
||||
let tableFormFactory: ((...args: any[]) => any) | undefined;
|
||||
|
||||
function normalizeVxeLocale<T extends Record<string, any>>(localeModule: T) {
|
||||
return (
|
||||
localeModule &&
|
||||
typeof localeModule === 'object' &&
|
||||
'default' in localeModule
|
||||
? localeModule.default
|
||||
: localeModule
|
||||
) as T;
|
||||
}
|
||||
|
||||
export function useTableForm(...args: any[]) {
|
||||
const pluginsOptions = injectPluginsOptions();
|
||||
const contextFormFactory = pluginsOptions?.form?.useVbenForm;
|
||||
|
||||
const factory = tableFormFactory || contextFormFactory;
|
||||
if (!factory) {
|
||||
throw new Error(
|
||||
'useTableForm is not initialized. Please provide useVbenForm via setupVbenVxeTable() or providePluginsOptions()',
|
||||
);
|
||||
}
|
||||
|
||||
return factory(...args);
|
||||
}
|
||||
|
||||
// 部分组件,如果没注册,vxe-table 会报错,这里实际没用组件,只是为了不报错,同时可以减少打包体积
|
||||
const createVirtualComponent = (name = '') => {
|
||||
return defineComponent({
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
export function initVxeTable() {
|
||||
if (isInit) {
|
||||
return;
|
||||
}
|
||||
|
||||
VxeUI.component(VxeTable);
|
||||
VxeUI.component(VxeColumn);
|
||||
VxeUI.component(VxeColgroup);
|
||||
VxeUI.component(VxeGrid);
|
||||
VxeUI.component(VxeToolbar);
|
||||
|
||||
VxeUI.component(VxeButton);
|
||||
// VxeUI.component(VxeButtonGroup);
|
||||
VxeUI.component(VxeCheckbox);
|
||||
// VxeUI.component(VxeCheckboxGroup);
|
||||
VxeUI.component(createVirtualComponent('VxeForm'));
|
||||
// VxeUI.component(VxeFormGather);
|
||||
// VxeUI.component(VxeFormItem);
|
||||
VxeUI.component(VxeIcon);
|
||||
VxeUI.component(VxeInput);
|
||||
// VxeUI.component(VxeList);
|
||||
VxeUI.component(VxeLoading);
|
||||
VxeUI.component(VxeModal);
|
||||
VxeUI.component(VxeNumberInput);
|
||||
// VxeUI.component(VxeOptgroup);
|
||||
// VxeUI.component(VxeOption);
|
||||
VxeUI.component(VxePager);
|
||||
// VxeUI.component(VxePulldown);
|
||||
// VxeUI.component(VxeRadio);
|
||||
// VxeUI.component(VxeRadioButton);
|
||||
VxeUI.component(VxeRadioGroup);
|
||||
VxeUI.component(VxeSelect);
|
||||
// VxeUI.component(VxeSwitch);
|
||||
// VxeUI.component(VxeTextarea);
|
||||
VxeUI.component(VxeTooltip);
|
||||
VxeUI.component(VxeUpload);
|
||||
|
||||
isInit = true;
|
||||
}
|
||||
|
||||
export function setupVbenVxeTable(setupOptions: SetupVxeTable) {
|
||||
const { configVxeTable, useVbenForm: useVbenFormFromParam } = setupOptions;
|
||||
|
||||
initVxeTable();
|
||||
|
||||
// 优先使用参数传入的 useVbenForm,否则清空让 context 注入生效
|
||||
if (useVbenFormFromParam) {
|
||||
tableFormFactory = useVbenFormFromParam;
|
||||
}
|
||||
const { isDark, locale } = usePreferences();
|
||||
|
||||
const localMap = {
|
||||
'zh-CN': normalizeVxeLocale(zhCN),
|
||||
'en-US': normalizeVxeLocale(enUS),
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => isDark.value, () => locale.value],
|
||||
([isDarkValue, localeValue]) => {
|
||||
VxeUI.setTheme(isDarkValue ? 'dark' : 'light');
|
||||
VxeUI.setI18n(localeValue, localMap[localeValue]);
|
||||
VxeUI.setLanguage(localeValue);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
extendsDefaultFormatter(VxeUI);
|
||||
|
||||
configVxeTable(VxeUI);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@reference "@vben/tailwind-config/theme";
|
||||
|
||||
:root .vxe-grid {
|
||||
--vxe-ui-font-color: hsl(var(--foreground));
|
||||
--vxe-ui-font-primary-color: hsl(var(--primary));
|
||||
|
||||
/* --vxe-ui-font-lighten-color: #babdc0;
|
||||
--vxe-ui-font-darken-color: #86898e; */
|
||||
--vxe-ui-font-disabled-color: hsl(var(--foreground) / 50%);
|
||||
|
||||
/* base */
|
||||
--vxe-ui-base-popup-border-color: hsl(var(--border));
|
||||
--vxe-ui-input-disabled-color: hsl(var(--border) / 60%);
|
||||
|
||||
/* --vxe-ui-base-popup-box-shadow: 0px 12px 30px 8px rgb(0 0 0 / 50%); */
|
||||
|
||||
/* layout */
|
||||
--vxe-ui-layout-background-color: hsl(var(--background));
|
||||
--vxe-ui-table-resizable-line-color: hsl(var(--heavy));
|
||||
|
||||
/* --vxe-ui-table-fixed-left-scrolling-box-shadow: 8px 0px 10px -5px hsl(var(--accent));
|
||||
--vxe-ui-table-fixed-right-scrolling-box-shadow: -8px 0px 10px -5px hsl(var(--accent)); */
|
||||
|
||||
/* input */
|
||||
--vxe-ui-input-border-color: hsl(var(--border));
|
||||
|
||||
/* --vxe-ui-input-placeholder-color: #8d9095; */
|
||||
|
||||
/* --vxe-ui-input-disabled-background-color: #262727; */
|
||||
|
||||
/* loading */
|
||||
--vxe-ui-loading-background-color: hsl(var(--overlay-content));
|
||||
|
||||
/* table */
|
||||
--vxe-ui-table-header-background-color: hsl(var(--accent));
|
||||
--vxe-ui-table-border-color: hsl(var(--border));
|
||||
--vxe-ui-table-row-hover-background-color: hsl(var(--accent-hover));
|
||||
--vxe-ui-table-row-striped-background-color: hsl(var(--accent) / 60%);
|
||||
--vxe-ui-table-row-hover-striped-background-color: hsl(var(--accent));
|
||||
--vxe-ui-table-row-radio-checked-background-color: hsl(var(--accent));
|
||||
--vxe-ui-table-row-hover-radio-checked-background-color: hsl(
|
||||
var(--accent-hover)
|
||||
);
|
||||
--vxe-ui-table-row-checkbox-checked-background-color: hsl(var(--accent));
|
||||
--vxe-ui-table-row-hover-checkbox-checked-background-color: hsl(
|
||||
var(--accent-hover)
|
||||
);
|
||||
--vxe-ui-table-row-current-background-color: hsl(var(--accent));
|
||||
--vxe-ui-table-row-hover-current-background-color: hsl(var(--accent-hover));
|
||||
--vxe-ui-font-primary-tinge-color: hsl(var(--primary));
|
||||
--vxe-ui-font-primary-lighten-color: hsl(var(--primary) / 60%);
|
||||
--vxe-ui-font-primary-darken-color: hsl(var(--primary));
|
||||
|
||||
height: auto !important;
|
||||
|
||||
/* --vxe-ui-table-fixed-scrolling-box-shadow-color: rgb(0 0 0 / 80%); */
|
||||
}
|
||||
|
||||
.vxe-pager {
|
||||
.vxe-pager--prev-btn:not(.is--disabled):active,
|
||||
.vxe-pager--next-btn:not(.is--disabled):active,
|
||||
.vxe-pager--num-btn:not(.is--disabled):active,
|
||||
.vxe-pager--jump-prev:not(.is--disabled):active,
|
||||
.vxe-pager--jump-next:not(.is--disabled):active,
|
||||
.vxe-pager--prev-btn:not(.is--disabled):focus,
|
||||
.vxe-pager--next-btn:not(.is--disabled):focus,
|
||||
.vxe-pager--num-btn:not(.is--disabled):focus,
|
||||
.vxe-pager--jump-prev:not(.is--disabled):focus,
|
||||
.vxe-pager--jump-next:not(.is--disabled):focus {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow: 0 0 0 1px hsl(var(--border));
|
||||
}
|
||||
|
||||
.vxe-pager--wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vxe-pager--sizes {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.vxe-pager--wrapper {
|
||||
@apply justify-center md:justify-end;
|
||||
}
|
||||
|
||||
.vxe-tools--operate {
|
||||
margin-right: 0.25rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.vxe-table-custom--checkbox-option:hover {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.vxe-toolbar {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vxe-buttons--wrapper:not(:empty),
|
||||
.vxe-tools--operate:not(:empty),
|
||||
.vxe-tools--wrapper:not(:empty) {
|
||||
padding: 0.6em 0;
|
||||
}
|
||||
|
||||
.vxe-tools--operate:not(:has(button)) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.vxe-grid--layout-header-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vxe-grid--layout-body-content-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 已读行默认样式 */
|
||||
.vxe-row--viewed {
|
||||
color: hsl(var(--foreground) / 50%);
|
||||
|
||||
.vxe-body--column {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
VxeGridListeners,
|
||||
VxeGridPropTypes,
|
||||
VxeGridProps as VxeTableGridProps,
|
||||
VxeUIExport,
|
||||
} from 'vxe-table';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ClassType, DeepPartial } from '@vben/types';
|
||||
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
FormValues,
|
||||
VbenFormProps,
|
||||
} from '@vben-core/form-ui';
|
||||
|
||||
import type { VxeGridApi } from './api';
|
||||
import type { ViewedRowOptions } from './viewed-row';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
|
||||
export interface VxePaginationInfo {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ToolbarConfigOptions extends VxeGridPropTypes.ToolbarConfig {
|
||||
/** 是否显示切换搜索表单的按钮 */
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export type VxeTableGridColumns<T = any> = VxeTableGridOptions<T>['columns'];
|
||||
|
||||
export interface VxeTableGridOptions<T = any> extends VxeTableGridProps<T> {
|
||||
/** 工具栏配置 */
|
||||
toolbarConfig?: ToolbarConfigOptions;
|
||||
}
|
||||
|
||||
export interface SeparatorOptions {
|
||||
show?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface VxeGridProps<
|
||||
T extends Record<string, any> = any,
|
||||
D extends BaseFormComponentType = BaseFormComponentType,
|
||||
P extends Record<string, any> = Record<never, never>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> {
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
tableData?: any[];
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
tableTitle?: string;
|
||||
/**
|
||||
* 标题帮助
|
||||
*/
|
||||
tableTitleHelp?: string;
|
||||
/**
|
||||
* 组件class
|
||||
*/
|
||||
class?: ClassType;
|
||||
/**
|
||||
* vxe-grid class
|
||||
*/
|
||||
gridClass?: ClassType;
|
||||
/**
|
||||
* vxe-grid 配置
|
||||
*/
|
||||
gridOptions?: DeepPartial<VxeTableGridOptions<T>>;
|
||||
/**
|
||||
* vxe-grid 事件
|
||||
*/
|
||||
gridEvents?: DeepPartial<VxeGridListeners<T>>;
|
||||
/**
|
||||
* 表单配置
|
||||
*/
|
||||
formOptions?: VbenFormProps<D, P, TFormValues, TSubmitValues>;
|
||||
/**
|
||||
* 显示搜索表单
|
||||
*/
|
||||
showSearchForm?: boolean;
|
||||
/**
|
||||
* 搜索表单与表格主体之间的分隔条
|
||||
*/
|
||||
separator?: boolean | SeparatorOptions;
|
||||
/**
|
||||
* 已读行功能
|
||||
*/
|
||||
viewedRowOptions?: boolean | ViewedRowOptions<T>;
|
||||
}
|
||||
|
||||
export type ExtendedVxeGridApi<
|
||||
D extends Record<string, any> = any,
|
||||
F extends BaseFormComponentType = BaseFormComponentType,
|
||||
P extends Record<string, any> = Record<never, never>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> = VxeGridApi<D, F, P, TFormValues, TSubmitValues> & {
|
||||
useStore: <S = NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>>(
|
||||
selector?: (
|
||||
state: NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>,
|
||||
) => S,
|
||||
) => Readonly<Ref<S>>;
|
||||
};
|
||||
|
||||
export interface SetupVxeTable {
|
||||
configVxeTable: (ui: VxeUIExport) => void;
|
||||
useVbenForm?: typeof useVbenForm;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
|
||||
|
||||
import type { SlotsType } from 'vue';
|
||||
|
||||
import type { BaseFormComponentType, FormValues } from '@vben-core/form-ui';
|
||||
|
||||
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
|
||||
|
||||
import { defineComponent, h, onBeforeUnmount } from 'vue';
|
||||
|
||||
import { useStore } from '@vben-core/shared/store';
|
||||
|
||||
import { VxeGridApi } from './api';
|
||||
import VxeGrid from './use-vxe-grid.vue';
|
||||
|
||||
type FilteredSlots<T> = {
|
||||
[K in keyof VxeGridSlots<T> as K extends 'form'
|
||||
? never
|
||||
: K]: VxeGridSlots<T>[K];
|
||||
};
|
||||
|
||||
export function useVbenVxeGrid<
|
||||
T extends Record<string, any> = any,
|
||||
D extends BaseFormComponentType = BaseFormComponentType,
|
||||
P extends Record<string, any> = Record<never, never>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(options: VxeGridProps<T, D, P, TFormValues, TSubmitValues>) {
|
||||
// const IS_REACTIVE = isReactive(options);
|
||||
const api = new VxeGridApi<T, D, P, TFormValues, TSubmitValues>(options);
|
||||
const extendedApi: ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues> =
|
||||
api as ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues>;
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Grid = defineComponent(
|
||||
(
|
||||
props: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
|
||||
{ attrs, slots },
|
||||
) => {
|
||||
onBeforeUnmount(() => {
|
||||
api.unmount();
|
||||
});
|
||||
api.setState({ ...props, ...attrs } as Partial<
|
||||
VxeGridProps<T, D, P, TFormValues, TSubmitValues>
|
||||
>);
|
||||
return () =>
|
||||
h(
|
||||
VxeGrid,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
api: extendedApi as ExtendedVxeGridApi,
|
||||
} as any,
|
||||
slots,
|
||||
);
|
||||
},
|
||||
{
|
||||
name: 'VbenVxeGrid',
|
||||
inheritAttrs: false,
|
||||
slots: Object as SlotsType<
|
||||
{
|
||||
// 表格标题
|
||||
'table-title': undefined;
|
||||
// 工具栏左侧部分
|
||||
'toolbar-actions': VxeGridSlotTypes.DefaultSlotParams<T>;
|
||||
// 工具栏右侧部分
|
||||
'toolbar-tools': VxeGridSlotTypes.DefaultSlotParams<T>;
|
||||
} & FilteredSlots<T>
|
||||
>,
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
// if (IS_REACTIVE) {
|
||||
// watch(
|
||||
// () => options,
|
||||
// () => {
|
||||
// api.setState(options);
|
||||
// },
|
||||
// { immediate: true },
|
||||
// );
|
||||
// }
|
||||
|
||||
return [Grid, extendedApi] as const;
|
||||
}
|
||||
|
||||
export type UseVbenVxeGrid = typeof useVbenVxeGrid;
|
||||
@@ -0,0 +1,514 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
VxeGridDefines,
|
||||
VxeGridInstance,
|
||||
VxeGridListeners,
|
||||
VxeGridPropTypes,
|
||||
VxeGridProps as VxeTableGridProps,
|
||||
VxeToolbarPropTypes,
|
||||
} from 'vxe-table';
|
||||
|
||||
import type { SetupContext } from 'vue';
|
||||
|
||||
import type { VbenFormProps } from '@vben-core/form-ui';
|
||||
|
||||
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toRaw,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { usePriorityValues } from '@vben/hooks';
|
||||
import { EmptyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import {
|
||||
cloneDeep,
|
||||
cn,
|
||||
isBoolean,
|
||||
isEqual,
|
||||
mergeWithArrayOverride,
|
||||
} from '@vben/utils';
|
||||
|
||||
import { VbenHelpTooltip, VbenLoading } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { VxeButton } from 'vxe-pc-ui';
|
||||
import { VxeGrid, VxeUI } from 'vxe-table';
|
||||
|
||||
import { extendProxyOptions } from './extends';
|
||||
import { useTableForm } from './init';
|
||||
import { applyViewedRowOptions, useViewedRow } from './viewed-row';
|
||||
|
||||
import 'vxe-table/styles/cssvar.scss';
|
||||
import 'vxe-pc-ui/styles/cssvar.scss';
|
||||
import './style.css';
|
||||
|
||||
interface Props extends VxeGridProps {
|
||||
api: ExtendedVxeGridApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const FORM_SLOT_PREFIX = 'form-';
|
||||
|
||||
const TOOLBAR_ACTIONS = 'toolbar-actions';
|
||||
const TOOLBAR_TOOLS = 'toolbar-tools';
|
||||
const TABLE_TITLE = 'table-title';
|
||||
|
||||
const gridRef = useTemplateRef<VxeGridInstance>('gridRef');
|
||||
|
||||
const state = props.api?.useStore?.();
|
||||
|
||||
const {
|
||||
gridOptions,
|
||||
class: className,
|
||||
gridClass,
|
||||
gridEvents,
|
||||
formOptions,
|
||||
tableTitle,
|
||||
tableData,
|
||||
tableTitleHelp,
|
||||
showSearchForm,
|
||||
separator,
|
||||
viewedRowOptions,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
// viewedRowOptions:helper 只创建一次(persist/keyField 不支持运行时切换)
|
||||
// actionCodes、rowClassName、rowStyle、viewedKeys 的变化通过 options computed 自然响应
|
||||
const gridApi = props.api;
|
||||
|
||||
watch(
|
||||
viewedRowOptions,
|
||||
(cfg) => {
|
||||
// helper 已存在则不重建
|
||||
if (gridApi.viewedRowHelper) return;
|
||||
|
||||
if (!cfg) return;
|
||||
|
||||
const keyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id';
|
||||
const resolved = isBoolean(cfg) ? { keyField } : { keyField, ...cfg };
|
||||
gridApi.viewedRowHelper = useViewedRow(resolved);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const { isMobile } = usePreferences();
|
||||
const isSeparator = computed(() => {
|
||||
if (
|
||||
!formOptions.value ||
|
||||
showSearchForm.value === false ||
|
||||
separator.value === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (separator.value === true || separator.value === undefined) {
|
||||
return true;
|
||||
}
|
||||
return separator.value.show !== false;
|
||||
});
|
||||
const separatorBg = computed(() => {
|
||||
return !separator.value ||
|
||||
isBoolean(separator.value) ||
|
||||
!separator.value.backgroundColor
|
||||
? undefined
|
||||
: separator.value.backgroundColor;
|
||||
});
|
||||
const slots: SetupContext['slots'] = useSlots();
|
||||
|
||||
const [Form, formApi] = useTableForm({
|
||||
compact: true,
|
||||
handleSubmit: async () => {
|
||||
const formValues = await formApi.getValues();
|
||||
formApi.setLatestSubmissionValues(toRaw(formValues));
|
||||
props.api.reload(formValues);
|
||||
},
|
||||
handleReset: async () => {
|
||||
const prevValues = await formApi.getValues();
|
||||
await formApi.reset();
|
||||
const formValues = await formApi.getValues();
|
||||
formApi.setLatestSubmissionValues(formValues);
|
||||
// 如果值发生了变化,submitOnChange会触发刷新。所以只在submitOnChange为false或者值没有发生变化时,手动刷新
|
||||
if (isEqual(prevValues, formValues) || !formOptions.value?.submitOnChange) {
|
||||
props.api.reload(formValues);
|
||||
}
|
||||
},
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: computed(() => $t('common.search')),
|
||||
},
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
});
|
||||
|
||||
const showTableTitle = computed(() => {
|
||||
return !!slots[TABLE_TITLE]?.() || tableTitle.value;
|
||||
});
|
||||
|
||||
const showToolbar = computed(() => {
|
||||
return (
|
||||
!!slots[TOOLBAR_ACTIONS]?.() ||
|
||||
!!slots[TOOLBAR_TOOLS]?.() ||
|
||||
showTableTitle.value
|
||||
);
|
||||
});
|
||||
|
||||
const toolbarOptions = computed(() => {
|
||||
const slotActions = slots[TOOLBAR_ACTIONS]?.();
|
||||
const slotTools = slots[TOOLBAR_TOOLS]?.();
|
||||
const searchBtn: VxeToolbarPropTypes.ToolConfig = {
|
||||
code: 'search',
|
||||
icon: 'vxe-icon-search',
|
||||
circle: true,
|
||||
status: showSearchForm.value ? 'primary' : undefined,
|
||||
title: showSearchForm.value
|
||||
? $t('common.hideSearchPanel')
|
||||
: $t('common.showSearchPanel'),
|
||||
};
|
||||
// 将搜索按钮合并到用户配置的toolbarConfig.tools中
|
||||
const toolbarConfig: VxeGridPropTypes.ToolbarConfig = {
|
||||
tools: (gridOptions.value?.toolbarConfig?.tools ??
|
||||
[]) as VxeToolbarPropTypes.ToolConfig[],
|
||||
};
|
||||
if (gridOptions.value?.toolbarConfig?.search && !!formOptions.value) {
|
||||
toolbarConfig.tools = Array.isArray(toolbarConfig.tools)
|
||||
? [...toolbarConfig.tools, searchBtn]
|
||||
: [searchBtn];
|
||||
}
|
||||
|
||||
if (!showToolbar.value) {
|
||||
toolbarConfig.enabled = false;
|
||||
return { toolbarConfig };
|
||||
}
|
||||
|
||||
// 强制使用固定的toolbar配置,不允许用户自定义
|
||||
// 减少配置的复杂度,以及后续维护的成本
|
||||
toolbarConfig.slots = {
|
||||
...(slotActions || showTableTitle.value
|
||||
? { buttons: TOOLBAR_ACTIONS }
|
||||
: {}),
|
||||
...(slotTools ? { tools: TOOLBAR_TOOLS } : {}),
|
||||
};
|
||||
return { toolbarConfig };
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
|
||||
|
||||
const mergedOptions: VxeTableGridProps = cloneDeep(
|
||||
mergeWithArrayOverride(
|
||||
{},
|
||||
toRaw(toolbarOptions.value),
|
||||
toRaw(gridOptions.value),
|
||||
globalGridConfig,
|
||||
),
|
||||
);
|
||||
|
||||
if (mergedOptions.proxyConfig) {
|
||||
const { ajax } = mergedOptions.proxyConfig;
|
||||
mergedOptions.proxyConfig.enabled = !!ajax;
|
||||
// 不自动加载数据, 由组件控制
|
||||
mergedOptions.proxyConfig.autoLoad = false;
|
||||
}
|
||||
|
||||
if (mergedOptions.pagerConfig) {
|
||||
const mobileLayouts = [
|
||||
'PrevJump',
|
||||
'PrevPage',
|
||||
'Number',
|
||||
'NextPage',
|
||||
'NextJump',
|
||||
] as any;
|
||||
const layouts = [
|
||||
'Total',
|
||||
'Sizes',
|
||||
'Home',
|
||||
...mobileLayouts,
|
||||
'End',
|
||||
] as readonly string[];
|
||||
mergedOptions.pagerConfig = mergeWithArrayOverride(
|
||||
{},
|
||||
mergedOptions.pagerConfig,
|
||||
{
|
||||
pageSize: 20,
|
||||
background: true,
|
||||
pageSizes: [10, 20, 30, 50, 100, 200],
|
||||
className: 'mt-2 w-full',
|
||||
layouts: isMobile.value ? mobileLayouts : layouts,
|
||||
size: 'mini' as const,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (mergedOptions.formConfig) {
|
||||
mergedOptions.formConfig.enabled = false;
|
||||
}
|
||||
if (tableData.value && tableData.value.length > 0) {
|
||||
mergedOptions.data = tableData.value;
|
||||
}
|
||||
|
||||
// 注入已读行功能(rowClassName、rowStyle、columns 拦截)
|
||||
if (viewedRowOptions.value && gridApi.viewedRowHelper) {
|
||||
applyViewedRowOptions(
|
||||
mergedOptions,
|
||||
viewedRowOptions.value,
|
||||
gridApi.viewedRowHelper,
|
||||
);
|
||||
}
|
||||
|
||||
return mergedOptions;
|
||||
});
|
||||
|
||||
function onToolbarToolClick(event: VxeGridDefines.ToolbarToolClickEventParams) {
|
||||
if (event.code === 'search') {
|
||||
onSearchBtnClick();
|
||||
}
|
||||
(
|
||||
gridEvents.value?.toolbarToolClick as VxeGridListeners['toolbarToolClick']
|
||||
)?.(event);
|
||||
}
|
||||
|
||||
function onSearchBtnClick() {
|
||||
props.api?.toggleSearchForm?.();
|
||||
}
|
||||
|
||||
const events = computed(() => {
|
||||
return {
|
||||
...gridEvents.value,
|
||||
toolbarToolClick: onToolbarToolClick,
|
||||
};
|
||||
});
|
||||
|
||||
const delegatedSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (
|
||||
!['empty', 'form', 'loading', TOOLBAR_ACTIONS, TOOLBAR_TOOLS].includes(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots;
|
||||
});
|
||||
|
||||
const delegatedFormSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (key.startsWith(FORM_SLOT_PREFIX)) {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots.map((key) => key.replace(FORM_SLOT_PREFIX, ''));
|
||||
});
|
||||
|
||||
const showDefaultEmpty = computed(() => {
|
||||
// 检查是否有原生的 VXE Table 空状态配置
|
||||
const hasEmptyText = options.value.emptyText !== undefined;
|
||||
const hasEmptyRender = options.value.emptyRender !== undefined;
|
||||
|
||||
// 如果有原生配置,就不显示默认的空状态
|
||||
return !hasEmptyText && !hasEmptyRender;
|
||||
});
|
||||
|
||||
async function init() {
|
||||
await nextTick();
|
||||
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
|
||||
const defaultGridOptions: VxeTableGridProps = mergeWithArrayOverride(
|
||||
{},
|
||||
toRaw(gridOptions.value),
|
||||
toRaw(globalGridConfig),
|
||||
);
|
||||
// 内部主动加载数据,防止form的默认值影响
|
||||
const autoLoad = defaultGridOptions.proxyConfig?.autoLoad;
|
||||
const enableProxyConfig = options.value.proxyConfig?.enabled;
|
||||
if (enableProxyConfig && autoLoad) {
|
||||
props.api.grid.commitProxy?.(
|
||||
'query',
|
||||
formOptions.value ? ((await formApi.getValues()) ?? {}) : {},
|
||||
);
|
||||
}
|
||||
|
||||
// form 由 vben-form代替,所以不适配formConfig,这里给出警告
|
||||
const formConfig = gridOptions.value?.formConfig;
|
||||
// 处理某个页面加载多个Table时,第2个之后的Table初始化报出警告
|
||||
// 因为第一次初始化之后会把defaultGridOptions和gridOptions合并后缓存进State
|
||||
if (formConfig && formConfig.enabled) {
|
||||
console.warn(
|
||||
'[Vben Vxe Table]: The formConfig in the grid is not supported, please use the `formOptions` props',
|
||||
);
|
||||
}
|
||||
props.api?.setState?.({ gridOptions: defaultGridOptions });
|
||||
// form 由 vben-form 代替,所以需要保证query相关事件可以拿到参数
|
||||
extendProxyOptions(props.api, defaultGridOptions, () =>
|
||||
formApi.getLatestSubmissionValues(),
|
||||
);
|
||||
}
|
||||
|
||||
// formOptions支持响应式
|
||||
watch(
|
||||
formOptions,
|
||||
() => {
|
||||
formApi.setState((prev: Record<string, any>) => {
|
||||
const finalFormOptions: VbenFormProps = mergeWithArrayOverride(
|
||||
{},
|
||||
formOptions.value,
|
||||
prev,
|
||||
);
|
||||
return {
|
||||
...finalFormOptions,
|
||||
collapseTriggerResize: !!finalFormOptions.showCollapseButton,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const isCompactForm = computed(() => {
|
||||
return formApi.getState()?.compact;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
props.api?.mount?.(gridRef.value, formApi);
|
||||
init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
formApi?.unmount?.();
|
||||
props.api?.unmount?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('h-full rounded-md bg-card', className)">
|
||||
<VxeGrid
|
||||
ref="gridRef"
|
||||
:class="
|
||||
cn(
|
||||
'p-2',
|
||||
{
|
||||
'pt-0': showToolbar && !formOptions,
|
||||
},
|
||||
gridClass,
|
||||
)
|
||||
"
|
||||
v-bind="options"
|
||||
v-on="events"
|
||||
>
|
||||
<!-- 左侧操作区域或者title -->
|
||||
<template v-if="showToolbar" #toolbar-actions="slotProps">
|
||||
<slot v-if="showTableTitle" name="table-title">
|
||||
<div class="flex-center gap-1 text-[1rem] font-bold">
|
||||
{{ tableTitle }}
|
||||
<VbenHelpTooltip v-if="tableTitleHelp">
|
||||
{{ tableTitleHelp }}
|
||||
</VbenHelpTooltip>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="toolbar-actions" v-bind="slotProps"> </slot>
|
||||
</template>
|
||||
|
||||
<!-- 继承默认的slot -->
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #toolbar-tools="slotProps">
|
||||
<slot name="toolbar-tools" v-bind="slotProps"></slot>
|
||||
<VxeButton
|
||||
icon="vxe-icon-search"
|
||||
circle
|
||||
class="ml-2"
|
||||
v-if="gridOptions?.toolbarConfig?.search && !!formOptions"
|
||||
:status="showSearchForm ? 'primary' : undefined"
|
||||
:title="$t('common.search')"
|
||||
@click="onSearchBtnClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- form表单 -->
|
||||
<template #form>
|
||||
<div
|
||||
v-if="formOptions"
|
||||
v-show="showSearchForm !== false"
|
||||
:class="
|
||||
cn(
|
||||
'relative rounded-sm py-3',
|
||||
isCompactForm
|
||||
? isSeparator
|
||||
? 'pb-8'
|
||||
: 'pb-4'
|
||||
: isSeparator
|
||||
? 'pb-4'
|
||||
: 'pb-0',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="form">
|
||||
<Form>
|
||||
<template
|
||||
v-for="slotName in delegatedFormSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="`${FORM_SLOT_PREFIX}${slotName}`"
|
||||
v-bind="slotProps"
|
||||
></slot>
|
||||
</template>
|
||||
<template #reset-before="slotProps">
|
||||
<slot name="reset-before" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #submit-before="slotProps">
|
||||
<slot name="submit-before" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #expand-before="slotProps">
|
||||
<slot name="expand-before" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #expand-after="slotProps">
|
||||
<slot name="expand-after" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</Form>
|
||||
</slot>
|
||||
<div
|
||||
v-if="isSeparator"
|
||||
:style="{
|
||||
...(separatorBg ? { backgroundColor: separatorBg } : undefined),
|
||||
}"
|
||||
class="absolute bottom-1 -left-2 z-100 h-2 w-[calc(100%+1rem)] overflow-hidden bg-background-deep md:bottom-2 md:h-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- loading -->
|
||||
<template #loading>
|
||||
<slot name="loading">
|
||||
<VbenLoading :spinning="true" />
|
||||
</slot>
|
||||
</template>
|
||||
<!-- 统一控状态 -->
|
||||
<template v-if="showDefaultEmpty" #empty>
|
||||
<slot name="empty">
|
||||
<EmptyIcon class="mx-auto" />
|
||||
<div class="mt-2">{{ $t('common.noData') }}</div>
|
||||
</slot>
|
||||
</template>
|
||||
</VxeGrid>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
ViewedRowOptions,
|
||||
ViewedRowPersistOptions,
|
||||
ViewedRowStorageAdapter,
|
||||
} from './types';
|
||||
|
||||
export { applyViewedRowOptions, useViewedRow } from './use-viewed-row';
|
||||
export type { ViewedRowHelper } from './use-viewed-row';
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { VxeTablePropTypes } from 'vxe-table';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 自定义存储适配器接口
|
||||
* 用户可接入任意后端(API、IndexedDB wrapper、第三方库等)
|
||||
*/
|
||||
export interface ViewedRowStorageAdapter {
|
||||
/** 读取所有已查看的 key 列表 */
|
||||
getKeys(): Promise<Array<number | string>>;
|
||||
|
||||
/** 移除所有已查看数据 */
|
||||
removeKeys(): Promise<void>;
|
||||
|
||||
/** 持久化已查看的 key 列表 */
|
||||
setKeys(keys: Array<number | string>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已读行持久化 — 公共基础字段
|
||||
*/
|
||||
interface ViewedRowPersistBase {
|
||||
/** 持久化数据的存活时间(毫秒) */
|
||||
ttl?: number;
|
||||
/** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已读行持久化配置(按 type 区分的联合类型)
|
||||
*
|
||||
* - 'memory' → 仅内存,不持久化
|
||||
* - 'localStorage' → 使用 localStorage 整体存储,key 必传
|
||||
* - 'sessionStorage' → 使用 sessionStorage 整体存储,key 必传
|
||||
* - 'indexedDB' → 使用 IndexedDB 单条存储,key 必传
|
||||
* - 'custom' → 用户自定义存储适配器,storage 必传
|
||||
*/
|
||||
export type ViewedRowPersistOptions =
|
||||
| ({
|
||||
/** IndexedDB 数据库名称,默认 'viewed-table-db' */
|
||||
dbName?: string;
|
||||
/** IndexedDB 数据库版本,默认 1 */
|
||||
dbVersion?: number;
|
||||
/** 存储 key / prefix(必传) */
|
||||
key: string;
|
||||
/** IndexedDB 对象存储名称,默认 'viewed-table-row' */
|
||||
storeName?: string;
|
||||
type: 'indexedDB';
|
||||
} & ViewedRowPersistBase)
|
||||
| ({
|
||||
/** 存储 key(必传) */
|
||||
key: string;
|
||||
type: 'localStorage' | 'sessionStorage';
|
||||
} & ViewedRowPersistBase)
|
||||
| ({
|
||||
/** 自定义存储适配器(必传) */
|
||||
storage: ViewedRowStorageAdapter;
|
||||
type: 'custom';
|
||||
} & ViewedRowPersistBase)
|
||||
| (ViewedRowPersistBase & {
|
||||
type: 'memory';
|
||||
});
|
||||
|
||||
/**
|
||||
* 已查看row设置
|
||||
*/
|
||||
export interface ViewedRowOptions<T = any> {
|
||||
/** 点击 CellOperation 中匹配的 code 时,自动将该行标记为已读 */
|
||||
actionCodes?: string | string[];
|
||||
/** 行唯一标识字段,默认取 gridOptions.rowConfig.keyField,最终兜底 'id' */
|
||||
keyField?: string;
|
||||
/** 已查看的行key列表 */
|
||||
viewedKeys?: Array<number | string> | Ref<Array<number | string>>;
|
||||
/**
|
||||
* 持久化配置
|
||||
* - 传 string:使用内置 localStorage,值为 storage key(向后兼容)
|
||||
* - 传 object:高级配置
|
||||
* - 不传:不持久化(等同于 memory)
|
||||
*/
|
||||
persist?: string | ViewedRowPersistOptions;
|
||||
rowClassName?: VxeTablePropTypes.RowClassName<T>;
|
||||
rowStyle?: VxeTablePropTypes.RowStyle<T>;
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/* oxlint-disable unicorn/no-nested-ternary */
|
||||
import type { VxeGridProps as VxeTableGridProps } from 'vxe-table';
|
||||
|
||||
import type {
|
||||
ViewedRowOptions,
|
||||
ViewedRowPersistOptions,
|
||||
ViewedRowStorageAdapter,
|
||||
} from './types';
|
||||
|
||||
import { isRef, shallowRef, toRaw, triggerRef, watch } from 'vue';
|
||||
|
||||
import { isBoolean, isFunction } from '@vben/utils';
|
||||
|
||||
import {
|
||||
IndexedDBDriver,
|
||||
LocalStorageDriver,
|
||||
StorageManager,
|
||||
} from '@vben-core/shared/cache';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
const DEFAULT_VIEWED_CLASS = 'vxe-row--viewed';
|
||||
|
||||
// ========== 持久化策略 ==========
|
||||
|
||||
/**
|
||||
* localStorage / sessionStorage 适配器
|
||||
* 整体存储:key → [1, 2, 3]
|
||||
*/
|
||||
function createWebStorageAdapter(
|
||||
storageType: 'localStorage' | 'sessionStorage',
|
||||
key: string,
|
||||
ttl?: number,
|
||||
): ViewedRowStorageAdapter {
|
||||
const manager = new StorageManager({
|
||||
driver: new LocalStorageDriver({ storageType }),
|
||||
});
|
||||
|
||||
return {
|
||||
async getKeys() {
|
||||
const stored = await manager.getItem<Array<number | string>>(key);
|
||||
return stored ?? [];
|
||||
},
|
||||
async removeKeys() {
|
||||
await manager.removeItem(key);
|
||||
},
|
||||
async setKeys(keys) {
|
||||
await manager.setItem(key, keys, ttl);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB 适配器
|
||||
* 单条存储:prefix:1 → { expiry, value: 1 }
|
||||
*/
|
||||
function createIndexedDBAdapter(
|
||||
opts: Extract<ViewedRowPersistOptions, { type: 'indexedDB' }>,
|
||||
): ViewedRowStorageAdapter {
|
||||
const prefix = opts.key;
|
||||
const manager = new StorageManager({
|
||||
driver: new IndexedDBDriver({
|
||||
dbName: opts.dbName || 'viewed-table-db',
|
||||
dbVersion: opts.dbVersion || 1,
|
||||
storeName: opts.storeName || 'viewed-table-row',
|
||||
}),
|
||||
prefix,
|
||||
});
|
||||
|
||||
return {
|
||||
async getKeys() {
|
||||
try {
|
||||
// 通过 StorageManager 获取当前前缀下所有 key,再逐条读取(自动过滤过期)
|
||||
const shortKeys = await manager.keys();
|
||||
|
||||
const results: Array<number | string> = [];
|
||||
for (const shortKey of shortKeys) {
|
||||
const value = await manager.getItem<number | string>(shortKey);
|
||||
if (value !== null) {
|
||||
results.push(value);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('[viewedRow] indexedDB restore failed:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async removeKeys() {
|
||||
try {
|
||||
await manager.clear();
|
||||
} catch (error) {
|
||||
console.error('[viewedRow] indexedDB clear failed:', error);
|
||||
}
|
||||
},
|
||||
async setKeys(keys) {
|
||||
try {
|
||||
const newKeySet = new Set(keys.map(String));
|
||||
// 获取已存在的 key,避免重复写入刷新过期时间
|
||||
const existingKeys = await manager.keys();
|
||||
const existingKeySet = new Set(existingKeys);
|
||||
|
||||
// 只写入新增的 key,不覆盖已有记录的过期时间
|
||||
const toAdd = keys.filter((key) => !existingKeySet.has(String(key)));
|
||||
if (toAdd.length > 0) {
|
||||
await Promise.all(
|
||||
toAdd.map((key) => manager.setItem(String(key), key, opts.ttl)),
|
||||
);
|
||||
}
|
||||
|
||||
// 清理不在新集合中的旧 key
|
||||
const toRemove = existingKeys.filter((k) => !newKeySet.has(k));
|
||||
if (toRemove.length > 0) {
|
||||
await Promise.all(toRemove.map((k) => manager.removeItem(k)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[viewedRow] indexedDB persist failed:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 persist 配置创建存储适配器
|
||||
*/
|
||||
function createStorageAdapter(
|
||||
persist?: string | ViewedRowPersistOptions,
|
||||
): null | ViewedRowStorageAdapter {
|
||||
if (!persist) return null;
|
||||
|
||||
// 简写模式:string → localStorage
|
||||
if (typeof persist === 'string') {
|
||||
return createWebStorageAdapter('localStorage', persist);
|
||||
}
|
||||
|
||||
switch (persist.type) {
|
||||
case 'custom': {
|
||||
// 用户自定义适配器,解除 Vue 响应式代理
|
||||
return toRaw(persist.storage);
|
||||
}
|
||||
case 'indexedDB': {
|
||||
return createIndexedDBAdapter(persist);
|
||||
}
|
||||
case 'localStorage': {
|
||||
return createWebStorageAdapter('localStorage', persist.key, persist.ttl);
|
||||
}
|
||||
case 'memory': {
|
||||
return null;
|
||||
}
|
||||
case 'sessionStorage': {
|
||||
return createWebStorageAdapter(
|
||||
'sessionStorage',
|
||||
persist.key,
|
||||
persist.ttl,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== maxSize 淘汰 ==========
|
||||
|
||||
/**
|
||||
* 强制执行 maxSize 限制,超出时淘汰最早插入的 key(FIFO)
|
||||
*/
|
||||
function enforceMaxSize(set: Set<number | string>, maxSize: number): void {
|
||||
if (maxSize > 0 && set.size > maxSize) {
|
||||
const iterator = set.values();
|
||||
while (set.size > maxSize) {
|
||||
const oldest = iterator.next().value;
|
||||
if (oldest !== undefined) {
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 核心 composable ==========
|
||||
|
||||
export function useViewedRow<T = any>(
|
||||
options: ViewedRowOptions<T> & { keyField: string },
|
||||
) {
|
||||
// ========== 解析持久化配置 ==========
|
||||
const persistOpts: null | ViewedRowPersistOptions = options.persist
|
||||
? typeof options.persist === 'string'
|
||||
? { key: options.persist, type: 'localStorage' }
|
||||
: options.persist
|
||||
: null;
|
||||
|
||||
const adapter = createStorageAdapter(options.persist);
|
||||
const maxSize = persistOpts?.maxSize ?? 100;
|
||||
|
||||
// ========== 初始化已读集合 ==========
|
||||
const viewedSet = shallowRef<Set<number | string>>(new Set());
|
||||
|
||||
// ========== 持久化(防抖) ==========
|
||||
function persistImmediate() {
|
||||
if (!adapter) return;
|
||||
adapter.setKeys([...viewedSet.value]).catch((error) => {
|
||||
console.error('[viewedRow] persist failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
const persist = useDebounceFn(persistImmediate, 300);
|
||||
|
||||
// ========== 从存储恢复 ==========
|
||||
async function restoreFromStorage(): Promise<void> {
|
||||
if (!adapter) return;
|
||||
|
||||
try {
|
||||
const stored = await adapter.getKeys();
|
||||
if (stored && stored.length > 0) {
|
||||
for (const key of stored) {
|
||||
viewedSet.value.add(key);
|
||||
}
|
||||
if (maxSize > 0) {
|
||||
enforceMaxSize(viewedSet.value, maxSize);
|
||||
}
|
||||
triggerRef(viewedSet);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[viewedRow] restore failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 先恢复存储,再合并外部 viewedKeys,确保 viewedKeys 是最新插入的(最后被淘汰)
|
||||
restoreFromStorage().then(() => {
|
||||
if (options.viewedKeys) {
|
||||
const keys = isRef(options.viewedKeys)
|
||||
? options.viewedKeys.value
|
||||
: options.viewedKeys;
|
||||
updateViewedSet((set) => {
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
if (!set.has(key)) {
|
||||
set.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 更新 viewedSet 的统一入口 ==========
|
||||
function updateViewedSet(updater: (set: Set<number | string>) => boolean) {
|
||||
const changed = updater(viewedSet.value);
|
||||
|
||||
if (changed) {
|
||||
if (maxSize > 0) {
|
||||
enforceMaxSize(viewedSet.value, maxSize);
|
||||
}
|
||||
triggerRef(viewedSet);
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 监听外部 viewedKeys 变化(如果是 Ref) ==========
|
||||
if (isRef(options.viewedKeys)) {
|
||||
watch(options.viewedKeys, (newKeys) => {
|
||||
updateViewedSet((set) => {
|
||||
let changed = false;
|
||||
for (const key of newKeys) {
|
||||
if (!set.has(key)) {
|
||||
set.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 标记已读 ==========
|
||||
function markAsViewed(record: T) {
|
||||
const key = (record as Record<string, any>)[options.keyField] as
|
||||
| number
|
||||
| string;
|
||||
if (key === null || key === undefined) return;
|
||||
|
||||
updateViewedSet((set) => {
|
||||
if (set.has(key)) return false;
|
||||
set.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function markKeysAsViewed(keys: Array<number | string>) {
|
||||
updateViewedSet((set) => {
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
if (!set.has(key)) {
|
||||
set.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 查询 ==========
|
||||
function isViewed(record: T): boolean {
|
||||
const key = (record as Record<string, any>)[options.keyField] as
|
||||
| number
|
||||
| string;
|
||||
return viewedSet.value.has(key);
|
||||
}
|
||||
|
||||
// ========== 清除 ==========
|
||||
function clearViewed() {
|
||||
const hadData = viewedSet.value.size > 0;
|
||||
viewedSet.value.clear();
|
||||
|
||||
if (hadData) {
|
||||
triggerRef(viewedSet);
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
adapter.removeKeys().catch((error) => {
|
||||
console.error('[viewedRow] clear persist failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 移除指定 keys ==========
|
||||
function removeKeys(keys: Array<number | string>) {
|
||||
updateViewedSet((set) => {
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
if (set.has(key)) {
|
||||
set.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
// ========== rowClassName 函数 ==========
|
||||
function getRowClassName(params: any): string {
|
||||
if (!isViewed(params.row)) return '';
|
||||
|
||||
const { rowClassName } = options;
|
||||
if (rowClassName === undefined || rowClassName === null) {
|
||||
return DEFAULT_VIEWED_CLASS;
|
||||
}
|
||||
if (typeof rowClassName === 'string') {
|
||||
return rowClassName;
|
||||
}
|
||||
if (isFunction(rowClassName)) {
|
||||
return normalizeClassName(rowClassName(params));
|
||||
}
|
||||
return DEFAULT_VIEWED_CLASS;
|
||||
}
|
||||
|
||||
// ========== rowStyle 函数 ==========
|
||||
function getRowStyle(params: any): any {
|
||||
if (!isViewed(params.row)) return undefined;
|
||||
|
||||
const { rowStyle } = options;
|
||||
if (rowStyle === undefined || rowStyle === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (isFunction(rowStyle)) {
|
||||
return rowStyle(params);
|
||||
}
|
||||
return rowStyle;
|
||||
}
|
||||
|
||||
return {
|
||||
clearViewed,
|
||||
getRowClassName,
|
||||
getRowStyle,
|
||||
isViewed,
|
||||
markAsViewed,
|
||||
markKeysAsViewed,
|
||||
removeKeys,
|
||||
viewedSet,
|
||||
};
|
||||
}
|
||||
|
||||
export type ViewedRowHelper<T = any> = ReturnType<typeof useViewedRow<T>>;
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
function normalizeClassName(value: any): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(' ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function mergeClassNames(...classNames: any[]): string {
|
||||
return classNames
|
||||
.map((c) => normalizeClassName(c))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 columns,拦截 CellOperation 的 onClick,根据 actionCodes 自动标记已读
|
||||
* 注意:columns 每次都是 cloneDeep 后的新对象,不存在重复包装问题
|
||||
*/
|
||||
function wrapColumnsForViewedRow(
|
||||
columns: any[],
|
||||
actionCodes: string[],
|
||||
markAsViewed: (record: any) => void,
|
||||
): any[] {
|
||||
return columns.map((column) => {
|
||||
if (!column || typeof column !== 'object') return column;
|
||||
|
||||
const nextColumn = { ...column };
|
||||
|
||||
if (nextColumn.cellRender?.name === 'CellOperation') {
|
||||
const cellRender = { ...nextColumn.cellRender };
|
||||
const attrs = { ...cellRender.attrs };
|
||||
const originalOnClick = attrs.onClick;
|
||||
|
||||
attrs.onClick = (params: { code: string; row: any }) => {
|
||||
originalOnClick?.(params);
|
||||
if (actionCodes.includes(params.code)) {
|
||||
markAsViewed(params.row);
|
||||
}
|
||||
};
|
||||
|
||||
cellRender.attrs = attrs;
|
||||
nextColumn.cellRender = cellRender;
|
||||
}
|
||||
|
||||
if (Array.isArray(nextColumn.children)) {
|
||||
nextColumn.children = wrapColumnsForViewedRow(
|
||||
nextColumn.children,
|
||||
actionCodes,
|
||||
markAsViewed,
|
||||
);
|
||||
}
|
||||
|
||||
return nextColumn;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 viewedRow 配置应用到 mergedOptions 上
|
||||
* 注入 rowClassName、rowStyle、columns 拦截
|
||||
*/
|
||||
export function applyViewedRowOptions(
|
||||
mergedOptions: VxeTableGridProps,
|
||||
viewedRowConfig: boolean | ViewedRowOptions,
|
||||
helper: ReturnType<typeof useViewedRow>,
|
||||
) {
|
||||
// 从最新的配置中读取 rowClassName 和 rowStyle(支持运行时修改)
|
||||
const viewedRowClassName = isBoolean(viewedRowConfig)
|
||||
? undefined
|
||||
: viewedRowConfig.rowClassName;
|
||||
const viewedRowStyle = isBoolean(viewedRowConfig)
|
||||
? undefined
|
||||
: viewedRowConfig.rowStyle;
|
||||
|
||||
// 注入 rowClassName
|
||||
const originalRowClassName = mergedOptions.rowClassName;
|
||||
mergedOptions.rowClassName = (params: any) => {
|
||||
if (!helper.isViewed(params.row)) {
|
||||
return normalizeClassName(
|
||||
isFunction(originalRowClassName)
|
||||
? originalRowClassName(params)
|
||||
: originalRowClassName,
|
||||
);
|
||||
}
|
||||
|
||||
let viewedClass: string;
|
||||
if (viewedRowClassName === undefined || viewedRowClassName === null) {
|
||||
viewedClass = DEFAULT_VIEWED_CLASS;
|
||||
} else if (typeof viewedRowClassName === 'string') {
|
||||
viewedClass = viewedRowClassName;
|
||||
} else if (isFunction(viewedRowClassName)) {
|
||||
viewedClass = normalizeClassName(viewedRowClassName(params));
|
||||
} else {
|
||||
viewedClass = DEFAULT_VIEWED_CLASS;
|
||||
}
|
||||
|
||||
return mergeClassNames(
|
||||
isFunction(originalRowClassName)
|
||||
? originalRowClassName(params)
|
||||
: originalRowClassName,
|
||||
viewedClass,
|
||||
);
|
||||
};
|
||||
|
||||
// 注入 rowStyle
|
||||
const originalRowStyle = mergedOptions.rowStyle;
|
||||
mergedOptions.rowStyle = (params: any) => {
|
||||
const originalStyle = isFunction(originalRowStyle)
|
||||
? originalRowStyle(params)
|
||||
: originalRowStyle;
|
||||
|
||||
if (!helper.isViewed(params.row)) {
|
||||
return originalStyle || undefined;
|
||||
}
|
||||
|
||||
let viewedStyle: any;
|
||||
if (viewedRowStyle === undefined || viewedRowStyle === null) {
|
||||
viewedStyle = undefined;
|
||||
} else if (isFunction(viewedRowStyle)) {
|
||||
viewedStyle = viewedRowStyle(params);
|
||||
} else {
|
||||
viewedStyle = viewedRowStyle;
|
||||
}
|
||||
|
||||
if (!viewedStyle && !originalStyle) return undefined;
|
||||
if (!originalStyle) return viewedStyle;
|
||||
if (!viewedStyle) return originalStyle;
|
||||
return { ...originalStyle, ...viewedStyle };
|
||||
};
|
||||
|
||||
// 拦截 CellOperation columns
|
||||
const actionCodes =
|
||||
!isBoolean(viewedRowConfig) && viewedRowConfig.actionCodes
|
||||
? Array.isArray(viewedRowConfig.actionCodes)
|
||||
? viewedRowConfig.actionCodes
|
||||
: [viewedRowConfig.actionCodes]
|
||||
: [];
|
||||
|
||||
if (actionCodes.length > 0 && Array.isArray(mergedOptions.columns)) {
|
||||
mergedOptions.columns = wrapColumnsForViewedRow(
|
||||
mergedOptions.columns,
|
||||
actionCodes,
|
||||
helper.markAsViewed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user