Initial commit: OneOS frontend based on yudao-ui-admin-vben
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
k kfluous
2026-03-11 22:18:23 +08:00
commit 2650d1cdf0
5166 changed files with 641242 additions and 0 deletions

View File

@@ -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';
```
这样做的好处是,应用可以自行选择是否使用插件,而不会因为插件的引入及副作用而导致打包体积增大,只引入需要的插件即可。

View File

@@ -0,0 +1,70 @@
{
"name": "@vben/plugins",
"version": "5.6.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": {
"./code-editor": {
"types": "./src/code-editor/index.ts",
"default": "./src/code-editor/index.ts"
},
"./echarts": {
"types": "./src/echarts/index.ts",
"default": "./src/echarts/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"
},
"./markmap": {
"types": "./src/markmap/index.ts",
"default": "./src/markmap/index.ts"
},
"./tinyflow": {
"types": "./src/tinyflow/index.ts",
"default": "./src/tinyflow/index.ts"
}
},
"dependencies": {
"@tinyflow-ai/vue": "catalog:",
"@vben-core/form-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:",
"codemirror": "catalog:",
"echarts": "catalog:",
"markdown-it": "catalog:",
"markmap-common": "catalog:",
"markmap-lib": "catalog:",
"markmap-toolbar": "catalog:",
"markmap-view": "catalog:",
"vue": "catalog:",
"vxe-pc-ui": "catalog:",
"vxe-table": "catalog:"
},
"devDependencies": {
"@types/codemirror": "catalog:",
"@types/markdown-it": "catalog:"
}
}

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import type { CodeEditorProps } from './types';
import { computed } from 'vue';
import { isString } from '@vben/utils';
import CodeMirrorEditor from './code-mirror.vue';
import { MODE } from './types';
const props = withDefaults(defineProps<CodeEditorProps>(), {
value: '',
mode: MODE.JSON,
readonly: false,
autoFormat: true,
bordered: false,
});
const emit = defineEmits(['change', 'update:value', 'formatError']);
const getValue = computed(() => {
const { value, mode, autoFormat } = props;
if (!autoFormat || mode !== MODE.JSON) return value as string;
let result = value;
if (isString(value)) {
try {
result = JSON.parse(value);
} catch {
emit('formatError', value);
return value as string;
}
}
return JSON.stringify(result, null, 2);
});
function handleValueChange(v: string) {
emit('update:value', v);
emit('change', v);
}
</script>
<template>
<div class="h-full">
<CodeMirrorEditor
:value="getValue"
:mode="mode"
:readonly="readonly"
:bordered="bordered"
:auto-format="autoFormat"
@change="handleValueChange"
/>
</div>
</template>

View File

@@ -0,0 +1,23 @@
// modes
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import 'codemirror/mode/htmlmixed/htmlmixed';
import 'codemirror/mode/vue/vue';
// import 'codemirror/addon/lint/lint.css';
import './codemirror.css';
import 'codemirror/theme/idea.css';
import 'codemirror/theme/material-palenight.css';
// addons
// import 'codemirror/addon/edit/closebrackets';
// import 'codemirror/addon/edit/closetag';
// import 'codemirror/addon/comment/comment';
// import 'codemirror/addon/fold/foldcode';
// import 'codemirror/addon/fold/foldgutter';
// import 'codemirror/addon/fold/brace-fold';
// import 'codemirror/addon/fold/indent-fold';
// import 'codemirror/addon/lint/json-lint';
// import 'codemirror/addon/fold/comment-fold';
export { default as CodeMirror } from 'codemirror';

View File

@@ -0,0 +1,137 @@
<script lang="ts" setup>
import type { Nullable } from '@vben/types';
import type { CodeEditorProps } from './types';
import {
nextTick,
onMounted,
onUnmounted,
ref,
unref,
watch,
watchEffect,
} from 'vue';
import { usePreferences } from '@vben/preferences';
import { useDebounceFn, useWindowSize } from '@vueuse/core';
import CodeMirror from 'codemirror';
import { MODE } from './types';
// modes
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import 'codemirror/mode/htmlmixed/htmlmixed';
// css
import './codemirror.css';
import 'codemirror/theme/idea.css';
import 'codemirror/theme/material-palenight.css';
const props = withDefaults(defineProps<CodeEditorProps>(), {
mode: MODE.JSON,
value: '',
readonly: false,
bordered: false,
autoFormat: true,
});
const emit = defineEmits(['change']);
const { isDark } = usePreferences();
const { width, height } = useWindowSize();
const el = ref();
let editor: Nullable<CodeMirror.Editor>;
const debounceRefresh = useDebounceFn(refresh, 100);
watch(
() => props.value,
async (value) => {
await nextTick();
const oldValue = editor?.getValue();
if (value !== oldValue) editor?.setValue(value || '');
},
{ flush: 'post' },
);
watchEffect(() => {
editor?.setOption('mode', props.mode);
});
watch(
() => isDark.value,
async () => {
setTheme();
},
{
immediate: true,
},
);
watch(
() => [width.value, height.value],
async () => {
debounceRefresh();
},
);
function setTheme() {
unref(editor)?.setOption(
'theme',
isDark.value ? 'material-palenight' : 'idea',
);
}
function refresh() {
editor?.refresh();
}
async function init() {
const addonOptions = {
autoCloseBrackets: true,
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers'],
};
editor = CodeMirror(el.value!, {
value: '',
mode: props.mode,
readOnly: props.readonly,
tabSize: 2,
theme: 'material-palenight',
lineWrapping: true,
lineNumbers: true,
...addonOptions,
});
editor?.setValue(props.value);
setTheme();
editor?.on('change', () => {
emit('change', editor?.getValue());
});
}
onMounted(async () => {
await nextTick();
init();
});
onUnmounted(() => {
editor = null;
});
</script>
<template>
<div
ref="el"
class="relative !h-full w-full overflow-hidden"
:class="{
'ant-input': props.bordered,
'css-dev-only-do-not-override-kqecok': props.bordered,
}"
></div>
</template>

View File

@@ -0,0 +1,524 @@
/* BASICS */
.CodeMirror {
--base: #545281;
--comment: hsl(210deg 25% 60%);
--keyword: #af4ab1;
--variable: #0055d1;
--function: #c25205;
--string: #2ba46d;
--number: #c25205;
--tags: #d00;
--qualifier: #ff6032;
--important: var(--string);
position: relative;
height: auto;
height: 100%;
overflow: hidden;
font-family: var(--font-code);
background: white;
direction: ltr;
}
/* PADDING */
.CodeMirror-lines {
min-height: 1px; /* prevents collapsing before first draw */
padding: 4px 0; /* Vertical padding around content */
cursor: text;
}
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
position: absolute;
top: 0;
left: 0;
z-index: 3;
min-height: 100%;
white-space: nowrap;
background-color: transparent;
border-right: 1px solid #ddd;
}
.CodeMirror-linenumber {
min-width: 20px;
padding: 0 3px 0 5px;
color: var(--comment);
text-align: right;
white-space: nowrap;
opacity: 0.6;
}
.CodeMirror-guttermarker {
color: black;
}
.CodeMirror-guttermarker-subtle {
color: #999;
}
/* FOLD GUTTER */
.CodeMirror-foldmarker {
font-family: arial;
line-height: 0.3;
color: #414141;
text-shadow:
#f96 1px 1px 2px,
#f96 -1px -1px 2px,
#f96 1px -1px 2px,
#f96 -1px 1px 2px;
cursor: pointer;
}
.CodeMirror-foldgutter {
width: 0.7em;
}
.CodeMirror-foldgutter-open,
.CodeMirror-foldgutter-folded {
cursor: pointer;
}
.CodeMirror-foldgutter-open::after,
.CodeMirror-foldgutter-folded::after {
position: relative;
top: -0.1em;
display: inline-block;
font-size: 0.8em;
content: '>';
opacity: 0.8;
transform: rotate(90deg);
transition: transform 0.2s;
}
.CodeMirror-foldgutter-folded::after {
transform: none;
}
/* CURSOR */
.CodeMirror-cursor {
position: absolute;
width: 0;
pointer-events: none;
border-right: none;
border-left: 1px solid black;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
background: #7e7;
border: 0 !important;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor-mark {
background-color: rgb(20 255 20 / 50%);
animation: blink 1.06s steps(1) infinite;
}
.cm-animate-fat-cursor {
width: auto;
background-color: #7e7;
border: 0;
animation: blink 1.06s steps(1) infinite;
}
@keyframes blink {
50% {
background-color: transparent;
}
}
@keyframes blink {
50% {
background-color: transparent;
}
}
@keyframes blink {
50% {
background-color: transparent;
}
}
.cm-tab {
display: inline-block;
text-decoration: inherit;
}
.CodeMirror-rulers {
position: absolute;
inset: -50px 0 -20px;
overflow: hidden;
}
.CodeMirror-ruler {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px solid #ccc;
}
/* DEFAULT THEME */
.cm-s-default.CodeMirror {
background-color: transparent;
}
.cm-s-default .cm-header {
color: blue;
}
.cm-s-default .cm-quote {
color: #090;
}
.cm-negative {
color: #d44;
}
.cm-positive {
color: #292;
}
.cm-header,
.cm-strong {
font-weight: bold;
}
.cm-em {
font-style: italic;
}
.cm-link {
text-decoration: underline;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-s-default .cm-atom,
.cm-s-default .cm-def,
.cm-s-default .cm-property,
.cm-s-default .cm-variable-2,
.cm-s-default .cm-variable-3,
.cm-s-default .cm-punctuation {
color: var(--base);
}
.cm-s-default .cm-hr,
.cm-s-default .cm-comment {
color: var(--comment);
}
.cm-s-default .cm-attribute,
.cm-s-default .cm-keyword {
color: var(--keyword);
}
.cm-s-default .cm-variable {
color: var(--variable);
}
.cm-s-default .cm-bracket,
.cm-s-default .cm-tag {
color: var(--tags);
}
.cm-s-default .cm-number {
color: var(--number);
}
.cm-s-default .cm-string,
.cm-s-default .cm-string-2 {
color: var(--string);
}
.cm-s-default .cm-type {
color: #085;
}
.cm-s-default .cm-meta {
color: #555;
}
.cm-s-default .cm-qualifier {
color: var(--qualifier);
}
.cm-s-default .cm-builtin {
color: #7539ff;
}
.cm-s-default .cm-link {
color: var(--flash);
}
.cm-s-default .cm-error {
color: #ff008c;
}
.cm-invalidchar {
color: #ff008c;
}
.CodeMirror-composing {
border-bottom: 2px solid;
}
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {
color: #0b0;
}
div.CodeMirror span.CodeMirror-nonmatchingbracket {
color: #a22;
}
.CodeMirror-matchingtag {
background: rgb(255 150 0 / 30%);
}
.CodeMirror-activeline-background {
background: #e8f2ff;
}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror-scroll {
position: relative;
height: 100%;
padding-bottom: 30px;
margin-right: -30px;
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px;
overflow: scroll !important; /* Things will break if this is overridden */
outline: none; /* Prevent dragging from highlighting the element */
}
.CodeMirror-sizer {
position: relative;
margin-bottom: 20px !important;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
top: 0;
right: 0;
overflow: hidden scroll;
}
.CodeMirror-hscrollbar {
bottom: 0;
left: 0;
overflow: scroll hidden;
}
.CodeMirror-scrollbar-filler {
right: 0;
bottom: 0;
}
.CodeMirror-gutter-filler {
bottom: 0;
left: 0;
}
.CodeMirror-gutter {
display: inline-block;
height: 100%;
margin-bottom: -30px;
vertical-align: top;
white-space: normal;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0;
bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
z-index: 4;
cursor: default;
}
.CodeMirror-gutter-wrapper ::selection {
background-color: transparent;
}
.CodeMirrorwrapper ::selection {
background-color: transparent;
}
.CodeMirror pre {
position: relative;
z-index: 2;
padding: 0 4px; /* Horizontal padding of content */
margin: 0;
overflow: visible;
font-family: inherit;
font-size: inherit;
font-variant-ligatures: contextual;
line-height: inherit;
color: inherit;
overflow-wrap: normal;
white-space: pre;
background: transparent;
border-width: 0;
/* Reset some styles that the rest of the page might have set */
border-radius: 0;
-webkit-tap-highlight-color: transparent;
}
.CodeMirror-wrap pre {
word-break: normal;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.CodeMirror-linebackground {
position: absolute;
inset: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-rtl pre {
direction: rtl;
}
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
visibility: hidden;
width: 100%;
height: 0;
overflow: hidden;
}
.CodeMirror-measure pre {
position: static;
}
div.CodeMirror-cursors {
position: relative;
z-index: 3;
visibility: hidden;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-focused .CodeMirror-selected {
background: #d7d4f0;
}
.CodeMirror-crosshair {
cursor: crosshair;
}
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: #d7d4f0;
}
.cm-searching {
background-color: #ffa;
background-color: rgb(255 255 0 / 40%);
}
/* Used to force a border model for a node */
.cm-force-border {
padding-right: 0.1px;
}
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack::after {
content: '';
}
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext {
background: none;
}

View File

@@ -0,0 +1,2 @@
export { default as CodeEditor } from './code-editor.vue';
export * from './types';

View File

@@ -0,0 +1,14 @@
export enum MODE {
HTML = 'htmlmixed',
JS = 'javascript',
JSON = 'application/json',
VUE = 'vue',
}
export interface CodeEditorProps {
mode?: string;
value?: string;
readonly?: boolean;
bordered?: boolean;
autoFormat?: boolean;
}

View File

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

View File

@@ -0,0 +1,90 @@
import type {
// 系列类型的定义后缀都为 SeriesOption
BarSeriesOption,
GaugeSeriesOption,
LineSeriesOption,
MapSeriesOption,
} from 'echarts/charts';
import type {
DatasetComponentOption,
DataZoomComponentOption,
GeoComponentOption,
GridComponentOption,
// 组件类型的定义后缀都为 ComponentOption
TitleComponentOption,
TooltipComponentOption,
VisualMapComponentOption,
} from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
import {
BarChart,
FunnelChart,
GaugeChart,
LineChart,
MapChart,
PieChart,
RadarChart,
} from 'echarts/charts';
import {
// 数据集组件
DatasetComponent,
DataZoomComponent,
DataZoomInsideComponent,
DataZoomSliderComponent,
GeoComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
// 内置数据转换器组件 (filter, sort)
TransformComponent,
VisualMapComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
// 通过 ComposeOption 来组合出一个只有必须组件和图表的 Option 类型
export type ECOption = ComposeOption<
| BarSeriesOption
| DatasetComponentOption
| DataZoomComponentOption
| GaugeSeriesOption
| GeoComponentOption
| GridComponentOption
| LineSeriesOption
| MapSeriesOption
| TitleComponentOption
| TooltipComponentOption
| VisualMapComponentOption
>;
// 注册必须的组件
echarts.use([
TitleComponent,
PieChart,
RadarChart,
TooltipComponent,
GridComponent,
DatasetComponent,
DataZoomComponent,
DataZoomInsideComponent,
DataZoomSliderComponent,
TransformComponent,
BarChart,
LineChart,
FunnelChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer,
LegendComponent,
ToolboxComponent,
VisualMapComponent,
MapChart,
GeoComponent,
]);
export default echarts;

View File

@@ -0,0 +1,3 @@
export * from './echarts';
export { default as EchartsUI } from './echarts-ui.vue';
export * from './use-echarts';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
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, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import {
tryOnUnmounted,
useDebounceFn,
useResizeObserver,
useTimeoutFn,
useWindowSize,
} from '@vueuse/core';
import echarts from './echarts';
// TODO @xingyu有 500kbchina.json 会影响打包么?
import chinaMap from './map/china.json';
type EchartsUIType = typeof EchartsUI | undefined;
type EchartsThemeType = 'dark' | 'light' | null;
function useEcharts(chartRef: Ref<EchartsUIType>) {
let chartInstance: echarts.ECharts | null = null;
let cacheOptions: EChartsOption = {};
const { isDark } = usePreferences();
const { height, width } = useWindowSize();
const resizeHandler: () => void = useDebounceFn(resize, 200);
echarts.registerMap('china', {
geoJSON: chinaMap as any,
specialAreas: {
china: {
left: 500,
top: 500,
width: 1000,
height: 1000,
},
},
});
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;
};
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>> => {
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;
}
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, () => {
if (chartInstance) {
chartInstance.dispose();
initCharts();
renderEcharts(cacheOptions);
resize();
}
});
tryOnUnmounted(() => {
// 销毁实例,释放资源
chartInstance?.dispose();
});
return {
renderEcharts,
resize,
updateData,
getChartInstance: () => chartInstance,
};
}
export { useEcharts };
export type { EchartsUIType };

View File

@@ -0,0 +1,5 @@
export { default as MarkdownIt } from 'markdown-it';
export { Transformer } from 'markmap-lib';
export { Toolbar } from 'markmap-toolbar';
export * from 'markmap-view';

View File

@@ -0,0 +1,8 @@
export * from './types';
export {
MotionComponent as Motion,
MotionDirective,
MotionGroupComponent as MotionGroup,
MotionPlugin,
} from '@vueuse/motion';

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Tinyflow } from '@tinyflow-ai/vue';
import '@tinyflow-ai/vue/dist/index.css';
defineProps<{
data: any;
provider: any;
}>();
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
defineExpose({
getData: () => tinyflowRef.value?.getData(),
});
</script>
<template>
<Tinyflow
ref="tinyflowRef"
class-name="custom-class"
:data="data"
:provider="provider"
/>
</template>
<style scoped>
:deep(.custom-tinyflow) {
select {
appearance: auto !important;
}
/* 如果使用checkbox需要添加 */
input[type='checkbox'] {
width: 16px;
height: 16px;
margin: 0 4px;
border: 1px solid #ccc;
}
}
input[type='checkbox'] {
position: relative;
width: 18px;
height: 18px;
margin: 0 8px 0 0;
cursor: pointer;
border: 2px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
&:checked {
background-color: #1890ff;
border-color: #1890ff;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 12px;
content: '';
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: translate(-50%, -60%) rotate(45deg);
}
}
&:hover {
border-color: #40a9ff;
}
}
</style>

View File

@@ -0,0 +1,128 @@
import type { VxeGridInstance } from 'vxe-table';
import type { ExtendedFormApi } from '@vben-core/form-ui';
import type { VxeGridProps } from './types';
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> {
public formApi = {} as ExtendedFormApi;
// private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps<T> = null;
public store: Store<VxeGridProps<T>>;
private isMounted = false;
private stateHandler: StateHandler;
constructor(options: VxeGridProps = {}) {
const storeState = { ...options };
const defaultState = getDefaultState();
this.store = new Store<VxeGridProps>(
mergeWithArrayOverride(storeState, defaultState),
{
onUpdate: () => {
// this.prevState = this.state;
this.state = this.store.state;
},
},
);
this.state = this.store.state;
this.stateHandler = new StateHandler();
bindMethods(this);
}
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
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);
}
}
setGridOptions(options: Partial<VxeGridProps['gridOptions']>) {
this.setState({
gridOptions: options,
});
}
setLoading(isLoading: boolean) {
this.setState({
gridOptions: {
loading: isLoading,
},
});
}
setState(
stateOrFn:
| ((prev: VxeGridProps<T>) => Partial<VxeGridProps<T>>)
| Partial<VxeGridProps<T>>,
) {
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();
}
}

View File

@@ -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) as string;
},
});
vxeUI.formats.add('formatDateTime', {
tableCellFormatMethod({ cellValue }) {
return formatDateTime(cellValue) as string;
},
});
}

View File

@@ -0,0 +1,27 @@
import { defineAsyncComponent } from 'vue';
export { setupVbenVxeTable } from './init';
export { default as VbenVxeTableToolbar } from './table-toolbar.vue';
export type { VxeTableGridOptions } from './types';
export * from './use-vxe-grid';
export { default as VbenVxeGrid } from './use-vxe-grid.vue';
export { useTableToolbar } from './use-vxe-toolbar';
export * from './validation';
export type {
VxeGridListeners,
VxeGridProps,
VxeGridPropTypes,
VxeTableInstance,
} from 'vxe-table';
// 异步导出 vxe-table 相关组件提供给需要单独使用 vxe-table 的场景
export const AsyncVxeTable = defineAsyncComponent(() =>
import('vxe-table').then((mod) => mod.VxeTable),
);
export const AsyncVxeColumn = defineAsyncComponent(() =>
import('vxe-table').then((mod) => mod.VxeColumn),
);
export const AsyncVxeToolbar = defineAsyncComponent(() =>
import('vxe-table').then((mod) => mod.VxeToolbar),
);

View File

@@ -0,0 +1,131 @@
import type { SetupVxeTable } from './types';
import { defineComponent, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import { useVbenForm } from '@vben-core/form-ui';
import {
VxeButton,
VxeCheckbox,
// VxeFormGather,
// VxeForm,
// VxeFormItem,
VxeIcon,
VxeInput,
VxeLoading,
VxeModal,
VxeNumberInput,
VxePager,
// VxeList,
// VxeModal,
// VxeOptgroup,
// VxeOption,
// VxePulldown,
VxeRadio,
VxeRadioButton,
VxeRadioGroup,
VxeSelect,
VxeTooltip,
VxeUI,
VxeUpload,
// VxeSwitch,
// VxeTextarea,
} 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 { extendsDefaultFormatter } from './extends';
// 是否加载过
let isInit = false;
// eslint-disable-next-line import/no-mutable-exports
export let useTableForm: typeof useVbenForm;
// 部分组件如果没注册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 } = setupOptions;
initVxeTable();
useTableForm = useVbenForm;
const { isDark, locale } = usePreferences();
const localMap = {
'zh-CN': zhCN,
'en-US': 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);
}

View File

@@ -0,0 +1,143 @@
: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-table .required-field-error::after {
position: absolute;
top: -1px;
right: -1px;
z-index: 10;
padding: 1px 4px;
font-size: 10px;
line-height: 1;
color: white;
content: '必填';
background-color: #ff4d4f;
border-radius: 0 0 0 4px;
}
/* 必填字段内的输入框样式 */
.vxe-table .required-field-error .ant-select,
.vxe-table .required-field-error .ant-input-number,
.vxe-table .required-field-error .ant-input {
border-color: #ff4d4f !important;
}
.vxe-table .required-field-error .ant-select .ant-select-selector {
border-color: #ff4d4f !important;
}

View File

@@ -0,0 +1,74 @@
<!-- add by puhui999vxe table 工具栏二次封装提供给 vxe 原生列表使用 -->
<script setup lang="ts">
import type { VxeToolbarInstance } from 'vxe-table';
import { ref } from 'vue';
import { useContentMaximize, useRefresh } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { VxeButton, VxeTooltip } from 'vxe-pc-ui';
import { VxeToolbar } from 'vxe-table';
/** 列表工具栏封装 */
defineOptions({ name: 'TableToolbar' });
const props = defineProps<{
hiddenSearch: boolean;
}>();
const emits = defineEmits(['update:hiddenSearch']);
const toolbarRef = ref<VxeToolbarInstance>();
const { toggleMaximizeAndTabbarHidden, contentIsMaximize } =
useContentMaximize();
const { refresh } = useRefresh();
/** 隐藏搜索栏 */
function onHiddenSearchBar() {
emits('update:hiddenSearch', !props.hiddenSearch);
}
defineExpose({
getToolbarRef: () => toolbarRef.value,
});
</script>
<template>
<VxeToolbar ref="toolbarRef" custom>
<template #toolPrefix>
<slot></slot>
<VxeTooltip placement="bottom" content="搜索">
<template #default>
<VxeButton class="ml-2 font-normal" circle @click="onHiddenSearchBar">
<IconifyIcon icon="lucide:search" :size="15" />
</VxeButton>
</template>
</VxeTooltip>
<VxeTooltip
placement="bottom"
:content="contentIsMaximize ? '还原' : '全屏'"
>
<template #default>
<VxeButton class="ml-2 font-medium" circle @click="refresh">
<IconifyIcon icon="lucide:refresh-cw" :size="15" />
</VxeButton>
</template>
</VxeTooltip>
<VxeTooltip placement="bottom" content="全屏">
<template #default>
<VxeButton
class="ml-2 font-medium"
circle
@click="toggleMaximizeAndTabbarHidden"
>
<IconifyIcon
:icon="contentIsMaximize ? 'lucide:minimize' : 'lucide:maximize'"
:size="15"
/>
</VxeButton>
</template>
</VxeTooltip>
</template>
</VxeToolbar>
</template>

View File

@@ -0,0 +1,93 @@
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, VbenFormProps } from '@vben-core/form-ui';
import type { VxeGridApi } from './api';
import { useVbenForm } from '@vben-core/form-ui';
export interface VxePaginationInfo {
currentPage: number;
pageSize: number;
total: number;
}
interface ToolbarConfigOptions extends VxeGridPropTypes.ToolbarConfig {
/** 是否显示切换搜索表单的按钮 */
search?: boolean;
}
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,
> {
/**
* 标题
*/
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>;
/**
* 显示搜索表单
*/
showSearchForm?: boolean;
/**
* 搜索表单与表格主体之间的分隔条
*/
separator?: boolean | SeparatorOptions;
}
export type ExtendedVxeGridApi<
D extends Record<string, any> = any,
F extends BaseFormComponentType = BaseFormComponentType,
> = VxeGridApi<D> & {
useStore: <T = NoInfer<VxeGridProps<D, F>>>(
selector?: (state: NoInfer<VxeGridProps<any, any>>) => T,
) => Readonly<Ref<T>>;
};
export interface SetupVxeTable {
configVxeTable: (ui: VxeUIExport) => void;
useVbenForm: typeof useVbenForm;
}

View File

@@ -0,0 +1,70 @@
import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
import type { SlotsType } from 'vue';
import type { BaseFormComponentType } 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,
>(options: VxeGridProps<T, D>) {
// const IS_REACTIVE = isReactive(options);
const api = new VxeGridApi(options);
const extendedApi: ExtendedVxeGridApi<T, D> = api as ExtendedVxeGridApi<T, D>;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Grid = defineComponent(
(props: VxeGridProps<T>, { attrs, slots }) => {
onBeforeUnmount(() => {
api.unmount();
});
api.setState({ ...props, ...attrs });
return () => h(VxeGrid, { ...props, ...attrs, api: extendedApi }, 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;

View File

@@ -0,0 +1,483 @@
<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 '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,
tableTitleHelp,
showSearchForm,
separator,
} = usePriorityValues(props, state);
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.resetForm();
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;
}
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()) ?? {}) : {},
);
// props.api.reload(formApi.form?.values ?? {});
}
// 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',
);
}
// @ts-ignore
props.api?.setState?.({ gridOptions: defaultGridOptions });
// form 由 vben-form 代替所以需要保证query相关事件可以拿到参数
extendProxyOptions(props.api, defaultGridOptions, () =>
formApi.getLatestSubmissionValues(),
);
}
// formOptions支持响应式
watch(
formOptions,
() => {
formApi.setState((prev) => {
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('bg-card h-full rounded-md', 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 items-center justify-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 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="bg-background-deep z-100 absolute -left-2 bottom-1 h-2 w-[calc(100%+1rem)] overflow-hidden 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>

View File

@@ -0,0 +1,48 @@
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { ref, watch } from 'vue';
import VbenVxeTableToolbar from './table-toolbar.vue';
/**
* vxe 原生工具栏挂载封装
* 解决每个组件使用 vxe-table 组件时都需要写一遍的问题
*/
export function useTableToolbar() {
const hiddenSearchBar = ref(false); // 隐藏搜索栏
const tableToolbarRef = ref<InstanceType<typeof VbenVxeTableToolbar>>();
const tableRef = ref<VxeTableInstance>();
const isBound = ref<boolean>(false);
/** 挂载 toolbar 工具栏 */
async function bindTableToolbar() {
const table = tableRef.value;
const tableToolbar = tableToolbarRef.value;
if (table && tableToolbar) {
// 延迟 1 秒,确保 toolbar 组件已经挂载
setTimeout(async () => {
const toolbar = tableToolbar.getToolbarRef();
if (!toolbar) {
console.error('[toolbar 挂载失败] Table toolbar not found');
}
await table.connectToolbar(toolbar as VxeToolbarInstance);
isBound.value = true;
}, 1000); // 延迟挂载确保 toolbar 正确挂载
}
}
watch(
() => tableRef.value,
async (val) => {
if (!val || isBound.value) return;
await bindTableToolbar();
},
{ immediate: true },
);
return {
hiddenSearchBar,
tableToolbarRef,
tableRef,
};
}

View File

@@ -0,0 +1,61 @@
/**
* 创建验证类名的工具函数
* @param isValidating 验证状态
* @param fieldName 字段名
* @param validationRules 验证规则,可以是字符串或自定义函数
* @returns 返回 className 函数
*/
function createValidationClassName(
isValidating: any,
fieldName: string,
validationRules: ((row: any) => boolean) | string,
) {
return ({ row }: { row: any }) => {
if (!isValidating?.value) return '';
let isValid = true;
if (typeof validationRules === 'string') {
// 处理简单的验证规则
if (validationRules === 'required') {
isValid =
fieldName === 'count'
? row[fieldName] && row[fieldName] > 0
: !!row[fieldName];
}
} else if (typeof validationRules === 'function') {
// 处理自定义验证函数
isValid = validationRules(row);
}
return isValid ? '' : 'required-field-error';
};
}
/**
* 创建必填字段验证
* @param isValidating 验证状态
* @param fieldName 字段名
* @returns 返回 className 函数
*/
function createRequiredValidation(isValidating: any, fieldName: string) {
return createValidationClassName(isValidating, fieldName, 'required');
}
/**
* 创建自定义验证
* @param isValidating 验证状态
* @param validationFn 自定义验证函数
* @returns 返回 className 函数
*/
function createCustomValidation(
isValidating: any,
validationFn: (row: any) => boolean,
) {
return createValidationClassName(isValidating, '', validationFn);
}
export {
createCustomValidation,
createRequiredValidation,
createValidationClassName,
};

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}