This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1,28 @@
# @vben/plugins
该目录用于存放项目中集成的第三方库及其相关插件。每个插件都包含了可重用的逻辑、配置和组件,方便在项目中进行统一管理和调用。
## 注意
所有的第三方插件都必须以 `subpath` 形式引入,例:
`echarts` 为例,引入方式如下:
**packages.json**
```json
"exports": {
"./echarts": {
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
}
}
```
**使用方式**
```ts
import { useEcharts } from '@vben/plugins/echarts';
```
这样做的好处是,应用可以自行选择是否使用插件,而不会因为插件的引入及副作用而导致打包体积增大,只引入需要的插件即可。
@@ -0,0 +1,69 @@
{
"name": "@vben/plugins",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/effects/plugins"
},
"license": "MIT",
"type": "module",
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./echarts": {
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
},
"./tiptap": {
"types": "./src/tiptap/index.ts",
"default": "./src/tiptap/index.ts"
},
"./vxe-table": {
"types": "./src/vxe-table/index.ts",
"default": "./src/vxe-table/index.ts"
},
"./motion": {
"types": "./src/motion/index.ts",
"default": "./src/motion/index.ts"
}
},
"dependencies": {
"@tiptap/core": "catalog:",
"@tiptap/extension-document": "catalog:",
"@tiptap/extension-highlight": "catalog:",
"@tiptap/extension-image": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-placeholder": "catalog:",
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-text-style": "catalog:",
"@tiptap/extension-underline": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@tiptap/vue-3": "catalog:",
"@vben-core/design": "workspace:*",
"@vben-core/form-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/motion": "catalog:",
"echarts": "catalog:",
"vue": "catalog:",
"vxe-pc-ui": "catalog:",
"vxe-table": "catalog:"
}
}
@@ -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);
// viewedRowOptionshelper 只创建一次(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,
);
}
}
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}