第一基础版
This commit is contained in:
3
packages/@core/ui-kit/README.md
Normal file
3
packages/@core/ui-kit/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ui-kit
|
||||
|
||||
用于管理公共组件、不同UI组件库封装的组件
|
||||
189
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
Normal file
189
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FormApi } from '../src/form-api';
|
||||
|
||||
describe('formApi', () => {
|
||||
let formApi: FormApi;
|
||||
|
||||
beforeEach(() => {
|
||||
formApi = new FormApi();
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(formApi.state).toEqual(
|
||||
expect.objectContaining({
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
commonConfig: {},
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
}),
|
||||
);
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should mount form actions', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
setValues: vi.fn(),
|
||||
submitForm: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.isMounted).toBe(true);
|
||||
expect(formApi.form).toEqual(formActions);
|
||||
});
|
||||
|
||||
it('should get values from form', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should set field value', async () => {
|
||||
const setFieldValueMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
setFieldValue: setFieldValueMock,
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.setFieldValue('name', 'new value');
|
||||
expect(setFieldValueMock).toHaveBeenCalledWith(
|
||||
'name',
|
||||
'new value',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset form', async () => {
|
||||
const resetFormMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: resetFormMock,
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.resetForm();
|
||||
expect(resetFormMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call handleSubmit on submit', async () => {
|
||||
const handleSubmitMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
submitForm: vi.fn().mockResolvedValue(true),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
const state = {
|
||||
handleSubmit: handleSubmitMock,
|
||||
};
|
||||
|
||||
formApi.setState(state);
|
||||
await formApi.mount(formActions);
|
||||
|
||||
const result = await formApi.submitForm();
|
||||
expect(formActions.submitForm).toHaveBeenCalled();
|
||||
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
|
||||
expect(result).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should unmount form and reset state', () => {
|
||||
formApi.unmount();
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate form', async () => {
|
||||
const validateMock = vi.fn().mockResolvedValue(true);
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
validate: validateMock,
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
const isValid = await formApi.validate();
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSchema', () => {
|
||||
let instance: FormApi;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new FormApi();
|
||||
instance.state = {
|
||||
schema: [
|
||||
{ component: 'text', fieldName: 'name' },
|
||||
{ component: 'number', fieldName: 'age', label: 'Age' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('should update the schema correctly when fieldName matches', () => {
|
||||
const newSchema = [
|
||||
{ component: 'text', fieldName: 'name' },
|
||||
{ component: 'number', fieldName: 'age', label: 'Age' },
|
||||
];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.label).toBe('Age');
|
||||
});
|
||||
|
||||
it('should log an error if fieldName is missing in some items', () => {
|
||||
const newSchema: any[] = [
|
||||
{ component: 'textarea', fieldName: 'name' },
|
||||
{ component: 'number' },
|
||||
];
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'All items in the schema array must have a valid `fieldName` property to be updated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update schema if fieldName does not match', () => {
|
||||
const newSchema = [{ component: 'textarea', fieldName: 'unknown' }];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.component).toBe('number');
|
||||
});
|
||||
|
||||
it('should not update schema if updatedMap is empty', () => {
|
||||
const newSchema: any[] = [{ component: 'textarea' }];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.component).toBe('number');
|
||||
});
|
||||
});
|
||||
21
packages/@core/ui-kit/form-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/form-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
51
packages/@core/ui-kit/form-ui/package.json
Normal file
51
packages/@core/ui-kit/form-ui/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@vben-core/form-ui",
|
||||
"version": "5.5.4",
|
||||
"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/@vben-core/uikit/form-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-defaults": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/form-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/form-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
160
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
160
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw, unref, watch } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, triggerWindowResize } from '@vben-core/shared/utils';
|
||||
|
||||
import { COMPONENT_MAP } from '../config';
|
||||
import { injectFormProps } from '../use-form-context';
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
const [rootProps, form] = injectFormProps();
|
||||
|
||||
const collapsed = defineModel({ default: false });
|
||||
|
||||
const resetButtonOptions = computed(() => {
|
||||
return {
|
||||
content: `${$t.value('reset')}`,
|
||||
show: true,
|
||||
...unref(rootProps).resetButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const submitButtonOptions = computed(() => {
|
||||
return {
|
||||
content: `${$t.value('submit')}`,
|
||||
show: true,
|
||||
...unref(rootProps).submitButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
// const isQueryForm = computed(() => {
|
||||
// return !!unref(rootProps).showCollapseButton;
|
||||
// });
|
||||
|
||||
const queryFormStyle = computed(() => {
|
||||
if (!unref(rootProps).actionWrapperClass) {
|
||||
return {
|
||||
'grid-column': `-2 / -1`,
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const { valid } = await form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = toRaw(await unref(rootProps).formApi?.getValues());
|
||||
await unref(rootProps).handleSubmit?.(values);
|
||||
}
|
||||
|
||||
async function handleReset(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const props = unref(rootProps);
|
||||
|
||||
const values = toRaw(await props.formApi?.getValues());
|
||||
|
||||
if (isFunction(props.handleReset)) {
|
||||
await props.handleReset?.(values);
|
||||
} else {
|
||||
form.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => collapsed.value,
|
||||
() => {
|
||||
const props = unref(rootProps);
|
||||
if (props.collapseTriggerResize) {
|
||||
triggerWindowResize();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
handleReset,
|
||||
handleSubmit,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full w-full text-right',
|
||||
rootProps.compact ? 'pb-2' : 'pb-6',
|
||||
rootProps.actionWrapperClass,
|
||||
)
|
||||
"
|
||||
:style="queryFormStyle"
|
||||
>
|
||||
<template v-if="rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 重置按钮前 -->
|
||||
<slot name="reset-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.DefaultButton"
|
||||
v-if="resetButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleReset"
|
||||
v-bind="resetButtonOptions"
|
||||
>
|
||||
{{ resetButtonOptions.content }}
|
||||
</component>
|
||||
|
||||
<template v-if="!rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 展开按钮前 -->
|
||||
<slot name="expand-before"></slot>
|
||||
|
||||
<VbenExpandableArrow
|
||||
v-if="rootProps.showCollapseButton"
|
||||
v-model:model-value="collapsed"
|
||||
class="ml-2"
|
||||
>
|
||||
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
|
||||
</VbenExpandableArrow>
|
||||
|
||||
<!-- 展开按钮后 -->
|
||||
<slot name="expand-after"></slot>
|
||||
</div>
|
||||
</template>
|
||||
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
FormCommonConfig,
|
||||
VbenFormAdapterOptions,
|
||||
} from './types';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
VbenButton,
|
||||
VbenCheckbox,
|
||||
Input as VbenInput,
|
||||
VbenInputPassword,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
|
||||
import { defineRule } from 'vee-validate';
|
||||
|
||||
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
|
||||
|
||||
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
|
||||
|
||||
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
|
||||
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
|
||||
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
|
||||
VbenCheckbox,
|
||||
VbenInput,
|
||||
VbenInputPassword,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
};
|
||||
|
||||
export const COMPONENT_BIND_EVENT_MAP: Partial<
|
||||
Record<BaseFormComponentType, string>
|
||||
> = {
|
||||
VbenCheckbox: 'checked',
|
||||
};
|
||||
|
||||
export function setupVbenForm<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
>(options: VbenFormAdapterOptions<T>) {
|
||||
const { config, defineRules } = options;
|
||||
|
||||
const {
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
} = (config || {}) as FormCommonConfig;
|
||||
|
||||
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
});
|
||||
|
||||
if (defineRules) {
|
||||
for (const key of Object.keys(defineRules)) {
|
||||
defineRule(key, defineRules[key as never]);
|
||||
}
|
||||
}
|
||||
|
||||
const baseModelPropName =
|
||||
config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME;
|
||||
const modelPropNameMap = config?.modelPropNameMap as
|
||||
| Record<BaseFormComponentType, string>
|
||||
| undefined;
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
for (const component of Object.keys(components)) {
|
||||
const key = component as BaseFormComponentType;
|
||||
COMPONENT_MAP[key] = components[component as never];
|
||||
|
||||
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
|
||||
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
|
||||
}
|
||||
|
||||
// 覆盖特殊组件的modelPropName
|
||||
if (modelPropNameMap && modelPropNameMap[key]) {
|
||||
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
579
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
579
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import type {
|
||||
FormState,
|
||||
GenericObject,
|
||||
ResetFormOpts,
|
||||
ValidationOptions,
|
||||
} from 'vee-validate';
|
||||
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { FormActions, FormSchema, VbenFormProps } from './types';
|
||||
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
createMerge,
|
||||
formatDate,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
isFunction,
|
||||
isObject,
|
||||
mergeWithArrayOverride,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
collapseTriggerResize: false,
|
||||
commonConfig: {},
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
handleValuesChange: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
submitOnChange: false,
|
||||
submitOnEnter: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
};
|
||||
}
|
||||
|
||||
export class FormApi {
|
||||
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
|
||||
public form = {} as FormActions;
|
||||
isMounted = false;
|
||||
|
||||
public state: null | VbenFormProps = null;
|
||||
stateHandler: StateHandler;
|
||||
|
||||
public store: Store<VbenFormProps>;
|
||||
|
||||
/**
|
||||
* 组件实例映射
|
||||
*/
|
||||
private componentRefMap: Map<string, unknown> = new Map();
|
||||
|
||||
// 最后一次点击提交时的表单值
|
||||
private latestSubmissionValues: null | Recordable<any> = null;
|
||||
|
||||
private prevState: null | VbenFormProps = null;
|
||||
|
||||
constructor(options: VbenFormProps = {}) {
|
||||
const { ...storeState } = options;
|
||||
|
||||
const defaultState = getDefaultState();
|
||||
|
||||
this.store = new Store<VbenFormProps>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
this.prevState = this.state;
|
||||
this.state = this.store.state;
|
||||
this.updateState();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.state = this.store.state;
|
||||
this.stateHandler = new StateHandler();
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段组件实例
|
||||
* @param fieldName 字段名
|
||||
* @returns 组件实例
|
||||
*/
|
||||
getFieldComponentRef<T = ComponentPublicInstance>(
|
||||
fieldName: string,
|
||||
): T | undefined {
|
||||
return this.componentRefMap.has(fieldName)
|
||||
? (this.componentRefMap.get(fieldName) as T)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前聚焦的字段,如果没有聚焦的字段则返回undefined
|
||||
*/
|
||||
getFocusedField() {
|
||||
for (const fieldName of this.componentRefMap.keys()) {
|
||||
const ref = this.getFieldComponentRef(fieldName);
|
||||
if (ref) {
|
||||
let el: HTMLElement | null = null;
|
||||
if (ref instanceof HTMLElement) {
|
||||
el = ref;
|
||||
} else if (ref.$el instanceof HTMLElement) {
|
||||
el = ref.$el;
|
||||
}
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
el === document.activeElement ||
|
||||
el.contains(document.activeElement)
|
||||
) {
|
||||
return fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getLatestSubmissionValues() {
|
||||
return this.latestSubmissionValues || {};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async getValues<T = Recordable<any>>() {
|
||||
const form = await this.getForm();
|
||||
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
|
||||
}
|
||||
|
||||
async isFieldValid(fieldName: string) {
|
||||
const form = await this.getForm();
|
||||
return form.isFieldValid(fieldName);
|
||||
}
|
||||
|
||||
merge(formApi: FormApi) {
|
||||
const chain = [this, formApi];
|
||||
const proxy = new Proxy(formApi, {
|
||||
get(target: any, prop: any) {
|
||||
if (prop === 'merge') {
|
||||
return (nextFormApi: FormApi) => {
|
||||
chain.push(nextFormApi);
|
||||
return proxy;
|
||||
};
|
||||
}
|
||||
if (prop === 'submitAllForm') {
|
||||
return async (needMerge: boolean = true) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
chain.map(async (api) => {
|
||||
const validateResult = await api.validate();
|
||||
if (!validateResult.valid) {
|
||||
return;
|
||||
}
|
||||
const rawValues = toRaw((await api.getValues()) || {});
|
||||
return rawValues;
|
||||
}),
|
||||
);
|
||||
if (needMerge) {
|
||||
const mergedResults = Object.assign({}, ...results);
|
||||
return mergedResults;
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
|
||||
if (!this.isMounted) {
|
||||
Object.assign(this.form, formActions);
|
||||
this.stateHandler.setConditionTrue();
|
||||
this.setLatestSubmissionValues({
|
||||
...toRaw(this.handleRangeTimeValue(this.form.values)),
|
||||
});
|
||||
this.componentRefMap = componentRefMap;
|
||||
this.isMounted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段名移除表单项
|
||||
* @param fields
|
||||
*/
|
||||
async removeSchemaByFields(fields: string[]) {
|
||||
const fieldSet = new Set(fields);
|
||||
const schema = this.state?.schema ?? [];
|
||||
|
||||
const filterSchema = schema.filter((item) => !fieldSet.has(item.fieldName));
|
||||
|
||||
this.setState({
|
||||
schema: filterSchema,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
async resetForm(
|
||||
state?: Partial<FormState<GenericObject>> | undefined,
|
||||
opts?: Partial<ResetFormOpts>,
|
||||
) {
|
||||
const form = await this.getForm();
|
||||
return form.resetForm(state, opts);
|
||||
}
|
||||
|
||||
async resetValidate() {
|
||||
const form = await this.getForm();
|
||||
const fields = Object.keys(form.errors.value);
|
||||
fields.forEach((field) => {
|
||||
form.setFieldError(field, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
|
||||
const form = await this.getForm();
|
||||
form.setFieldValue(field, value, shouldValidate);
|
||||
}
|
||||
|
||||
setLatestSubmissionValues(values: null | Recordable<any>) {
|
||||
this.latestSubmissionValues = { ...toRaw(values) };
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: VbenFormProps) => Partial<VbenFormProps>)
|
||||
| Partial<VbenFormProps>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState((prev) => {
|
||||
return mergeWithArrayOverride(stateOrFn(prev), prev);
|
||||
});
|
||||
} else {
|
||||
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单值
|
||||
* @param fields record
|
||||
* @param filterFields 过滤不在schema中定义的字段 默认为true
|
||||
* @param shouldValidate
|
||||
*/
|
||||
async setValues(
|
||||
fields: Record<string, any>,
|
||||
filterFields: boolean = true,
|
||||
shouldValidate: boolean = false,
|
||||
) {
|
||||
const form = await this.getForm();
|
||||
if (!filterFields) {
|
||||
form.setValues(fields, shouldValidate);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并算法有待改进,目前的算法不支持object类型的值。
|
||||
* antd的日期时间相关组件的值类型为dayjs对象
|
||||
* element-plus的日期时间相关组件的值类型可能为Date对象
|
||||
* 以上两种类型需要排除深度合并
|
||||
*/
|
||||
const fieldMergeFn = createMerge((obj, key, value) => {
|
||||
if (key in obj) {
|
||||
obj[key] =
|
||||
!Array.isArray(obj[key]) &&
|
||||
isObject(obj[key]) &&
|
||||
!isDayjsObject(obj[key]) &&
|
||||
!isDate(obj[key])
|
||||
? fieldMergeFn(obj[key], value)
|
||||
: value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const filteredFields = fieldMergeFn(fields, form.values);
|
||||
this.handleStringToArrayFields(filteredFields);
|
||||
form.setValues(filteredFields, shouldValidate);
|
||||
}
|
||||
|
||||
async submitForm(e?: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const form = await this.getForm();
|
||||
await form.submitForm();
|
||||
const rawValues = toRaw(await this.getValues());
|
||||
this.handleArrayToStringFields(rawValues);
|
||||
await this.state?.handleSubmit?.(rawValues);
|
||||
|
||||
return rawValues;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.form?.resetForm?.();
|
||||
// this.state = null;
|
||||
this.latestSubmissionValues = null;
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
}
|
||||
|
||||
updateSchema(schema: Partial<FormSchema>[]) {
|
||||
const updated: Partial<FormSchema>[] = [...schema];
|
||||
const hasField = updated.every(
|
||||
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
|
||||
);
|
||||
|
||||
if (!hasField) {
|
||||
console.error(
|
||||
'All items in the schema array must have a valid `fieldName` property to be updated',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentSchema = [...(this.state?.schema ?? [])];
|
||||
|
||||
const updatedMap: Record<string, any> = {};
|
||||
|
||||
updated.forEach((item) => {
|
||||
if (item.fieldName) {
|
||||
updatedMap[item.fieldName] = item;
|
||||
}
|
||||
});
|
||||
|
||||
currentSchema.forEach((schema, index) => {
|
||||
const updatedData = updatedMap[schema.fieldName];
|
||||
if (updatedData) {
|
||||
currentSchema[index] = mergeWithArrayOverride(
|
||||
updatedData,
|
||||
schema,
|
||||
) as FormSchema;
|
||||
}
|
||||
});
|
||||
this.setState({ schema: currentSchema });
|
||||
}
|
||||
|
||||
async validate(opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
|
||||
const validateResult = await form.validate(opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
async validateAndSubmitForm() {
|
||||
const form = await this.getForm();
|
||||
const { valid } = await form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
return await this.submitForm();
|
||||
}
|
||||
|
||||
async validateField(fieldName: string, opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
const validateResult = await form.validateField(fieldName, opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
private async getForm() {
|
||||
if (!this.isMounted) {
|
||||
// 等待form挂载
|
||||
await this.stateHandler.waitForCondition();
|
||||
}
|
||||
if (!this.form?.meta) {
|
||||
throw new Error('<VbenForm /> is not mounted');
|
||||
}
|
||||
return this.form;
|
||||
}
|
||||
|
||||
private handleArrayToStringFields = (originValues: Record<string, any>) => {
|
||||
const arrayToStringFields = this.state?.arrayToStringFields;
|
||||
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processFields = (fields: string[], separator: string = ',') => {
|
||||
this.processFields(fields, separator, originValues, (value, sep) =>
|
||||
Array.isArray(value) ? value.join(sep) : value,
|
||||
);
|
||||
};
|
||||
|
||||
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
|
||||
if (arrayToStringFields.every((item) => typeof item === 'string')) {
|
||||
const lastItem =
|
||||
arrayToStringFields[arrayToStringFields.length - 1] || '';
|
||||
const fields =
|
||||
lastItem.length === 1
|
||||
? arrayToStringFields.slice(0, -1)
|
||||
: arrayToStringFields;
|
||||
const separator = lastItem.length === 1 ? lastItem : ',';
|
||||
processFields(fields, separator);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理嵌套数组格式 [['field1'], ';']
|
||||
arrayToStringFields.forEach((fieldConfig) => {
|
||||
if (Array.isArray(fieldConfig)) {
|
||||
const [fields, separator = ','] = fieldConfig;
|
||||
// 根据类型定义,fields 应该始终是字符串数组
|
||||
if (!Array.isArray(fields)) {
|
||||
console.warn(
|
||||
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
processFields(fields, separator);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private handleRangeTimeValue = (originValues: Record<string, any>) => {
|
||||
const values = { ...originValues };
|
||||
const fieldMappingTime = this.state?.fieldMappingTime;
|
||||
|
||||
this.handleStringToArrayFields(values);
|
||||
|
||||
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
fieldMappingTime.forEach(
|
||||
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
|
||||
if (startTimeKey && endTimeKey && values[field] === null) {
|
||||
Reflect.deleteProperty(values, startTimeKey);
|
||||
Reflect.deleteProperty(values, endTimeKey);
|
||||
// delete values[startTimeKey];
|
||||
// delete values[endTimeKey];
|
||||
}
|
||||
|
||||
if (!values[field]) {
|
||||
Reflect.deleteProperty(values, field);
|
||||
// delete values[field];
|
||||
return;
|
||||
}
|
||||
|
||||
const [startTime, endTime] = values[field];
|
||||
if (format === null) {
|
||||
values[startTimeKey] = startTime;
|
||||
values[endTimeKey] = endTime;
|
||||
} else if (isFunction(format)) {
|
||||
values[startTimeKey] = format(startTime, startTimeKey);
|
||||
values[endTimeKey] = format(endTime, endTimeKey);
|
||||
} else {
|
||||
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
|
||||
? format
|
||||
: [format, format];
|
||||
|
||||
values[startTimeKey] = startTime
|
||||
? formatDate(startTime, startTimeFormat)
|
||||
: undefined;
|
||||
values[endTimeKey] = endTime
|
||||
? formatDate(endTime, endTimeFormat)
|
||||
: undefined;
|
||||
}
|
||||
// delete values[field];
|
||||
Reflect.deleteProperty(values, field);
|
||||
},
|
||||
);
|
||||
return values;
|
||||
};
|
||||
|
||||
private handleStringToArrayFields = (originValues: Record<string, any>) => {
|
||||
const arrayToStringFields = this.state?.arrayToStringFields;
|
||||
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processFields = (fields: string[], separator: string = ',') => {
|
||||
this.processFields(fields, separator, originValues, (value, sep) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
// 处理空字符串的情况
|
||||
if (value === '') {
|
||||
return [];
|
||||
}
|
||||
// 处理复杂分隔符的情况
|
||||
const escapedSeparator = sep.replaceAll(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
return value.split(new RegExp(escapedSeparator));
|
||||
});
|
||||
};
|
||||
|
||||
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
|
||||
if (arrayToStringFields.every((item) => typeof item === 'string')) {
|
||||
const lastItem =
|
||||
arrayToStringFields[arrayToStringFields.length - 1] || '';
|
||||
const fields =
|
||||
lastItem.length === 1
|
||||
? arrayToStringFields.slice(0, -1)
|
||||
: arrayToStringFields;
|
||||
const separator = lastItem.length === 1 ? lastItem : ',';
|
||||
processFields(fields, separator);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理嵌套数组格式 [['field1'], ';']
|
||||
arrayToStringFields.forEach((fieldConfig) => {
|
||||
if (Array.isArray(fieldConfig)) {
|
||||
const [fields, separator = ','] = fieldConfig;
|
||||
if (Array.isArray(fields)) {
|
||||
processFields(fields, separator);
|
||||
} else if (typeof originValues[fields] === 'string') {
|
||||
const value = originValues[fields];
|
||||
if (value === '') {
|
||||
originValues[fields] = [];
|
||||
} else {
|
||||
const escapedSeparator = separator.replaceAll(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
originValues[fields] = value.split(new RegExp(escapedSeparator));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private processFields = (
|
||||
fields: string[],
|
||||
separator: string,
|
||||
originValues: Record<string, any>,
|
||||
transformFn: (value: any, separator: string) => any,
|
||||
) => {
|
||||
fields.forEach((field) => {
|
||||
const value = originValues[field];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
originValues[field] = transformFn(value, separator);
|
||||
});
|
||||
};
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
// 进行了删除schema操作
|
||||
if (currentSchema.length < prevSchema.length) {
|
||||
const currentFields = new Set(
|
||||
currentSchema.map((item) => item.fieldName),
|
||||
);
|
||||
const deletedSchema = prevSchema.filter(
|
||||
(item) => !currentFields.has(item.fieldName),
|
||||
);
|
||||
for (const schema of deletedSchema) {
|
||||
this.form?.setFieldValue?.(schema.fieldName, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FormRenderProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
|
||||
export const [injectRenderFormProps, provideFormRenderProps] =
|
||||
createContext<FormRenderProps>('FormRenderProps');
|
||||
|
||||
export const useFormContext = () => {
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
|
||||
const isVertical = computed(() => formRenderProps.layout === 'vertical');
|
||||
|
||||
const componentMap = computed(() => formRenderProps.componentMap);
|
||||
const componentBindEventMap = computed(
|
||||
() => formRenderProps.componentBindEventMap,
|
||||
);
|
||||
return {
|
||||
componentBindEventMap,
|
||||
componentMap,
|
||||
isVertical,
|
||||
};
|
||||
};
|
||||
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
FormItemDependencies,
|
||||
FormSchemaRuleType,
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { isBoolean, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { useFormValues } from 'vee-validate';
|
||||
|
||||
import { injectRenderFormProps } from './context';
|
||||
|
||||
export default function useDependencies(
|
||||
getDependencies: () => FormItemDependencies | undefined,
|
||||
) {
|
||||
const values = useFormValues();
|
||||
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const formApi = formRenderProps.form!;
|
||||
|
||||
if (!values) {
|
||||
throw new Error('useDependencies should be used within <VbenForm>');
|
||||
}
|
||||
|
||||
const isIf = ref(true);
|
||||
const isDisabled = ref(false);
|
||||
const isShow = ref(true);
|
||||
const isRequired = ref(false);
|
||||
const dynamicComponentProps = ref<MaybeComponentProps>({});
|
||||
const dynamicRules = ref<FormSchemaRuleType>();
|
||||
|
||||
const triggerFieldValues = computed(() => {
|
||||
// 该字段可能会被多个字段触发
|
||||
const triggerFields = getDependencies()?.triggerFields ?? [];
|
||||
return triggerFields.map((dep) => {
|
||||
return values.value[dep];
|
||||
});
|
||||
});
|
||||
|
||||
const resetConditionState = () => {
|
||||
isDisabled.value = false;
|
||||
isIf.value = true;
|
||||
isShow.value = true;
|
||||
isRequired.value = false;
|
||||
dynamicRules.value = undefined;
|
||||
dynamicComponentProps.value = {};
|
||||
};
|
||||
|
||||
watch(
|
||||
[triggerFieldValues, getDependencies],
|
||||
async ([_values, dependencies]) => {
|
||||
if (!dependencies || !dependencies?.triggerFields?.length) {
|
||||
return;
|
||||
}
|
||||
resetConditionState();
|
||||
const {
|
||||
componentProps,
|
||||
disabled,
|
||||
if: whenIf,
|
||||
required,
|
||||
rules,
|
||||
show,
|
||||
trigger,
|
||||
} = dependencies;
|
||||
|
||||
// 1. 优先判断if,如果if为false,则不渲染dom,后续判断也不再执行
|
||||
const formValues = values.value;
|
||||
|
||||
if (isFunction(whenIf)) {
|
||||
isIf.value = !!(await whenIf(formValues, formApi));
|
||||
// 不渲染
|
||||
if (!isIf.value) return;
|
||||
} else if (isBoolean(whenIf)) {
|
||||
isIf.value = whenIf;
|
||||
if (!isIf.value) return;
|
||||
}
|
||||
|
||||
// 2. 判断show,如果show为false,则隐藏
|
||||
if (isFunction(show)) {
|
||||
isShow.value = !!(await show(formValues, formApi));
|
||||
if (!isShow.value) return;
|
||||
} else if (isBoolean(show)) {
|
||||
isShow.value = show;
|
||||
if (!isShow.value) return;
|
||||
}
|
||||
|
||||
if (isFunction(componentProps)) {
|
||||
dynamicComponentProps.value = await componentProps(formValues, formApi);
|
||||
}
|
||||
|
||||
if (isFunction(rules)) {
|
||||
dynamicRules.value = await rules(formValues, formApi);
|
||||
}
|
||||
|
||||
if (isFunction(disabled)) {
|
||||
isDisabled.value = !!(await disabled(formValues, formApi));
|
||||
} else if (isBoolean(disabled)) {
|
||||
isDisabled.value = disabled;
|
||||
}
|
||||
|
||||
if (isFunction(required)) {
|
||||
isRequired.value = !!(await required(formValues, formApi));
|
||||
}
|
||||
|
||||
if (isFunction(trigger)) {
|
||||
await trigger(formValues, formApi);
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
dynamicComponentProps,
|
||||
dynamicRules,
|
||||
isDisabled,
|
||||
isIf,
|
||||
isRequired,
|
||||
isShow,
|
||||
};
|
||||
}
|
||||
99
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
99
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { FormRenderProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
|
||||
|
||||
/**
|
||||
* 动态计算行数
|
||||
*/
|
||||
export function useExpandable(props: FormRenderProps) {
|
||||
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
|
||||
const rowMapping = ref<Record<number, number>>({});
|
||||
// 是否已经计算过一次
|
||||
const isCalculated = ref(false);
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
|
||||
const keepFormItemIndex = computed(() => {
|
||||
const rows = props.collapsedRows ?? 1;
|
||||
const mapping = rowMapping.value;
|
||||
let maxItem = 0;
|
||||
for (let index = 1; index <= rows; index++) {
|
||||
maxItem += mapping?.[index] ?? 0;
|
||||
}
|
||||
// 保持一行
|
||||
return maxItem - 1 || 1;
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.showCollapseButton,
|
||||
() => breakpoints.active().value,
|
||||
() => props.schema?.length,
|
||||
],
|
||||
async ([val]) => {
|
||||
if (val) {
|
||||
await nextTick();
|
||||
rowMapping.value = {};
|
||||
isCalculated.value = false;
|
||||
await calculateRowMapping();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function calculateRowMapping() {
|
||||
if (!props.showCollapseButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (!wrapperRef.value) {
|
||||
return;
|
||||
}
|
||||
// 小屏幕不计算
|
||||
// if (breakpoints.smaller('sm').value) {
|
||||
// // 保持一行
|
||||
// rowMapping.value = { 1: 2 };
|
||||
// return;
|
||||
// }
|
||||
|
||||
const formItems = [...wrapperRef.value.children];
|
||||
|
||||
const container = wrapperRef.value;
|
||||
const containerStyles = window.getComputedStyle(container);
|
||||
const rowHeights = containerStyles
|
||||
.getPropertyValue('grid-template-rows')
|
||||
.split(' ');
|
||||
|
||||
const containerRect = container?.getBoundingClientRect();
|
||||
|
||||
formItems.forEach((el) => {
|
||||
const itemRect = el.getBoundingClientRect();
|
||||
|
||||
// 计算元素在第几行
|
||||
const itemTop = itemRect.top - containerRect.top;
|
||||
let rowStart = 0;
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (const [i, rowHeight] of rowHeights.entries()) {
|
||||
cumulativeHeight += Number.parseFloat(rowHeight);
|
||||
if (itemTop < cumulativeHeight) {
|
||||
rowStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rowStart > (props?.collapsedRows ?? 1)) {
|
||||
return;
|
||||
}
|
||||
rowMapping.value[rowStart] = (rowMapping.value[rowStart] ?? 0) + 1;
|
||||
isCalculated.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
calculateRowMapping();
|
||||
});
|
||||
|
||||
return { isCalculated, keepFormItemIndex, wrapperRef };
|
||||
}
|
||||
374
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
374
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<script setup lang="ts">
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import type { FormSchema, MaybeComponentProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { useFieldError, useFormValues } from 'vee-validate';
|
||||
|
||||
import { injectComponentRefMap } from '../use-form-context';
|
||||
import { injectRenderFormProps, useFormContext } from './context';
|
||||
import useDependencies from './dependencies';
|
||||
import FormLabel from './form-label.vue';
|
||||
import { isEventObjectLike } from './helper';
|
||||
|
||||
interface Props extends FormSchema {}
|
||||
|
||||
const {
|
||||
colon,
|
||||
commonComponentProps,
|
||||
component,
|
||||
componentProps,
|
||||
dependencies,
|
||||
description,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
fieldName,
|
||||
formFieldProps,
|
||||
label,
|
||||
labelClass,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
renderComponentContent,
|
||||
rules,
|
||||
} = defineProps<
|
||||
Props & {
|
||||
commonComponentProps: MaybeComponentProps;
|
||||
}
|
||||
>();
|
||||
|
||||
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
const values = useFormValues();
|
||||
const errors = useFieldError(fieldName);
|
||||
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
|
||||
const formApi = formRenderProps.form;
|
||||
const compact = formRenderProps.compact;
|
||||
const isInValid = computed(() => errors.value?.length > 0);
|
||||
|
||||
const FieldComponent = computed(() => {
|
||||
const finalComponent = isString(component)
|
||||
? componentMap.value[component]
|
||||
: component;
|
||||
if (!finalComponent) {
|
||||
// 组件未注册
|
||||
console.warn(`Component ${component} is not registered`);
|
||||
}
|
||||
return finalComponent;
|
||||
});
|
||||
|
||||
const {
|
||||
dynamicComponentProps,
|
||||
dynamicRules,
|
||||
isDisabled,
|
||||
isIf,
|
||||
isRequired,
|
||||
isShow,
|
||||
} = useDependencies(() => dependencies);
|
||||
|
||||
const labelStyle = computed(() => {
|
||||
return labelClass?.includes('w-') || isVertical.value
|
||||
? {}
|
||||
: {
|
||||
width: `${labelWidth}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const currentRules = computed(() => {
|
||||
return dynamicRules.value || rules;
|
||||
});
|
||||
|
||||
const visible = computed(() => {
|
||||
return isIf.value && isShow.value;
|
||||
});
|
||||
|
||||
const shouldRequired = computed(() => {
|
||||
if (!visible.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentRules.value) {
|
||||
return isRequired.value;
|
||||
}
|
||||
|
||||
if (isRequired.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isString(currentRules.value)) {
|
||||
return ['required', 'selectRequired'].includes(currentRules.value);
|
||||
}
|
||||
|
||||
let isOptional = currentRules?.value?.isOptional?.();
|
||||
|
||||
// 如果有设置默认值,则不是必填,需要特殊处理
|
||||
const typeName = currentRules?.value?._def?.typeName;
|
||||
if (typeName === 'ZodDefault') {
|
||||
const innerType = currentRules?.value?._def.innerType;
|
||||
if (innerType) {
|
||||
isOptional = innerType.isOptional?.();
|
||||
}
|
||||
}
|
||||
|
||||
return !isOptional;
|
||||
});
|
||||
|
||||
const fieldRules = computed(() => {
|
||||
if (!visible.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rules = currentRules.value;
|
||||
if (!rules) {
|
||||
return isRequired.value ? 'required' : null;
|
||||
}
|
||||
|
||||
if (isString(rules)) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const isOptional = !shouldRequired.value;
|
||||
if (!isOptional) {
|
||||
const unwrappedRules = (rules as any)?.unwrap?.();
|
||||
if (unwrappedRules) {
|
||||
rules = unwrappedRules;
|
||||
}
|
||||
}
|
||||
return toTypedSchema(rules as ZodType);
|
||||
});
|
||||
|
||||
const computedProps = computed(() => {
|
||||
const finalComponentProps = isFunction(componentProps)
|
||||
? componentProps(values.value, formApi!)
|
||||
: componentProps;
|
||||
|
||||
return {
|
||||
...commonComponentProps,
|
||||
...finalComponentProps,
|
||||
...dynamicComponentProps.value,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => computedProps.value?.autofocus,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
nextTick(() => {
|
||||
autofocus();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const shouldDisabled = computed(() => {
|
||||
return isDisabled.value || disabled || computedProps.value?.disabled;
|
||||
});
|
||||
|
||||
const customContentRender = computed(() => {
|
||||
if (!isFunction(renderComponentContent)) {
|
||||
return {};
|
||||
}
|
||||
return renderComponentContent(values.value, formApi!);
|
||||
});
|
||||
|
||||
const renderContentKey = computed(() => {
|
||||
return Object.keys(customContentRender.value);
|
||||
});
|
||||
|
||||
const fieldProps = computed(() => {
|
||||
const rules = fieldRules.value;
|
||||
return {
|
||||
keepValue: true,
|
||||
label: isString(label) ? label : '',
|
||||
...(rules ? { rules } : {}),
|
||||
...(formFieldProps as Record<string, any>),
|
||||
};
|
||||
});
|
||||
|
||||
function fieldBindEvent(slotProps: Record<string, any>) {
|
||||
const modelValue = slotProps.componentField.modelValue;
|
||||
const handler = slotProps.componentField['onUpdate:modelValue'];
|
||||
|
||||
const bindEventField =
|
||||
modelPropName ||
|
||||
(isString(component) ? componentBindEventMap.value?.[component] : null);
|
||||
|
||||
let value = modelValue;
|
||||
// antd design 的一些组件会传递一个 event 对象
|
||||
if (modelValue && isObject(modelValue) && bindEventField) {
|
||||
value = isEventObjectLike(modelValue)
|
||||
? modelValue?.target?.[bindEventField]
|
||||
: (modelValue?.[bindEventField] ?? modelValue);
|
||||
}
|
||||
|
||||
if (bindEventField) {
|
||||
return {
|
||||
[`onUpdate:${bindEventField}`]: handler,
|
||||
[bindEventField]: value === undefined ? emptyStateValue : value,
|
||||
onChange: disabledOnChangeListener
|
||||
? undefined
|
||||
: (e: Record<string, any>) => {
|
||||
const shouldUnwrap = isEventObjectLike(e);
|
||||
const onChange = slotProps?.componentField?.onChange;
|
||||
if (!shouldUnwrap) {
|
||||
return onChange?.(e);
|
||||
}
|
||||
|
||||
return onChange?.(e?.target?.[bindEventField] ?? e);
|
||||
},
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
...(disabledOnChangeListener ? { onChange: undefined } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createComponentProps(slotProps: Record<string, any>) {
|
||||
const bindEvents = fieldBindEvent(slotProps);
|
||||
|
||||
const binds = {
|
||||
...slotProps.componentField,
|
||||
...computedProps.value,
|
||||
...bindEvents,
|
||||
...(Reflect.has(computedProps.value, 'onChange')
|
||||
? { onChange: computedProps.value.onChange }
|
||||
: {}),
|
||||
...(Reflect.has(computedProps.value, 'onInput')
|
||||
? { onInput: computedProps.value.onInput }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return binds;
|
||||
}
|
||||
|
||||
function autofocus() {
|
||||
if (
|
||||
fieldComponentRef.value &&
|
||||
isFunction(fieldComponentRef.value.focus) &&
|
||||
// 检查当前是否有元素被聚焦
|
||||
document.activeElement !== fieldComponentRef.value
|
||||
) {
|
||||
fieldComponentRef.value?.focus?.();
|
||||
}
|
||||
}
|
||||
const componentRefMap = injectComponentRefMap();
|
||||
watch(fieldComponentRef, (componentRef) => {
|
||||
componentRefMap?.set(fieldName, componentRef);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (componentRefMap?.has(fieldName)) {
|
||||
componentRefMap.delete(fieldName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField
|
||||
v-if="isIf"
|
||||
v-bind="fieldProps"
|
||||
v-slot="slotProps"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem
|
||||
v-show="isShow"
|
||||
:class="{
|
||||
'form-valid-error': isInValid,
|
||||
'form-is-required': shouldRequired,
|
||||
'flex-col': isVertical,
|
||||
'flex-row items-center': !isVertical,
|
||||
'pb-6': !compact,
|
||||
'pb-2': compact,
|
||||
}"
|
||||
class="relative flex"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<FormLabel
|
||||
v-if="!hideLabel"
|
||||
:class="
|
||||
cn(
|
||||
'flex leading-6',
|
||||
{
|
||||
'mr-2 flex-shrink-0 justify-end': !isVertical,
|
||||
'mb-1 flex-row': isVertical,
|
||||
},
|
||||
labelClass,
|
||||
)
|
||||
"
|
||||
:help="help"
|
||||
:colon="colon"
|
||||
:label="label"
|
||||
:required="shouldRequired && !hideRequiredMark"
|
||||
:style="labelStyle"
|
||||
>
|
||||
<template v-if="label">
|
||||
<VbenRenderContent :content="label" />
|
||||
</template>
|
||||
</FormLabel>
|
||||
<div class="flex-auto overflow-hidden p-[1px]">
|
||||
<div :class="cn('relative flex w-full items-center', wrapperClass)">
|
||||
<FormControl :class="cn(controlClass)">
|
||||
<slot
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
...createComponentProps(slotProps),
|
||||
disabled: shouldDisabled,
|
||||
isInValid,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
isInValid,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
>
|
||||
<template
|
||||
v-for="name in renderContentKey"
|
||||
:key="name"
|
||||
#[name]="renderSlotProps"
|
||||
>
|
||||
<VbenRenderContent
|
||||
:content="customContentRender[name]"
|
||||
v-bind="{ ...renderSlotProps, formContext: slotProps }"
|
||||
/>
|
||||
</template>
|
||||
<!-- <slot></slot> -->
|
||||
</component>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<!-- 自定义后缀 -->
|
||||
<div v-if="suffix" class="ml-1">
|
||||
<VbenRenderContent :content="suffix" />
|
||||
</div>
|
||||
<FormDescription v-if="description" class="ml-1">
|
||||
<VbenRenderContent :content="description" />
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<FormMessage class="absolute bottom-1" />
|
||||
</Transition>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { CustomRenderType } from '../types';
|
||||
|
||||
import {
|
||||
FormLabel,
|
||||
VbenHelpTooltip,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
colon?: boolean;
|
||||
help?: CustomRenderType;
|
||||
label?: CustomRenderType;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel :class="cn('flex items-center', props.class)">
|
||||
<span v-if="required" class="text-destructive mr-[2px]">*</span>
|
||||
<slot></slot>
|
||||
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
|
||||
<VbenRenderContent :content="help" />
|
||||
</VbenHelpTooltip>
|
||||
<span v-if="colon && label" class="ml-[2px]">:</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
165
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
165
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type {
|
||||
FormCommonConfig,
|
||||
FormRenderProps,
|
||||
FormSchema,
|
||||
FormShape,
|
||||
} from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Form } from '@vben-core/shadcn-ui';
|
||||
import { cn, isString, mergeWithArrayOverride } from '@vben-core/shared/utils';
|
||||
|
||||
import { provideFormRenderProps } from './context';
|
||||
import { useExpandable } from './expandable';
|
||||
import FormField from './form-field.vue';
|
||||
import { getBaseRules, getDefaultValueInZodStack } from './helper';
|
||||
|
||||
interface Props extends FormRenderProps {}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<Props & { globalCommonConfig?: FormCommonConfig }>(),
|
||||
{
|
||||
collapsedRows: 1,
|
||||
commonConfig: () => ({}),
|
||||
globalCommonConfig: () => ({}),
|
||||
showCollapseButton: false,
|
||||
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [event: any];
|
||||
}>();
|
||||
|
||||
provideFormRenderProps(props);
|
||||
|
||||
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);
|
||||
|
||||
const shapes = computed(() => {
|
||||
const resultShapes: FormShape[] = [];
|
||||
props.schema?.forEach((schema) => {
|
||||
const { fieldName } = schema;
|
||||
const rules = schema.rules as ZodTypeAny;
|
||||
|
||||
let typeName = '';
|
||||
if (rules && !isString(rules)) {
|
||||
typeName = rules._def.typeName;
|
||||
}
|
||||
|
||||
const baseRules = getBaseRules(rules) as ZodTypeAny;
|
||||
|
||||
resultShapes.push({
|
||||
default: getDefaultValueInZodStack(rules),
|
||||
fieldName,
|
||||
required: !['ZodNullable', 'ZodOptional'].includes(typeName),
|
||||
rules: baseRules,
|
||||
});
|
||||
});
|
||||
return resultShapes;
|
||||
});
|
||||
|
||||
const formComponent = computed(() => (props.form ? 'form' : Form));
|
||||
|
||||
const formComponentProps = computed(() => {
|
||||
return props.form
|
||||
? {
|
||||
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
|
||||
}
|
||||
: {
|
||||
onSubmit: (val: GenericObject) => emits('submit', val),
|
||||
};
|
||||
});
|
||||
|
||||
const formCollapsed = computed(() => {
|
||||
return props.collapsed && isCalculated.value;
|
||||
});
|
||||
|
||||
const computedSchema = computed(
|
||||
(): (Omit<FormSchema, 'formFieldProps'> & {
|
||||
commonComponentProps: Record<string, any>;
|
||||
formFieldProps: Record<string, any>;
|
||||
})[] => {
|
||||
const {
|
||||
colon = false,
|
||||
componentProps = {},
|
||||
controlClass = '',
|
||||
disabled,
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
formFieldProps = {},
|
||||
formItemClass = '',
|
||||
hideLabel = false,
|
||||
hideRequiredMark = false,
|
||||
labelClass = '',
|
||||
labelWidth = 100,
|
||||
modelPropName = '',
|
||||
wrapperClass = '',
|
||||
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
|
||||
return (props.schema || []).map((schema, index) => {
|
||||
const keepIndex = keepFormItemIndex.value;
|
||||
|
||||
const hidden =
|
||||
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
|
||||
props.showCollapseButton && !!formCollapsed.value && keepIndex
|
||||
? keepIndex <= index
|
||||
: false;
|
||||
|
||||
return {
|
||||
colon,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
hideLabel,
|
||||
hideRequiredMark,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
wrapperClass,
|
||||
...schema,
|
||||
commonComponentProps: componentProps,
|
||||
componentProps: schema.componentProps,
|
||||
controlClass: cn(controlClass, schema.controlClass),
|
||||
formFieldProps: {
|
||||
...formFieldProps,
|
||||
...schema.formFieldProps,
|
||||
},
|
||||
formItemClass: cn(
|
||||
'flex-shrink-0',
|
||||
{ hidden },
|
||||
formItemClass,
|
||||
schema.formItemClass,
|
||||
),
|
||||
labelClass: cn(labelClass, schema.labelClass),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="formComponent" v-bind="formComponentProps">
|
||||
<div ref="wrapperRef" :class="wrapperClass" class="grid">
|
||||
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
|
||||
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
|
||||
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot>
|
||||
</div> -->
|
||||
<FormField
|
||||
v-bind="cSchema"
|
||||
:class="cSchema.formItemClass"
|
||||
:rules="cSchema.rules"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps" :name="cSchema.fieldName"> </slot>
|
||||
</template>
|
||||
</FormField>
|
||||
</template>
|
||||
<slot :shapes="shapes"></slot>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
AnyZodObject,
|
||||
ZodDefault,
|
||||
ZodEffects,
|
||||
ZodNumber,
|
||||
ZodString,
|
||||
ZodTypeAny,
|
||||
} from 'zod';
|
||||
|
||||
import { isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
/**
|
||||
* Get the lowest level Zod type.
|
||||
* This will unpack optionals, refinements, etc.
|
||||
*/
|
||||
export function getBaseRules<
|
||||
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
|
||||
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
|
||||
if (!schema || isString(schema)) return null;
|
||||
if ('innerType' in schema._def)
|
||||
return getBaseRules(schema._def.innerType as ChildType);
|
||||
|
||||
if ('schema' in schema._def)
|
||||
return getBaseRules(schema._def.schema as ChildType);
|
||||
|
||||
return schema as ChildType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a "ZodDefault" in the Zod stack and return its value.
|
||||
*/
|
||||
export function getDefaultValueInZodStack(schema: ZodTypeAny): any {
|
||||
if (!schema || isString(schema)) {
|
||||
return;
|
||||
}
|
||||
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
|
||||
|
||||
if (typedSchema._def.typeName === 'ZodDefault')
|
||||
return typedSchema._def.defaultValue();
|
||||
|
||||
if ('innerType' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
typedSchema._def.innerType as unknown as ZodTypeAny,
|
||||
);
|
||||
}
|
||||
if ('schema' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
(typedSchema._def as any).schema as ZodTypeAny,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isEventObjectLike(obj: any) {
|
||||
if (!obj || !isObject(obj)) {
|
||||
return false;
|
||||
}
|
||||
return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation');
|
||||
}
|
||||
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FormField } from './form-field.vue';
|
||||
export { default as FormLabel } from './form-label.vue';
|
||||
export { default as Form } from './form.vue';
|
||||
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { setupVbenForm } from './config';
|
||||
|
||||
export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
VbenFormProps,
|
||||
FormSchema as VbenFormSchema,
|
||||
} from './types';
|
||||
|
||||
export * from './use-vben-form';
|
||||
// export { default as VbenForm } from './vben-form.vue';
|
||||
export * as z from 'zod';
|
||||
443
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
443
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import type { FieldOptions, FormContext, GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
|
||||
|
||||
import type { VbenButtonProps } from '@vben-core/shadcn-ui';
|
||||
import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
|
||||
|
||||
import type { FormApi } from './form-api';
|
||||
|
||||
export type FormLayout = 'horizontal' | 'vertical';
|
||||
|
||||
export type BaseFormComponentType =
|
||||
| 'DefaultButton'
|
||||
| 'PrimaryButton'
|
||||
| 'TinyMCE'
|
||||
| 'VbenCheckbox'
|
||||
| 'VbenInput'
|
||||
| 'VbenInputPassword'
|
||||
| 'VbenPinInput'
|
||||
| 'VbenSelect'
|
||||
| (Record<never, never> & string);
|
||||
|
||||
type Breakpoints = '2xl:' | '3xl:' | '' | 'lg:' | 'md:' | 'sm:' | 'xl:';
|
||||
|
||||
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
|
||||
|
||||
export type WrapperClassType =
|
||||
| `${Breakpoints}grid-cols-${GridCols}`
|
||||
| (Record<never, never> & string);
|
||||
|
||||
export type FormItemClassType =
|
||||
| `${Breakpoints}cols-end-${'auto' | GridCols}`
|
||||
| `${Breakpoints}cols-span-${'auto' | 'full' | GridCols}`
|
||||
| `${Breakpoints}cols-start-${'auto' | GridCols}`
|
||||
| (Record<never, never> & string)
|
||||
| WrapperClassType;
|
||||
|
||||
export type FormFieldOptions = Partial<
|
||||
FieldOptions & {
|
||||
validateOnBlur?: boolean;
|
||||
validateOnChange?: boolean;
|
||||
validateOnInput?: boolean;
|
||||
validateOnModelUpdate?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface FormShape {
|
||||
/** 默认值 */
|
||||
default?: any;
|
||||
/** 字段名 */
|
||||
fieldName: string;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
rules?: ZodTypeAny;
|
||||
}
|
||||
|
||||
export type MaybeComponentPropKey =
|
||||
| 'options'
|
||||
| 'placeholder'
|
||||
| 'title'
|
||||
| keyof HtmlHTMLAttributes
|
||||
| (Record<never, never> & string);
|
||||
|
||||
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
|
||||
|
||||
export type FormActions = FormContext<GenericObject>;
|
||||
|
||||
export type CustomRenderType = (() => Component | string) | string;
|
||||
|
||||
export type FormSchemaRuleType =
|
||||
| 'required'
|
||||
| 'selectRequired'
|
||||
| null
|
||||
| (Record<never, never> & string)
|
||||
| ZodTypeAny;
|
||||
|
||||
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => T;
|
||||
|
||||
type FormItemDependenciesConditionWithRules = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
|
||||
|
||||
type FormItemDependenciesConditionWithProps = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
|
||||
|
||||
export interface FormItemDependencies {
|
||||
/**
|
||||
* 组件参数
|
||||
* @returns 组件参数
|
||||
*/
|
||||
componentProps?: FormItemDependenciesConditionWithProps;
|
||||
/**
|
||||
* 是否禁用
|
||||
* @returns 是否禁用
|
||||
*/
|
||||
disabled?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否渲染(删除dom)
|
||||
* @returns 是否渲染
|
||||
*/
|
||||
if?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否必填
|
||||
* @returns 是否必填
|
||||
*/
|
||||
required?: FormItemDependenciesCondition;
|
||||
/**
|
||||
* 字段规则
|
||||
*/
|
||||
rules?: FormItemDependenciesConditionWithRules;
|
||||
/**
|
||||
* 是否隐藏(Css)
|
||||
* @returns 是否隐藏
|
||||
*/
|
||||
show?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 任意触发都会执行
|
||||
*/
|
||||
trigger?: FormItemDependenciesCondition<void>;
|
||||
/**
|
||||
* 触发字段
|
||||
*/
|
||||
triggerFields: string[];
|
||||
}
|
||||
|
||||
type ComponentProps =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => MaybeComponentProps)
|
||||
| MaybeComponentProps;
|
||||
|
||||
export interface FormCommonConfig {
|
||||
/**
|
||||
* 在Label后显示一个冒号
|
||||
*/
|
||||
colon?: boolean;
|
||||
/**
|
||||
* 所有表单项的props
|
||||
*/
|
||||
componentProps?: ComponentProps;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
*/
|
||||
controlClass?: string;
|
||||
/**
|
||||
* 所有表单项的禁用状态
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的change事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnChangeListener?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的input事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnInputListener?: boolean;
|
||||
/**
|
||||
* 所有表单项的空状态值,默认都是undefined,naive-ui的空状态值是null
|
||||
*/
|
||||
emptyStateValue?: null | undefined;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
* @default {}
|
||||
*/
|
||||
formFieldProps?: FormFieldOptions;
|
||||
/**
|
||||
* 所有表单项的栅格布局
|
||||
* @default ""
|
||||
*/
|
||||
formItemClass?: string;
|
||||
/**
|
||||
* 隐藏所有表单项label
|
||||
* @default false
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* 是否隐藏必填标记
|
||||
* @default false
|
||||
*/
|
||||
hideRequiredMark?: boolean;
|
||||
/**
|
||||
* 所有表单项的label样式
|
||||
* @default ""
|
||||
*/
|
||||
labelClass?: string;
|
||||
/**
|
||||
* 所有表单项的label宽度
|
||||
*/
|
||||
labelWidth?: number;
|
||||
/**
|
||||
* 所有表单项的model属性名
|
||||
* @default "modelValue"
|
||||
*/
|
||||
modelPropName?: string;
|
||||
/**
|
||||
* 所有表单项的wrapper样式
|
||||
*/
|
||||
wrapperClass?: string;
|
||||
}
|
||||
|
||||
type RenderComponentContentType = (
|
||||
value: Partial<Record<string, any>>,
|
||||
api: FormActions,
|
||||
) => Record<string, any>;
|
||||
|
||||
export type HandleSubmitFn = (
|
||||
values: Record<string, any>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export type HandleResetFn = (
|
||||
values: Record<string, any>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export type FieldMappingTime = [
|
||||
string,
|
||||
[string, string],
|
||||
(
|
||||
| ((value: any, fieldName: string) => any)
|
||||
| [string, string]
|
||||
| null
|
||||
| string
|
||||
)?,
|
||||
][];
|
||||
|
||||
export type ArrayToStringFields = Array<
|
||||
| [string[], string?] // 嵌套数组格式,可选分隔符
|
||||
| string // 单个字段,使用默认分隔符
|
||||
| string[] // 简单数组格式,最后一个元素可以是分隔符
|
||||
>;
|
||||
|
||||
export interface FormSchema<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends FormCommonConfig {
|
||||
/** 组件 */
|
||||
component: Component | T;
|
||||
/** 组件参数 */
|
||||
componentProps?: ComponentProps;
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
/** 依赖 */
|
||||
dependencies?: FormItemDependencies;
|
||||
/** 描述 */
|
||||
description?: CustomRenderType;
|
||||
/** 字段名 */
|
||||
fieldName: string;
|
||||
/** 帮助信息 */
|
||||
help?: CustomRenderType;
|
||||
/** 表单项 */
|
||||
label?: CustomRenderType;
|
||||
// 自定义组件内部渲染
|
||||
renderComponentContent?: RenderComponentContentType;
|
||||
/** 字段规则 */
|
||||
rules?: FormSchemaRuleType;
|
||||
/** 后缀 */
|
||||
suffix?: CustomRenderType;
|
||||
}
|
||||
|
||||
export interface FormFieldProps extends FormSchema {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface FormRenderProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
/**
|
||||
* 是否展开,在showCollapseButton=true下生效
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* 折叠时保持行数
|
||||
* @default 1
|
||||
*/
|
||||
collapsedRows?: number;
|
||||
/**
|
||||
* 是否触发resize事件
|
||||
* @default false
|
||||
*/
|
||||
collapseTriggerResize?: boolean;
|
||||
/**
|
||||
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
|
||||
*/
|
||||
commonConfig?: FormCommonConfig;
|
||||
/**
|
||||
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* 组件v-model事件绑定
|
||||
*/
|
||||
componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>;
|
||||
/**
|
||||
* 组件集合
|
||||
*/
|
||||
componentMap: Record<BaseFormComponentType, Component>;
|
||||
/**
|
||||
* 表单字段映射到时间格式
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单实例
|
||||
*/
|
||||
form?: FormContext<GenericObject>;
|
||||
/**
|
||||
* 表单项布局
|
||||
*/
|
||||
layout?: FormLayout;
|
||||
/**
|
||||
* 表单定义
|
||||
*/
|
||||
schema?: FormSchema<T>[];
|
||||
|
||||
/**
|
||||
* 是否显示展开/折叠
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表单栅格布局
|
||||
* @default "grid-cols-1"
|
||||
*/
|
||||
wrapperClass?: WrapperClassType;
|
||||
}
|
||||
|
||||
export interface ActionButtonOptions extends VbenButtonProps {
|
||||
[key: string]: any;
|
||||
content?: MaybeComputedRef<string>;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export interface VbenFormProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends Omit<
|
||||
FormRenderProps<T>,
|
||||
'componentBindEventMap' | 'componentMap' | 'form'
|
||||
> {
|
||||
/**
|
||||
* 操作按钮是否反转(提交按钮前置)
|
||||
*/
|
||||
actionButtonsReverse?: boolean;
|
||||
/**
|
||||
* 表单操作区域class
|
||||
*/
|
||||
actionWrapperClass?: ClassType;
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
|
||||
/**
|
||||
* 表单字段映射
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单重置回调
|
||||
*/
|
||||
handleReset?: HandleResetFn;
|
||||
/**
|
||||
* 表单提交回调
|
||||
*/
|
||||
handleSubmit?: HandleSubmitFn;
|
||||
/**
|
||||
* 表单值变化回调
|
||||
*/
|
||||
handleValuesChange?: (
|
||||
values: Record<string, any>,
|
||||
fieldsChanged: string[],
|
||||
) => void;
|
||||
/**
|
||||
* 重置按钮参数
|
||||
*/
|
||||
resetButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 是否显示默认操作按钮
|
||||
* @default true
|
||||
*/
|
||||
showDefaultActions?: boolean;
|
||||
|
||||
/**
|
||||
* 提交按钮参数
|
||||
*/
|
||||
submitButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 是否在字段值改变时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnChange?: boolean;
|
||||
|
||||
/**
|
||||
* 是否在回车时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedFormApi = FormApi & {
|
||||
useStore: <T = NoInfer<VbenFormProps>>(
|
||||
selector?: (state: NoInfer<VbenFormProps>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface VbenFormAdapterOptions<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
config?: {
|
||||
baseModelPropName?: string;
|
||||
disabledOnChangeListener?: boolean;
|
||||
disabledOnInputListener?: boolean;
|
||||
emptyStateValue?: null | undefined;
|
||||
modelPropNameMap?: Partial<Record<T, string>>;
|
||||
};
|
||||
defineRules?: {
|
||||
required?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
selectRequired?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
};
|
||||
}
|
||||
72
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
72
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ZodRawShape } from 'zod';
|
||||
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
|
||||
|
||||
import { computed, unref, useSlots } from 'vue';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useForm } from 'vee-validate';
|
||||
import { object } from 'zod';
|
||||
import { getDefaultsForSchema } from 'zod-defaults';
|
||||
|
||||
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
|
||||
|
||||
export const [injectFormProps, provideFormProps] =
|
||||
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
|
||||
'VbenFormProps',
|
||||
);
|
||||
|
||||
export const [injectComponentRefMap, provideComponentRefMap] =
|
||||
createContext<Map<string, unknown>>('ComponentRefMap');
|
||||
|
||||
export function useFormInitial(
|
||||
props: ComputedRef<VbenFormProps> | VbenFormProps,
|
||||
) {
|
||||
const slots = useSlots();
|
||||
const initialValues = generateInitialValues();
|
||||
|
||||
const form = useForm({
|
||||
...(Object.keys(initialValues)?.length ? { initialValues } : {}),
|
||||
});
|
||||
|
||||
const delegatedSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (key !== 'default') {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots;
|
||||
});
|
||||
|
||||
function generateInitialValues() {
|
||||
const initialValues: Record<string, any> = {};
|
||||
|
||||
const zodObject: ZodRawShape = {};
|
||||
(unref(props).schema || []).forEach((item) => {
|
||||
if (Reflect.has(item, 'defaultValue')) {
|
||||
set(initialValues, item.fieldName, item.defaultValue);
|
||||
} else if (item.rules && !isString(item.rules)) {
|
||||
zodObject[item.fieldName] = item.rules;
|
||||
}
|
||||
});
|
||||
|
||||
const schemaInitialValues = getDefaultsForSchema(object(zodObject));
|
||||
|
||||
const zodDefaults: Record<string, any> = {};
|
||||
for (const key in schemaInitialValues) {
|
||||
set(zodDefaults, key, schemaInitialValues[key]);
|
||||
}
|
||||
return mergeWithArrayOverride(initialValues, zodDefaults);
|
||||
}
|
||||
|
||||
return {
|
||||
delegatedSlots,
|
||||
form,
|
||||
};
|
||||
}
|
||||
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
VbenFormProps,
|
||||
} from './types';
|
||||
|
||||
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
import { useStore } from '@vben-core/shared/store';
|
||||
|
||||
import { FormApi } from './form-api';
|
||||
import VbenUseForm from './vben-use-form.vue';
|
||||
|
||||
export function useVbenForm<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
>(options: VbenFormProps<T>) {
|
||||
const IS_REACTIVE = isReactive(options);
|
||||
const api = new FormApi(options);
|
||||
const extendedApi: ExtendedFormApi = api as never;
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Form = defineComponent(
|
||||
(props: VbenFormProps, { attrs, slots }) => {
|
||||
onBeforeUnmount(() => {
|
||||
api.unmount();
|
||||
});
|
||||
api.setState({ ...props, ...attrs });
|
||||
return () =>
|
||||
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenUseForm',
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
if (IS_REACTIVE) {
|
||||
watch(
|
||||
() => options.schema,
|
||||
() => {
|
||||
api.setState({ schema: options.schema });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
return [Form, extendedApi] as const;
|
||||
}
|
||||
77
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
77
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from './types';
|
||||
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from '@vben-core/composables';
|
||||
|
||||
import FormActions from './components/form-actions.vue';
|
||||
import {
|
||||
COMPONENT_BIND_EVENT_MAP,
|
||||
COMPONENT_MAP,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} from './config';
|
||||
import { Form } from './form-render';
|
||||
import { provideFormProps, useFormInitial } from './use-form-context';
|
||||
|
||||
// 通过 extends 会导致热更新卡死
|
||||
interface Props extends VbenFormProps {}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
commonConfig: () => ({}),
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: () => ({}),
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: () => ({}),
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
const forward = useForwardPropsEmits(props);
|
||||
|
||||
const currentCollapsed = ref(false);
|
||||
|
||||
const { delegatedSlots, form } = useFormInitial(props);
|
||||
|
||||
provideFormProps([props, form]);
|
||||
|
||||
const handleUpdateCollapsed = (value: boolean) => {
|
||||
currentCollapsed.value = !!value;
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
currentCollapsed.value = props.collapsed;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
v-bind="forward"
|
||||
:collapsed="currentCollapsed"
|
||||
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
|
||||
:component-map="COMPONENT_MAP"
|
||||
:form="form"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps">
|
||||
<FormActions
|
||||
v-if="showDefaultActions"
|
||||
:model-value="currentCollapsed"
|
||||
@update:model-value="handleUpdateCollapsed"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
||||
148
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
148
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { ExtendedFormApi, VbenFormProps } from './types';
|
||||
|
||||
// import { toRaw, watch } from 'vue';
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { useForwardPriorityValues } from '@vben-core/composables';
|
||||
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
import FormActions from './components/form-actions.vue';
|
||||
import {
|
||||
COMPONENT_BIND_EVENT_MAP,
|
||||
COMPONENT_MAP,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} from './config';
|
||||
import { Form } from './form-render';
|
||||
import {
|
||||
provideComponentRefMap,
|
||||
provideFormProps,
|
||||
useFormInitial,
|
||||
} from './use-form-context';
|
||||
// 通过 extends 会导致热更新卡死,所以重复写了一遍
|
||||
interface Props extends VbenFormProps {
|
||||
formApi: ExtendedFormApi;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const state = props.formApi?.useStore?.();
|
||||
|
||||
const forward = useForwardPriorityValues(props, state);
|
||||
|
||||
const componentRefMap = new Map<string, unknown>();
|
||||
|
||||
const { delegatedSlots, form } = useFormInitial(forward);
|
||||
|
||||
provideFormProps([forward, form]);
|
||||
provideComponentRefMap(componentRefMap);
|
||||
|
||||
props.formApi?.mount?.(form, componentRefMap);
|
||||
|
||||
const handleUpdateCollapsed = (value: boolean) => {
|
||||
props.formApi?.setState({ collapsed: !!value });
|
||||
};
|
||||
|
||||
function handleKeyDownEnter(event: KeyboardEvent) {
|
||||
if (!state.value.submitOnEnter || !forward.value.formApi?.isMounted) {
|
||||
return;
|
||||
}
|
||||
// 如果是 textarea 不阻止默认行为,否则会导致无法换行。
|
||||
// 跳过 textarea 的回车提交处理
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
forward.value.formApi.validateAndSubmitForm();
|
||||
}
|
||||
|
||||
const handleValuesChangeDebounced = useDebounceFn(async () => {
|
||||
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
|
||||
}, 300);
|
||||
|
||||
const valuesCache: Recordable<any> = {};
|
||||
|
||||
onMounted(async () => {
|
||||
// 只在挂载后开始监听,form.values会有一个初始化的过程
|
||||
await nextTick();
|
||||
watch(
|
||||
() => form.values,
|
||||
async (newVal) => {
|
||||
if (forward.value.handleValuesChange) {
|
||||
const fields = state.value.schema?.map((item) => {
|
||||
return item.fieldName;
|
||||
});
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
const changedFields: string[] = [];
|
||||
fields.forEach((field) => {
|
||||
const newFieldValue = get(newVal, field);
|
||||
const oldFieldValue = get(valuesCache, field);
|
||||
if (!isEqual(newFieldValue, oldFieldValue)) {
|
||||
changedFields.push(field);
|
||||
set(valuesCache, field, newFieldValue);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
// 调用handleValuesChange回调,传入所有表单值的深拷贝和变更的字段列表
|
||||
forward.value.handleValuesChange(
|
||||
cloneDeep(await forward.value.formApi.getValues()),
|
||||
changedFields,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleValuesChangeDebounced();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
@keydown.enter="handleKeyDownEnter"
|
||||
v-bind="forward"
|
||||
:collapsed="state.collapsed"
|
||||
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
|
||||
:component-map="COMPONENT_MAP"
|
||||
:form="form"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps">
|
||||
<FormActions
|
||||
v-if="forward.showDefaultActions"
|
||||
:model-value="state.collapsed"
|
||||
@update:model-value="handleUpdateCollapsed"
|
||||
>
|
||||
<template #reset-before="resetSlotProps">
|
||||
<slot name="reset-before" v-bind="resetSlotProps"></slot>
|
||||
</template>
|
||||
<template #submit-before="submitSlotProps">
|
||||
<slot name="submit-before" v-bind="submitSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-before="expandBeforeSlotProps">
|
||||
<slot name="expand-before" v-bind="expandBeforeSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-after="expandAfterSlotProps">
|
||||
<slot name="expand-after" v-bind="expandAfterSlotProps"></slot>
|
||||
</template>
|
||||
</FormActions>
|
||||
</slot>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
||||
1
packages/@core/ui-kit/form-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/form-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/form-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/form-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src", "__tests__"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
packages/@core/ui-kit/layout-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/layout-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
48
packages/@core/ui-kit/layout-ui/package.json
Normal file
48
packages/@core/ui-kit/layout-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/layout-ui",
|
||||
"version": "5.5.4",
|
||||
"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/@vben-core/uikit/layout-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/layout-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/layout-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
5
packages/@core/ui-kit/layout-ui/src/components/index.ts
Normal file
5
packages/@core/ui-kit/layout-ui/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutContent } from './layout-content.vue';
|
||||
export { default as LayoutFooter } from './layout-footer.vue';
|
||||
export { default as LayoutHeader } from './layout-header.vue';
|
||||
export { default as LayoutSidebar } from './layout-sidebar.vue';
|
||||
export { default as LayoutTabbar } from './layout-tabbar.vue';
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ContentCompactType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useLayoutContentStyle } from '@vben-core/composables';
|
||||
import { Slot } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
*/
|
||||
contentCompact: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
*/
|
||||
contentCompactWidth: number;
|
||||
padding: number;
|
||||
paddingBottom: number;
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
paddingTop: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const { contentElement, overlayStyle } = useLayoutContentStyle();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const {
|
||||
contentCompact,
|
||||
padding,
|
||||
paddingBottom,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingTop,
|
||||
} = props;
|
||||
|
||||
const compactStyle: CSSProperties =
|
||||
contentCompact === 'compact'
|
||||
? { margin: '0 auto', width: `${props.contentCompactWidth}px` }
|
||||
: {};
|
||||
return {
|
||||
...compactStyle,
|
||||
flex: 1,
|
||||
padding: `${padding}px`,
|
||||
paddingBottom: `${paddingBottom}px`,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
paddingRight: `${paddingRight}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="contentElement" :style="style" class="bg-background-deep relative">
|
||||
<Slot :style="overlayStyle">
|
||||
<slot name="overlay"></slot>
|
||||
</Slot>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否固定在底部
|
||||
*/
|
||||
fixed?: boolean;
|
||||
height: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
width: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: true,
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fixed, height, show, width, zIndex } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginBottom: show ? '0' : `-${height}px`,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
:style="style"
|
||||
class="bg-background-deep bottom-0 w-full transition-all duration-200"
|
||||
>
|
||||
<slot></slot>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 横屏
|
||||
*/
|
||||
fullWidth: boolean;
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* 是否移动端
|
||||
*/
|
||||
isMobile: boolean;
|
||||
/**
|
||||
* 是否显示
|
||||
*/
|
||||
show: boolean;
|
||||
/**
|
||||
* 侧边菜单宽度
|
||||
*/
|
||||
sidebarWidth: number;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string | undefined;
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: string;
|
||||
/**
|
||||
* zIndex
|
||||
*/
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fullWidth, height, show } = props;
|
||||
const right = !show || !fullWidth ? undefined : 0;
|
||||
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginTop: show ? 0 : `-${height}px`,
|
||||
right,
|
||||
};
|
||||
});
|
||||
|
||||
const logoStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
minWidth: `${props.isMobile ? 40 : props.sidebarWidth}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
:class="theme"
|
||||
:style="style"
|
||||
class="border-border bg-header top-0 flex w-full flex-[0_0_auto] items-center border-b pl-2 transition-[margin-top] duration-200"
|
||||
>
|
||||
<div v-if="slots.logo" :style="logoStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
|
||||
<slot name="toggle-button"> </slot>
|
||||
|
||||
<slot></slot>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, shallowRef, useSlots, watchEffect } from 'vue';
|
||||
|
||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useScrollLock } from '@vueuse/core';
|
||||
|
||||
import { SidebarCollapseButton, SidebarFixedButton } from './widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 折叠区域高度
|
||||
* @default 42
|
||||
*/
|
||||
collapseHeight?: number;
|
||||
/**
|
||||
* 折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
collapseWidth?: number;
|
||||
/**
|
||||
* 隐藏的dom是否可见
|
||||
* @default true
|
||||
*/
|
||||
domVisible?: boolean;
|
||||
/**
|
||||
* 扩展区域宽度
|
||||
*/
|
||||
extraWidth: number;
|
||||
/**
|
||||
* 固定扩展区域
|
||||
* @default false
|
||||
*/
|
||||
fixedExtra?: boolean;
|
||||
/**
|
||||
* 头部高度
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
* @default false
|
||||
*/
|
||||
isSidebarMixed?: boolean;
|
||||
/**
|
||||
* 顶部margin
|
||||
* @default 60
|
||||
*/
|
||||
marginTop?: number;
|
||||
/**
|
||||
* 混合菜单宽度
|
||||
* @default 80
|
||||
*/
|
||||
mixedWidth?: number;
|
||||
/**
|
||||
* 顶部padding
|
||||
* @default 60
|
||||
*/
|
||||
paddingTop?: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 显示折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 显示固定按钮
|
||||
* @default true
|
||||
*/
|
||||
showFixedButton?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string;
|
||||
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapseHeight: 42,
|
||||
collapseWidth: 48,
|
||||
domVisible: true,
|
||||
fixedExtra: false,
|
||||
isSidebarMixed: false,
|
||||
marginTop: 0,
|
||||
mixedWidth: 70,
|
||||
paddingTop: 0,
|
||||
show: true,
|
||||
showCollapseButton: true,
|
||||
showFixedButton: true,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ leave: [] }>();
|
||||
const collapse = defineModel<boolean>('collapse');
|
||||
const extraCollapse = defineModel<boolean>('extraCollapse');
|
||||
const expandOnHovering = defineModel<boolean>('expandOnHovering');
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
const extraVisible = defineModel<boolean>('extraVisible');
|
||||
|
||||
const isLocked = useScrollLock(document.body);
|
||||
const slots = useSlots();
|
||||
|
||||
const asideRef = shallowRef<HTMLDivElement | null>();
|
||||
|
||||
const hiddenSideStyle = computed((): CSSProperties => calcMenuWidthStyle(true));
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { isSidebarMixed, marginTop, paddingTop, zIndex } = props;
|
||||
|
||||
return {
|
||||
'--scroll-shadow': 'var(--sidebar)',
|
||||
...calcMenuWidthStyle(false),
|
||||
height: `calc(100% - ${marginTop}px)`,
|
||||
marginTop: `${marginTop}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
zIndex,
|
||||
...(isSidebarMixed && extraVisible.value ? { transition: 'none' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const extraStyle = computed((): CSSProperties => {
|
||||
const { extraWidth, show, width, zIndex } = props;
|
||||
|
||||
return {
|
||||
left: `${width}px`,
|
||||
width: extraVisible.value && show ? `${extraWidth}px` : 0,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const extraTitleStyle = computed((): CSSProperties => {
|
||||
const { headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `${headerHeight - 1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const contentWidthStyle = computed((): CSSProperties => {
|
||||
const { collapseWidth, fixedExtra, isSidebarMixed, mixedWidth } = props;
|
||||
if (isSidebarMixed && fixedExtra) {
|
||||
return { width: `${collapse.value ? collapseWidth : mixedWidth}px` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
paddingTop: '8px',
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const headerStyle = computed((): CSSProperties => {
|
||||
const { headerHeight, isSidebarMixed } = props;
|
||||
|
||||
return {
|
||||
...(isSidebarMixed ? { display: 'flex', justifyContent: 'center' } : {}),
|
||||
height: `${headerHeight - 1}px`,
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const extraContentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const collapseStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: `${props.collapseHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
|
||||
});
|
||||
|
||||
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
|
||||
const { extraWidth, fixedExtra, isSidebarMixed, show, width } = props;
|
||||
|
||||
let widthValue =
|
||||
width === 0
|
||||
? '0px'
|
||||
: `${width + (isSidebarMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
|
||||
|
||||
const { collapseWidth } = props;
|
||||
|
||||
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
|
||||
widthValue = `${collapseWidth}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
|
||||
flex: `0 0 ${widthValue}`,
|
||||
marginLeft: show ? 0 : `-${widthValue}`,
|
||||
maxWidth: widthValue,
|
||||
minWidth: widthValue,
|
||||
width: widthValue,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseenter(e: MouseEvent) {
|
||||
if (e?.offsetX < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未开启和未折叠状态不生效
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
if (!expandOnHovering.value) {
|
||||
collapse.value = false;
|
||||
}
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = true;
|
||||
}
|
||||
expandOnHovering.value = true;
|
||||
}
|
||||
|
||||
function handleMouseleave() {
|
||||
emit('leave');
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = false;
|
||||
}
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandOnHovering.value = false;
|
||||
collapse.value = true;
|
||||
extraVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="domVisible"
|
||||
:class="theme"
|
||||
:style="hiddenSideStyle"
|
||||
class="h-full transition-all duration-150"
|
||||
></div>
|
||||
<aside
|
||||
:class="[
|
||||
theme,
|
||||
{
|
||||
'bg-sidebar-deep': isSidebarMixed,
|
||||
'bg-sidebar border-border border-r': !isSidebarMixed,
|
||||
},
|
||||
]"
|
||||
:style="style"
|
||||
class="fixed left-0 top-0 h-full transition-all duration-150"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="handleMouseleave"
|
||||
>
|
||||
<SidebarFixedButton
|
||||
v-if="!collapse && !isSidebarMixed && showFixedButton"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="slots.logo" :style="headerStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<VbenScrollbar :style="contentStyle" shadow shadow-border>
|
||||
<slot></slot>
|
||||
</VbenScrollbar>
|
||||
|
||||
<div :style="collapseStyle"></div>
|
||||
<SidebarCollapseButton
|
||||
v-if="showCollapseButton && !isSidebarMixed"
|
||||
v-model:collapsed="collapse"
|
||||
/>
|
||||
<div
|
||||
v-if="isSidebarMixed"
|
||||
ref="asideRef"
|
||||
:class="{
|
||||
'border-l': extraVisible,
|
||||
}"
|
||||
:style="extraStyle"
|
||||
class="border-border bg-sidebar fixed top-0 h-full overflow-hidden border-r transition-all duration-200"
|
||||
>
|
||||
<SidebarCollapseButton
|
||||
v-if="isSidebarMixed && expandOnHover"
|
||||
v-model:collapsed="extraCollapse"
|
||||
/>
|
||||
|
||||
<SidebarFixedButton
|
||||
v-if="!extraCollapse"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="!extraCollapse" :style="extraTitleStyle" class="pl-2">
|
||||
<slot name="extra-title"></slot>
|
||||
</div>
|
||||
<VbenScrollbar
|
||||
:style="extraContentStyle"
|
||||
class="border-border py-2"
|
||||
shadow
|
||||
shadow-border
|
||||
>
|
||||
<slot name="extra"></slot>
|
||||
</VbenScrollbar>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { height } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:style="style"
|
||||
class="border-border bg-background flex w-full border-b transition-all"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SidebarCollapseButton } from './sidebar-collapse-button.vue';
|
||||
export { default as SidebarFixedButton } from './sidebar-fixed-button.vue';
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsLeft, ChevronsRight } from '@vben-core/icons';
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed');
|
||||
|
||||
function handleCollapsed() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-center hover:text-foreground text-foreground/60 hover:bg-accent-hover bg-accent absolute bottom-2 left-3 z-10 cursor-pointer rounded-sm p-1"
|
||||
@click.stop="handleCollapsed"
|
||||
>
|
||||
<ChevronsRight v-if="collapsed" class="size-4" />
|
||||
<ChevronsLeft v-else class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Pin, PinOff } from '@vben-core/icons';
|
||||
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
|
||||
function toggleFixed() {
|
||||
expandOnHover.value = !expandOnHover.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-center hover:text-foreground text-foreground/60 hover:bg-accent-hover bg-accent absolute bottom-2 right-3 z-10 cursor-pointer rounded-sm p-[5px] transition-all duration-300"
|
||||
@click="toggleFixed"
|
||||
>
|
||||
<PinOff v-if="!expandOnHover" class="size-3.5" />
|
||||
<Pin v-else class="size-3.5" />
|
||||
</div>
|
||||
</template>
|
||||
53
packages/@core/ui-kit/layout-ui/src/hooks/use-layout.ts
Normal file
53
packages/@core/ui-kit/layout-ui/src/hooks/use-layout.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { LayoutType } from '@vben-core/typings';
|
||||
|
||||
import type { VbenLayoutProps } from '../vben-layout';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useLayout(props: VbenLayoutProps) {
|
||||
const currentLayout = computed(() =>
|
||||
props.isMobile ? 'sidebar-nav' : (props.layout as LayoutType),
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否全屏显示content,不需要侧边、底部、顶部、tab区域
|
||||
*/
|
||||
const isFullContent = computed(() => currentLayout.value === 'full-content');
|
||||
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
*/
|
||||
const isSidebarMixedNav = computed(
|
||||
() => currentLayout.value === 'sidebar-mixed-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部导航模式
|
||||
*/
|
||||
const isHeaderNav = computed(() => currentLayout.value === 'header-nav');
|
||||
|
||||
/**
|
||||
* 是否为混合导航模式
|
||||
*/
|
||||
const isMixedNav = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部混合模式
|
||||
*/
|
||||
const isHeaderMixedNav = computed(
|
||||
() => currentLayout.value === 'header-mixed-nav',
|
||||
);
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
};
|
||||
}
|
||||
2
packages/@core/ui-kit/layout-ui/src/index.ts
Normal file
2
packages/@core/ui-kit/layout-ui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type * from './vben-layout';
|
||||
export { default as VbenAdminLayout } from './vben-layout.vue';
|
||||
175
packages/@core/ui-kit/layout-ui/src/vben-layout.ts
Normal file
175
packages/@core/ui-kit/layout-ui/src/vben-layout.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
ContentCompactType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
ThemeModeType,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
interface VbenLayoutProps {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
* @default 'wide'
|
||||
*/
|
||||
contentCompact?: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
* @default 1200
|
||||
*/
|
||||
contentCompactWidth?: number;
|
||||
/**
|
||||
* padding
|
||||
* @default 16
|
||||
*/
|
||||
contentPadding?: number;
|
||||
/**
|
||||
* paddingBottom
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
/**
|
||||
* paddingLeft
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingLeft?: number;
|
||||
/**
|
||||
* paddingRight
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingRight?: number;
|
||||
/**
|
||||
* paddingTop
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingTop?: number;
|
||||
/**
|
||||
* footer 是否可见
|
||||
* @default false
|
||||
*/
|
||||
footerEnable?: boolean;
|
||||
/**
|
||||
* footer 是否固定
|
||||
* @default true
|
||||
*/
|
||||
footerFixed?: boolean;
|
||||
/**
|
||||
* footer 高度
|
||||
* @default 32
|
||||
*/
|
||||
footerHeight?: number;
|
||||
|
||||
/**
|
||||
* header高度
|
||||
* @default 48
|
||||
*/
|
||||
headerHeight?: number;
|
||||
/**
|
||||
* 顶栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
headerHidden?: boolean;
|
||||
/**
|
||||
* header 显示模式
|
||||
* @default 'fixed'
|
||||
*/
|
||||
headerMode?: LayoutHeaderModeType;
|
||||
/**
|
||||
* header 顶栏主题
|
||||
*/
|
||||
headerTheme?: ThemeModeType;
|
||||
/**
|
||||
* 是否显示header切换侧边栏按钮
|
||||
* @default
|
||||
*/
|
||||
headerToggleSidebarButton?: boolean;
|
||||
/**
|
||||
* header是否显示
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* 是否移动端显示
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 布局方式
|
||||
* sidebar-nav 侧边菜单布局
|
||||
* header-nav 顶部菜单布局
|
||||
* mixed-nav 侧边&顶部菜单布局
|
||||
* sidebar-mixed-nav 侧边混合菜单布局
|
||||
* full-content 全屏内容布局
|
||||
* @default sidebar-nav
|
||||
*/
|
||||
layout?: LayoutType;
|
||||
/**
|
||||
* 侧边菜单折叠状态
|
||||
* @default false
|
||||
*/
|
||||
sidebarCollapse?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapsedButton?: boolean;
|
||||
/**
|
||||
* 侧边菜单是否折叠时,是否显示title
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapseShowTitle?: boolean;
|
||||
/**
|
||||
* 侧边栏是否可见
|
||||
* @default true
|
||||
*/
|
||||
sidebarEnable?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠额外宽度
|
||||
* @default 48
|
||||
*/
|
||||
sidebarExtraCollapsedWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠按钮是否固定
|
||||
* @default true
|
||||
*/
|
||||
sidebarFixedButton?: boolean;
|
||||
/**
|
||||
* 侧边栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
sidebarHidden?: boolean;
|
||||
/**
|
||||
* 混合侧边栏宽度
|
||||
* @default 80
|
||||
*/
|
||||
sidebarMixedWidth?: number;
|
||||
/**
|
||||
* 侧边栏
|
||||
* @default dark
|
||||
*/
|
||||
sidebarTheme?: ThemeModeType;
|
||||
/**
|
||||
* 侧边栏宽度
|
||||
* @default 210
|
||||
*/
|
||||
sidebarWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* tab是否可见
|
||||
* @default true
|
||||
*/
|
||||
tabbarEnable?: boolean;
|
||||
/**
|
||||
* tab高度
|
||||
* @default 30
|
||||
*/
|
||||
tabbarHeight?: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 100
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
export type { VbenLayoutProps };
|
||||
616
packages/@core/ui-kit/layout-ui/src/vben-layout.vue
Normal file
616
packages/@core/ui-kit/layout-ui/src/vben-layout.vue
Normal file
@@ -0,0 +1,616 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { VbenLayoutProps } from './vben-layout';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
SCROLL_FIXED_CLASS,
|
||||
useLayoutFooterStyle,
|
||||
useLayoutHeaderStyle,
|
||||
} from '@vben-core/composables';
|
||||
import { Menu } from '@vben-core/icons';
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
|
||||
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
LayoutContent,
|
||||
LayoutFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebar,
|
||||
LayoutTabbar,
|
||||
} from './components';
|
||||
import { useLayout } from './hooks/use-layout';
|
||||
|
||||
interface Props extends VbenLayoutProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLayout',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentCompact: 'wide',
|
||||
contentCompactWidth: 1200,
|
||||
contentPadding: 0,
|
||||
contentPaddingBottom: 0,
|
||||
contentPaddingLeft: 0,
|
||||
contentPaddingRight: 0,
|
||||
contentPaddingTop: 0,
|
||||
footerEnable: false,
|
||||
footerFixed: true,
|
||||
footerHeight: 32,
|
||||
headerHeight: 50,
|
||||
headerHidden: false,
|
||||
headerMode: 'fixed',
|
||||
headerToggleSidebarButton: true,
|
||||
headerVisible: true,
|
||||
isMobile: false,
|
||||
layout: 'sidebar-nav',
|
||||
sidebarCollapsedButton: true,
|
||||
sidebarCollapseShowTitle: false,
|
||||
sidebarExtraCollapsedWidth: 60,
|
||||
sidebarFixedButton: true,
|
||||
sidebarHidden: false,
|
||||
sidebarMixedWidth: 80,
|
||||
sidebarTheme: 'dark',
|
||||
sidebarWidth: 180,
|
||||
sideCollapseWidth: 60,
|
||||
tabbarEnable: true,
|
||||
tabbarHeight: 40,
|
||||
zIndex: 200,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ sideMouseLeave: []; toggleSidebar: [] }>();
|
||||
const sidebarCollapse = defineModel<boolean>('sidebarCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
|
||||
const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
|
||||
|
||||
// side是否处于hover状态展开菜单中
|
||||
const sidebarExpandOnHovering = ref(false);
|
||||
const headerIsHidden = ref(false);
|
||||
const contentRef = ref();
|
||||
|
||||
const {
|
||||
arrivedState,
|
||||
directions,
|
||||
isScrolling,
|
||||
y: scrollY,
|
||||
} = useScroll(document);
|
||||
|
||||
const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
|
||||
const { setLayoutFooterHeight } = useLayoutFooterStyle();
|
||||
|
||||
const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
|
||||
|
||||
const {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
} = useLayout(props);
|
||||
|
||||
/**
|
||||
* 顶栏是否自动隐藏
|
||||
*/
|
||||
const isHeaderAutoMode = computed(() => props.headerMode === 'auto');
|
||||
|
||||
const headerWrapperHeight = computed(() => {
|
||||
let height = 0;
|
||||
if (props.headerVisible && !props.headerHidden) {
|
||||
height += props.headerHeight;
|
||||
}
|
||||
if (props.tabbarEnable) {
|
||||
height += props.tabbarHeight;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
|
||||
const getSideCollapseWidth = computed(() => {
|
||||
const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
|
||||
props;
|
||||
|
||||
return sidebarCollapseShowTitle ||
|
||||
isSidebarMixedNav.value ||
|
||||
isHeaderMixedNav.value
|
||||
? sidebarMixedWidth
|
||||
: sideCollapseWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边区域是否可见
|
||||
*/
|
||||
const sidebarEnableState = computed(() => {
|
||||
return !isHeaderNav.value && sidebarEnable.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边区域离顶部高度
|
||||
*/
|
||||
const sidebarMarginTop = computed(() => {
|
||||
const { headerHeight, isMobile } = props;
|
||||
return isMixedNav.value && !isMobile ? headerHeight : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边宽度
|
||||
*/
|
||||
const getSidebarWidth = computed(() => {
|
||||
const { isMobile, sidebarHidden, sidebarMixedWidth, sidebarWidth } = props;
|
||||
let width = 0;
|
||||
|
||||
if (sidebarHidden) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if (
|
||||
!sidebarEnableState.value ||
|
||||
(sidebarHidden &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!isHeaderMixedNav.value)
|
||||
) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
|
||||
width = sidebarMixedWidth;
|
||||
} else if (sidebarCollapse.value) {
|
||||
width = isMobile ? 0 : getSideCollapseWidth.value;
|
||||
} else {
|
||||
width = sidebarWidth;
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取扩展区域宽度
|
||||
*/
|
||||
const sidebarExtraWidth = computed(() => {
|
||||
const { sidebarExtraCollapsedWidth, sidebarWidth } = props;
|
||||
|
||||
return sidebarExtraCollapse.value ? sidebarExtraCollapsedWidth : sidebarWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否侧边栏模式,包含混合侧边
|
||||
*/
|
||||
const isSideMode = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-nav' ||
|
||||
currentLayout.value === 'header-mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* header fixed值
|
||||
*/
|
||||
const headerFixed = computed(() => {
|
||||
const { headerMode } = props;
|
||||
return (
|
||||
isMixedNav.value ||
|
||||
headerMode === 'fixed' ||
|
||||
headerMode === 'auto-scroll' ||
|
||||
headerMode === 'auto'
|
||||
);
|
||||
});
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return isSideMode.value && sidebarEnable.value && !props.sidebarHidden;
|
||||
});
|
||||
|
||||
/**
|
||||
* 遮罩可见性
|
||||
*/
|
||||
const maskVisible = computed(() => !sidebarCollapse.value && props.isMobile);
|
||||
|
||||
const mainStyle = computed(() => {
|
||||
let width = '100%';
|
||||
let sidebarAndExtraWidth = 'unset';
|
||||
if (
|
||||
headerFixed.value &&
|
||||
currentLayout.value !== 'header-nav' &&
|
||||
currentLayout.value !== 'mixed-nav' &&
|
||||
currentLayout.value !== 'header-sidebar-nav' &&
|
||||
showSidebar.value &&
|
||||
!props.isMobile
|
||||
) {
|
||||
// fixed模式下生效
|
||||
const isSideNavEffective =
|
||||
(isSidebarMixedNav.value || isHeaderMixedNav.value) &&
|
||||
sidebarExpandOnHover.value &&
|
||||
sidebarExtraVisible.value;
|
||||
|
||||
if (isSideNavEffective) {
|
||||
const sideCollapseWidth = sidebarCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: props.sidebarMixedWidth;
|
||||
const sideWidth = sidebarExtraCollapse.value
|
||||
? props.sidebarExtraCollapsedWidth
|
||||
: props.sidebarWidth;
|
||||
|
||||
// 100% - 侧边菜单混合宽度 - 菜单宽度
|
||||
sidebarAndExtraWidth = `${sideCollapseWidth + sideWidth}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
} else {
|
||||
sidebarAndExtraWidth =
|
||||
sidebarExpandOnHovering.value && !sidebarExpandOnHover.value
|
||||
? `${getSideCollapseWidth.value}px`
|
||||
: `${getSidebarWidth.value}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sidebarAndExtraWidth,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
// 计算 tabbar 的样式
|
||||
const tabbarStyle = computed((): CSSProperties => {
|
||||
let width = '';
|
||||
let marginLeft = 0;
|
||||
|
||||
// 如果不是混合导航,tabbar 的宽度为 100%
|
||||
if (!isMixedNav.value || props.sidebarHidden) {
|
||||
width = '100%';
|
||||
} else if (sidebarEnable.value) {
|
||||
// 鼠标在侧边栏上时,且侧边栏展开时的宽度
|
||||
const onHoveringWidth = sidebarExpandOnHover.value
|
||||
? props.sidebarWidth
|
||||
: getSideCollapseWidth.value;
|
||||
|
||||
// 设置 marginLeft,根据侧边栏是否折叠来决定
|
||||
marginLeft = sidebarCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: onHoveringWidth;
|
||||
|
||||
// 设置 tabbar 的宽度,计算方式为 100% 减去侧边栏的宽度
|
||||
width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
|
||||
} else {
|
||||
// 默认情况下,tabbar 的宽度为 100%
|
||||
width = '100%';
|
||||
}
|
||||
|
||||
return {
|
||||
marginLeft: `${marginLeft}px`,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
|
||||
const { footerEnable, footerFixed, footerHeight } = props;
|
||||
return {
|
||||
marginTop:
|
||||
fixed &&
|
||||
!isFullContent.value &&
|
||||
!headerIsHidden.value &&
|
||||
(!isHeaderAutoMode.value || scrollY.value < headerWrapperHeight.value)
|
||||
? `${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const headerZIndex = computed(() => {
|
||||
const { zIndex } = props;
|
||||
const offset = isMixedNav.value ? 1 : 0;
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const headerWrapperStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
return {
|
||||
height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
||||
left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
top:
|
||||
headerIsHidden.value || isFullContent.value
|
||||
? `-${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
width: mainStyle.value.width,
|
||||
'z-index': headerZIndex.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边栏z-index
|
||||
*/
|
||||
const sidebarZIndex = computed(() => {
|
||||
const { isMobile, zIndex } = props;
|
||||
let offset = isMobile || isSideMode.value ? 1 : -1;
|
||||
|
||||
if (isMixedNav.value) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const footerWidth = computed(() => {
|
||||
if (!props.footerFixed) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
return mainStyle.value.width;
|
||||
});
|
||||
|
||||
const maskStyle = computed((): CSSProperties => {
|
||||
return { zIndex: props.zIndex };
|
||||
});
|
||||
|
||||
const showHeaderToggleButton = computed(() => {
|
||||
return (
|
||||
props.isMobile ||
|
||||
(props.headerToggleSidebarButton &&
|
||||
isSideMode.value &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!props.isMobile)
|
||||
);
|
||||
});
|
||||
|
||||
const showHeaderLogo = computed(() => {
|
||||
return !isSideMode.value || isMixedNav.value || props.isMobile;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isMobile,
|
||||
(val) => {
|
||||
if (val) {
|
||||
sidebarCollapse.value = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => headerWrapperHeight.value, () => isFullContent.value],
|
||||
([height]) => {
|
||||
setLayoutHeaderHeight(isFullContent.value ? 0 : height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.footerHeight,
|
||||
(height: number) => {
|
||||
setLayoutFooterHeight(height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
{
|
||||
const mouseMove = () => {
|
||||
mouseY.value > headerWrapperHeight.value
|
||||
? (headerIsHidden.value = true)
|
||||
: (headerIsHidden.value = false);
|
||||
};
|
||||
watch(
|
||||
[() => props.headerMode, () => mouseY.value],
|
||||
() => {
|
||||
if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
|
||||
if (props.headerMode !== 'auto-scroll') {
|
||||
headerIsHidden.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
headerIsHidden.value = true;
|
||||
mouseMove();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
|
||||
if (scrollY.value < headerWrapperHeight.value) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
if (topArrived) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top) {
|
||||
headerIsHidden.value = false;
|
||||
} else if (bottom) {
|
||||
headerIsHidden.value = true;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
watch(
|
||||
() => scrollY.value,
|
||||
() => {
|
||||
if (
|
||||
props.headerMode !== 'auto-scroll' ||
|
||||
isMixedNav.value ||
|
||||
isFullContent.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isScrolling.value) {
|
||||
checkHeaderIsHidden(
|
||||
directions.top,
|
||||
directions.bottom,
|
||||
arrivedState.top,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleClickMask() {
|
||||
sidebarCollapse.value = true;
|
||||
}
|
||||
|
||||
function handleHeaderToggle() {
|
||||
if (props.isMobile) {
|
||||
sidebarCollapse.value = false;
|
||||
} else {
|
||||
emit('toggleSidebar');
|
||||
}
|
||||
}
|
||||
|
||||
const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex min-h-full w-full">
|
||||
<LayoutSidebar
|
||||
v-if="sidebarEnableState"
|
||||
v-model:collapse="sidebarCollapse"
|
||||
v-model:expand-on-hover="sidebarExpandOnHover"
|
||||
v-model:expand-on-hovering="sidebarExpandOnHovering"
|
||||
v-model:extra-collapse="sidebarExtraCollapse"
|
||||
v-model:extra-visible="sidebarExtraVisible"
|
||||
:show-collapse-button="sidebarCollapsedButton"
|
||||
:show-fixed-button="sidebarFixedButton"
|
||||
:collapse-width="getSideCollapseWidth"
|
||||
:dom-visible="!isMobile"
|
||||
:extra-width="sidebarExtraWidth"
|
||||
:fixed-extra="sidebarExpandOnHover"
|
||||
:header-height="isMixedNav ? 0 : headerHeight"
|
||||
:is-sidebar-mixed="isSidebarMixedNav || isHeaderMixedNav"
|
||||
:margin-top="sidebarMarginTop"
|
||||
:mixed-width="sidebarMixedWidth"
|
||||
:show="showSidebar"
|
||||
:theme="sidebarTheme"
|
||||
:width="getSidebarWidth"
|
||||
:z-index="sidebarZIndex"
|
||||
@leave="() => emit('sideMouseLeave')"
|
||||
>
|
||||
<template v-if="isSideMode && !isMixedNav" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSidebarMixedNav || isHeaderMixedNav">
|
||||
<slot name="mixed-menu"></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="menu"></slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="side-extra"></slot>
|
||||
</template>
|
||||
<template #extra-title>
|
||||
<slot name="side-extra-title"></slot>
|
||||
</template>
|
||||
</LayoutSidebar>
|
||||
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
{
|
||||
'shadow-[0_16px_24px_hsl(var(--background))]': scrollY > 20,
|
||||
},
|
||||
SCROLL_FIXED_CLASS,
|
||||
]"
|
||||
:style="headerWrapperStyle"
|
||||
class="overflow-hidden transition-all duration-200"
|
||||
>
|
||||
<LayoutHeader
|
||||
v-if="headerVisible"
|
||||
:full-width="!isSideMode"
|
||||
:height="headerHeight"
|
||||
:is-mobile="isMobile"
|
||||
:show="!isFullContent && !headerHidden"
|
||||
:sidebar-width="sidebarWidth"
|
||||
:theme="headerTheme"
|
||||
:width="mainStyle.width"
|
||||
:z-index="headerZIndex"
|
||||
>
|
||||
<template v-if="showHeaderLogo" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template #toggle-button>
|
||||
<VbenIconButton
|
||||
v-if="showHeaderToggleButton"
|
||||
class="my-0 mr-1 rounded-md"
|
||||
@click="handleHeaderToggle"
|
||||
>
|
||||
<Menu class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<slot name="header"></slot>
|
||||
</LayoutHeader>
|
||||
|
||||
<LayoutTabbar
|
||||
v-if="tabbarEnable"
|
||||
:height="tabbarHeight"
|
||||
:style="tabbarStyle"
|
||||
>
|
||||
<slot name="tabbar"></slot>
|
||||
</LayoutTabbar>
|
||||
</div>
|
||||
|
||||
<!-- </div> -->
|
||||
<LayoutContent
|
||||
:id="idMainContent"
|
||||
:content-compact="contentCompact"
|
||||
:content-compact-width="contentCompactWidth"
|
||||
:padding="contentPadding"
|
||||
:padding-bottom="contentPaddingBottom"
|
||||
:padding-left="contentPaddingLeft"
|
||||
:padding-right="contentPaddingRight"
|
||||
:padding-top="contentPaddingTop"
|
||||
:style="contentStyle"
|
||||
class="transition-[margin-top] duration-200"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
|
||||
<template #overlay>
|
||||
<slot name="content-overlay"></slot>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<LayoutFooter
|
||||
v-if="footerEnable"
|
||||
:fixed="footerFixed"
|
||||
:height="footerHeight"
|
||||
:show="!isFullContent"
|
||||
:width="footerWidth"
|
||||
:z-index="zIndex"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</LayoutFooter>
|
||||
</div>
|
||||
<slot name="extra"></slot>
|
||||
<div
|
||||
v-if="maskVisible"
|
||||
:style="maskStyle"
|
||||
class="bg-overlay fixed left-0 top-0 h-full w-full transition-[background-color] duration-200"
|
||||
@click="handleClickMask"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
1
packages/@core/ui-kit/layout-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/layout-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/layout-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/layout-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 菜单组件
|
||||
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
pattern: ['**/*'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/menu-ui",
|
||||
"version": "5.5.4",
|
||||
"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/@vben-core/uikit/menu-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RendererElement } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'CollapseTransition',
|
||||
});
|
||||
|
||||
const reset = (el: RendererElement) => {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
};
|
||||
|
||||
const on = {
|
||||
afterEnter(el: RendererElement) {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
},
|
||||
|
||||
afterLeave(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
beforeEnter(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
if (el.style.height) el.dataset.elExistsHeight = el.style.height;
|
||||
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginBottom = 0;
|
||||
},
|
||||
|
||||
beforeLeave(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
enter(el: RendererElement) {
|
||||
requestAnimationFrame(() => {
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
if (el.dataset.elExistsHeight) {
|
||||
el.style.maxHeight = el.dataset.elExistsHeight;
|
||||
} else if (el.scrollHeight === 0) {
|
||||
el.style.maxHeight = 0;
|
||||
} else {
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
el.style.marginTop = el.dataset.oldMarginTop;
|
||||
el.style.marginBottom = el.dataset.oldMarginBottom;
|
||||
el.style.overflow = 'hidden';
|
||||
});
|
||||
},
|
||||
|
||||
enterCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
leave(el: RendererElement) {
|
||||
if (el.scrollHeight !== 0) {
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.marginBottom = 0;
|
||||
}
|
||||
},
|
||||
|
||||
leaveCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="collapse-transition" v-on="on">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
||||
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as MenuBadge } from './menu-badge.vue';
|
||||
export { default as MenuItem } from './menu-item.vue';
|
||||
export { default as Menu } from './menu.vue';
|
||||
export { default as SubMenu } from './sub-menu.vue';
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
interface Props {
|
||||
dotClass?: string;
|
||||
dotStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
dotClass: '',
|
||||
dotStyle: () => ({}),
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative mr-1 flex size-1.5">
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="relative inline-flex size-1.5 rounded-full"
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
||||
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordBadgeRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isValidColor } from '@vben-core/shared/color';
|
||||
|
||||
import BadgeDot from './menu-badge-dot.vue';
|
||||
|
||||
interface Props extends MenuRecordBadgeRaw {
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const variantsMap: Record<string, string> = {
|
||||
default: 'bg-green-500',
|
||||
destructive: 'bg-destructive',
|
||||
primary: 'bg-primary',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
};
|
||||
|
||||
const isDot = computed(() => props.badgeType === 'dot');
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const { badgeVariants } = props;
|
||||
|
||||
if (!badgeVariants) {
|
||||
return variantsMap.default;
|
||||
}
|
||||
|
||||
return variantsMap[badgeVariants] || badgeVariants;
|
||||
});
|
||||
|
||||
const badgeStyle = computed(() => {
|
||||
if (badgeClass.value && isValidColor(badgeClass.value)) {
|
||||
return {
|
||||
backgroundColor: badgeClass.value,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span v-if="isDot || badge" :class="$attrs.class" class="absolute">
|
||||
<BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" />
|
||||
<div
|
||||
v-else
|
||||
:class="badgeClass"
|
||||
:style="badgeStyle"
|
||||
class="text-primary-foreground flex-center rounded-xl px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuItemProps, MenuItemRegistered } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { MenuBadge } from '../components';
|
||||
import { useMenu, useMenuContext, useSubMenuContext } from '../hooks';
|
||||
|
||||
interface Props extends MenuItemProps {}
|
||||
|
||||
defineOptions({ name: 'MenuItem' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ click: [MenuItemRegistered] }>();
|
||||
|
||||
const slots = useSlots();
|
||||
const { b, e, is } = useNamespace('menu-item');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
|
||||
const active = computed(() => props.path === rootMenu?.activePath);
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const isTopLevelMenuItem = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
|
||||
const collapseShowTitle = computed(
|
||||
() =>
|
||||
rootMenu.props?.collapseShowTitle &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props.collapse,
|
||||
);
|
||||
|
||||
const showTooltip = computed(
|
||||
() =>
|
||||
rootMenu.props.mode === 'vertical' &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props?.collapse &&
|
||||
slots.title,
|
||||
);
|
||||
|
||||
const item: MenuItemRegistered = reactive({
|
||||
active,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单项点击事件
|
||||
*/
|
||||
function handleClick() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
rootMenu?.handleMenuItemClick?.({
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
emit('click', item);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addMenuItem?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeMenuItem?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
rootMenu.theme,
|
||||
b(),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
is('collapse-show-title', collapseShowTitle),
|
||||
]"
|
||||
role="menuitem"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<VbenTooltip
|
||||
v-if="showTooltip"
|
||||
:content-class="[rootMenu.theme]"
|
||||
side="right"
|
||||
>
|
||||
<template #trigger>
|
||||
<div :class="[nsMenu.be('tooltip', 'trigger')]">
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" fallback />
|
||||
<slot></slot>
|
||||
<span v-if="collapseShowTitle" :class="nsMenu.e('name')">
|
||||
<slot name="title"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="title"></slot>
|
||||
</VbenTooltip>
|
||||
<div v-show="!showTooltip" :class="[e('content')]">
|
||||
<MenuBadge
|
||||
v-if="rootMenu.props.mode !== 'horizontal'"
|
||||
class="right-2"
|
||||
v-bind="props"
|
||||
/>
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
|
||||
<slot></slot>
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
856
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
856
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
@@ -0,0 +1,856 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UseResizeObserverReturn } from '@vueuse/core';
|
||||
|
||||
import type { SetupContext, VNodeArrayChildren } from 'vue';
|
||||
|
||||
import type {
|
||||
MenuItemClicked,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
toRef,
|
||||
useSlots,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { Ellipsis } from '@vben-core/icons';
|
||||
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuStyle,
|
||||
} from '../hooks';
|
||||
import { flattedChildren } from '../utils';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props extends MenuProps {}
|
||||
|
||||
defineOptions({ name: 'Menu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
collapse: false,
|
||||
mode: 'vertical',
|
||||
rounded: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string, string[]];
|
||||
open: [string, string[]];
|
||||
select: [string, string[]];
|
||||
}>();
|
||||
|
||||
const { b, is } = useNamespace('menu');
|
||||
const menuStyle = useMenuStyle();
|
||||
const slots: SetupContext['slots'] = useSlots();
|
||||
const menu = ref<HTMLUListElement>();
|
||||
const sliceIndex = ref(-1);
|
||||
const openedMenus = ref<MenuProvider['openedMenus']>(
|
||||
props.defaultOpeneds && !props.collapse ? [...props.defaultOpeneds] : [],
|
||||
);
|
||||
const activePath = ref<MenuProvider['activePath']>(props.defaultActive);
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
|
||||
return (
|
||||
props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
||||
);
|
||||
});
|
||||
|
||||
const getSlot = computed(() => {
|
||||
// 更新插槽内容
|
||||
const defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
|
||||
|
||||
const originalSlot = flattedChildren(defaultSlots) as VNodeArrayChildren;
|
||||
const slotDefault =
|
||||
sliceIndex.value === -1
|
||||
? originalSlot
|
||||
: originalSlot.slice(0, sliceIndex.value);
|
||||
|
||||
const slotMore =
|
||||
sliceIndex.value === -1 ? [] : originalSlot.slice(sliceIndex.value);
|
||||
|
||||
return { showSlotMore: slotMore.length > 0, slotDefault, slotMore };
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.collapse,
|
||||
(value) => {
|
||||
if (value) openedMenus.value = [];
|
||||
},
|
||||
);
|
||||
|
||||
watch(items.value, initMenu);
|
||||
|
||||
watch(
|
||||
() => props.defaultActive,
|
||||
(currentActive = '') => {
|
||||
if (!items.value[currentActive]) {
|
||||
activePath.value = '';
|
||||
}
|
||||
updateActiveName(currentActive);
|
||||
},
|
||||
);
|
||||
|
||||
let resizeStopper: UseResizeObserverReturn['stop'];
|
||||
watchEffect(() => {
|
||||
if (props.mode === 'horizontal') {
|
||||
resizeStopper = useResizeObserver(menu, handleResize).stop;
|
||||
} else {
|
||||
resizeStopper?.();
|
||||
}
|
||||
});
|
||||
|
||||
// 注入上下文
|
||||
createMenuContext(
|
||||
reactive({
|
||||
activePath,
|
||||
addMenuItem,
|
||||
addSubMenu,
|
||||
closeMenu,
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
isMenuPopup,
|
||||
openedMenus,
|
||||
openMenu,
|
||||
props,
|
||||
removeMenuItem,
|
||||
removeSubMenu,
|
||||
subMenus,
|
||||
theme: toRef(props, 'theme'),
|
||||
items,
|
||||
}),
|
||||
);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
level: 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
function calcMenuItemWidth(menuItem: HTMLElement) {
|
||||
const computedStyle = getComputedStyle(menuItem);
|
||||
const marginLeft = Number.parseInt(computedStyle.marginLeft, 10);
|
||||
const marginRight = Number.parseInt(computedStyle.marginRight, 10);
|
||||
return menuItem.offsetWidth + marginLeft + marginRight || 0;
|
||||
}
|
||||
|
||||
function calcSliceIndex() {
|
||||
if (!menu.value) {
|
||||
return -1;
|
||||
}
|
||||
const items = [...(menu.value?.childNodes ?? [])].filter(
|
||||
(item) =>
|
||||
// remove comment type node #12634
|
||||
item.nodeName !== '#comment' &&
|
||||
(item.nodeName !== '#text' || item.nodeValue),
|
||||
) as HTMLElement[];
|
||||
|
||||
const moreItemWidth = 46;
|
||||
const computedMenuStyle = getComputedStyle(menu?.value);
|
||||
|
||||
const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10);
|
||||
const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10);
|
||||
const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight;
|
||||
|
||||
let calcWidth = 0;
|
||||
let sliceIndex = 0;
|
||||
items.forEach((item, index) => {
|
||||
calcWidth += calcMenuItemWidth(item);
|
||||
if (calcWidth <= menuWidth - moreItemWidth) {
|
||||
sliceIndex = index + 1;
|
||||
}
|
||||
});
|
||||
return sliceIndex === items.length ? -1 : sliceIndex;
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, wait = 33.34) {
|
||||
let timer: null | ReturnType<typeof setTimeout>;
|
||||
return () => {
|
||||
timer && clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
fn();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
let isFirstTimeRender = true;
|
||||
function handleResize() {
|
||||
if (sliceIndex.value === calcSliceIndex()) {
|
||||
return;
|
||||
}
|
||||
const callback = () => {
|
||||
sliceIndex.value = -1;
|
||||
nextTick(() => {
|
||||
sliceIndex.value = calcSliceIndex();
|
||||
});
|
||||
};
|
||||
callback();
|
||||
// // execute callback directly when first time resize to avoid shaking
|
||||
isFirstTimeRender ? callback() : debounce(callback)();
|
||||
isFirstTimeRender = false;
|
||||
}
|
||||
|
||||
function getActivePaths() {
|
||||
const activeItem = activePath.value && items.value[activePath.value];
|
||||
|
||||
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeItem.parentPaths;
|
||||
}
|
||||
|
||||
// 默认展开菜单
|
||||
function initMenu() {
|
||||
const parentPaths = getActivePaths();
|
||||
|
||||
// 展开该菜单项的路径上所有子菜单
|
||||
// expand all subMenus of the menu item
|
||||
parentPaths.forEach((path) => {
|
||||
const subMenu = subMenus.value[path];
|
||||
subMenu && openMenu(path, subMenu.parentPaths);
|
||||
});
|
||||
}
|
||||
|
||||
function updateActiveName(val: string) {
|
||||
const itemsInData = items.value;
|
||||
const item =
|
||||
itemsInData[val] ||
|
||||
(activePath.value && itemsInData[activePath.value]) ||
|
||||
itemsInData[props.defaultActive || ''];
|
||||
|
||||
activePath.value = item ? item.path : val;
|
||||
}
|
||||
|
||||
function handleMenuItemClick(data: MenuItemClicked) {
|
||||
const { collapse, mode } = props;
|
||||
if (mode === 'horizontal' || collapse) {
|
||||
openedMenus.value = [];
|
||||
}
|
||||
const { parentPaths, path } = data;
|
||||
if (!path || !parentPaths) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', path, parentPaths);
|
||||
}
|
||||
|
||||
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
|
||||
const isOpened = openedMenus.value.includes(path);
|
||||
|
||||
if (isOpened) {
|
||||
closeMenu(path, parentPaths);
|
||||
} else {
|
||||
openMenu(path, parentPaths);
|
||||
}
|
||||
}
|
||||
|
||||
function close(path: string) {
|
||||
const i = openedMenus.value.indexOf(path);
|
||||
|
||||
if (i !== -1) {
|
||||
openedMenus.value.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭、折叠菜单
|
||||
*/
|
||||
function closeMenu(path: string, parentPaths: string[]) {
|
||||
if (props.accordion) {
|
||||
openedMenus.value = subMenus.value[path]?.parentPaths ?? [];
|
||||
}
|
||||
|
||||
close(path);
|
||||
|
||||
emit('close', path, parentPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击展开菜单
|
||||
*/
|
||||
function openMenu(path: string, parentPaths: string[]) {
|
||||
if (openedMenus.value.includes(path)) {
|
||||
return;
|
||||
}
|
||||
// 手风琴模式菜单
|
||||
if (props.accordion) {
|
||||
const activeParentPaths = getActivePaths();
|
||||
if (activeParentPaths.includes(path)) {
|
||||
parentPaths = activeParentPaths;
|
||||
}
|
||||
openedMenus.value = openedMenus.value.filter((path: string) =>
|
||||
parentPaths.includes(path),
|
||||
);
|
||||
}
|
||||
openedMenus.value.push(path);
|
||||
emit('open', path, parentPaths);
|
||||
}
|
||||
|
||||
function addMenuItem(item: MenuItemRegistered) {
|
||||
items.value[item.path] = item;
|
||||
}
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
function removeMenuItem(item: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(items.value, item.path);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul
|
||||
ref="menu"
|
||||
:class="[
|
||||
theme,
|
||||
b(),
|
||||
is(mode, true),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
is('collapse', collapse),
|
||||
is('menu-align', mode === 'horizontal'),
|
||||
]"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
|
||||
<template v-for="item in getSlot.slotDefault" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
<SubMenu is-sub-menu-more path="sub-menu-more">
|
||||
<template #title>
|
||||
<Ellipsis class="size-4" />
|
||||
</template>
|
||||
<template v-for="item in getSlot.slotMore" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
</SubMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
$namespace: vben;
|
||||
|
||||
@mixin menu-item-active {
|
||||
color: var(--menu-item-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-active-background-color);
|
||||
}
|
||||
|
||||
@mixin menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
// gap: 12px;
|
||||
align-items: center;
|
||||
height: var(--menu-item-height);
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: 0 var(--menu-item-margin-x) var(--menu-item-margin-y)
|
||||
var(--menu-item-margin-x);
|
||||
font-size: var(--menu-font-size);
|
||||
color: var(--menu-item-color);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-background-color);
|
||||
border: none;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
background: none !important;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__icon {
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-menu__icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
* {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-title {
|
||||
max-width: var(--menu-title-width);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.is-menu-align {
|
||||
justify-content: var(--menu-align, start);
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__popup-container,
|
||||
.#{$namespace}-menu {
|
||||
--menu-title-width: 140px;
|
||||
--menu-item-icon-size: 16px;
|
||||
--menu-item-height: 38px;
|
||||
--menu-item-padding-y: 21px;
|
||||
--menu-item-padding-x: 12px;
|
||||
--menu-item-popup-padding-y: 20px;
|
||||
--menu-item-popup-padding-x: 12px;
|
||||
--menu-item-margin-y: 2px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-collapse-padding-y: 23.5px;
|
||||
--menu-item-collapse-padding-x: 0px;
|
||||
--menu-item-collapse-margin-y: 4px;
|
||||
--menu-item-collapse-margin-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
--menu-item-indent: 16px;
|
||||
--menu-font-size: 14px;
|
||||
|
||||
&.is-dark {
|
||||
--menu-background-color: hsl(var(--menu));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--menu-opened-dark));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--foreground) / 80%);
|
||||
--menu-item-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--accent-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-hover-color: hsl(var(--foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--foreground));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-background-color: hsl(var(--menu));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--menu-opened));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--foreground));
|
||||
--menu-item-hover-color: var(--menu-item-color);
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--primary));
|
||||
--menu-item-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-submenu-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--primary));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-margin-x: 8px;
|
||||
--menu-item-collapse-margin-x: 6px;
|
||||
--menu-item-radius: 8px;
|
||||
}
|
||||
|
||||
&.is-horizontal:not(.is-rounded) {
|
||||
--menu-item-height: 40px;
|
||||
--menu-item-radius: 6px;
|
||||
}
|
||||
|
||||
&.is-horizontal.is-rounded {
|
||||
--menu-item-height: 40px;
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-padding-x: 12px;
|
||||
}
|
||||
|
||||
// .vben-menu__popup,
|
||||
&.is-horizontal {
|
||||
--menu-item-padding-y: 0px;
|
||||
--menu-item-padding-x: 10px;
|
||||
--menu-item-margin-y: 0px;
|
||||
--menu-item-margin-x: 1px;
|
||||
--menu-background-color: transparent;
|
||||
|
||||
&.is-dark {
|
||||
--menu-item-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--accent-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--foreground));
|
||||
--menu-submenu-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-item-active-color: hsl(var(--primary));
|
||||
--menu-item-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-active-color: hsl(var(--primary));
|
||||
--menu-submenu-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-submenu-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-menu {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: hsl(var(--menu-background-color));
|
||||
|
||||
// 垂直菜单
|
||||
&.is-vertical {
|
||||
&:not(.#{$namespace}-menu.is-collapse) {
|
||||
& .#{$namespace}-menu-item,
|
||||
& .#{$namespace}-sub-menu-content,
|
||||
& .#{$namespace}-menu-item-group__title {
|
||||
padding-left: calc(
|
||||
var(--menu-item-indent) + var(--menu-level) * var(--menu-item-indent)
|
||||
);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
& > .#{$namespace}-menu {
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(
|
||||
0px + var(--menu-item-indent) + var(--menu-level) *
|
||||
var(--menu-item-indent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu-content {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-horizontal {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
height: var(--height-horizontal-height);
|
||||
border-right: none;
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--menu-item-height);
|
||||
padding-right: calc(var(--menu-item-padding-x) + 6px);
|
||||
margin: 0;
|
||||
margin-right: 2px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
height: var(--menu-item-height);
|
||||
margin-right: 2px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .#{$namespace}-sub-menu-content {
|
||||
height: 100%;
|
||||
padding-right: 40px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-menu-item.is-active {
|
||||
color: var(--menu-item-active-color);
|
||||
}
|
||||
|
||||
// &.is-light {
|
||||
// & > .#{$namespace}-sub-menu {
|
||||
// &.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// &:not(.is-active) .#{$namespace}-sub-menu-content {
|
||||
// &:hover {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// & > .#{$namespace}-menu-item.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// 折叠菜单
|
||||
|
||||
&.is-collapse {
|
||||
.#{$namespace}-menu__icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
.#{$namespace}-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--menu-item-collapse-padding-y)
|
||||
var(--menu-item-collapse-padding-x);
|
||||
margin: var(--menu-item-collapse-margin-y)
|
||||
var(--menu-item-collapse-margin-x);
|
||||
transition: all 0.3s;
|
||||
|
||||
&.is-active {
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-active {
|
||||
// color: hsl(var(--primary-foreground)) !important;
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-collapse-show-title {
|
||||
// padding: 32px 0 !important;
|
||||
margin: 4px 8px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__popup-container {
|
||||
max-width: 240px;
|
||||
height: unset;
|
||||
padding: 0;
|
||||
background: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&__popup {
|
||||
padding: 10px 0;
|
||||
border-radius: var(--menu-item-radius);
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
padding: var(--menu-item-popup-padding-y) var(--menu-item-popup-padding-x);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: var(--menu-item-icon-size);
|
||||
height: var(--menu-item-icon-size);
|
||||
margin-right: 8px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&.is-active {
|
||||
fill: var(--menu-item-active-color);
|
||||
|
||||
@include menu-item-active;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--menu-item-height);
|
||||
|
||||
span {
|
||||
@include menu-title;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
flex-direction: column;
|
||||
}
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__name {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: var(--menu-item-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-hover-background-color) !important;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--menu-item-padding-x);
|
||||
font-size: var(--menu-font-size);
|
||||
line-height: var(--menu-item-height);
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--menu-submenu-background-color);
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
&.is-active {
|
||||
div[data-state='open'] > .#{$namespace}-sub-menu-content,
|
||||
> .#{$namespace}-sub-menu-content {
|
||||
// font-weight: 500;
|
||||
color: var(--menu-submenu-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-active-background-color);
|
||||
fill: var(--menu-submenu-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content {
|
||||
height: var(--menu-item-height);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
width: inherit;
|
||||
margin-top: -8px;
|
||||
margin-right: 0;
|
||||
// font-size: 16px;
|
||||
font-weight: normal;
|
||||
opacity: 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include menu-title;
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
flex-direction: column;
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.#{$namespace}-sub-menu-content__title {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-more {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
// &:not(.is-active):hover {
|
||||
&:hover {
|
||||
color: var(--menu-submenu-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-hover-background-color) !important;
|
||||
|
||||
// svg {
|
||||
// fill: var(--menu-submenu-hover-color);
|
||||
// }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export type * from './normal-menu';
|
||||
export { default as NormalMenu } from './normal-menu.vue';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
interface NormalMenuProps {
|
||||
/**
|
||||
* 菜单数据
|
||||
*/
|
||||
activePath?: string;
|
||||
/**
|
||||
* 是否折叠
|
||||
*/
|
||||
collapse?: boolean;
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menus?: MenuRecordRaw[];
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export type { NormalMenuProps };
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import type { NormalMenuProps } from './normal-menu';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'NormalMenu',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activePath: '',
|
||||
collapse: false,
|
||||
menus: () => [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const { b, e, is } = useNamespace('normal-menu');
|
||||
|
||||
function menuIcon(menu: MenuRecordRaw) {
|
||||
return props.activePath === menu.path
|
||||
? menu.activeIcon || menu.icon
|
||||
: menu.icon;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="[
|
||||
theme,
|
||||
b(),
|
||||
is('collapse', collapse),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
]"
|
||||
class="relative"
|
||||
>
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<li
|
||||
:class="[e('item'), is('active', activePath === menu.path)]"
|
||||
@click="() => emit('select', menu)"
|
||||
@mouseenter="() => emit('enter', menu)"
|
||||
>
|
||||
<VbenIcon :class="e('icon')" :icon="menuIcon(menu)" fallback />
|
||||
|
||||
<span :class="e('name')" class="truncate"> {{ menu.name }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
$namespace: vben;
|
||||
|
||||
.#{$namespace}-normal-menu {
|
||||
--menu-item-margin-y: 4px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-padding-y: 9px;
|
||||
--menu-item-padding-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
|
||||
height: calc(100% - 4px);
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-margin-x: 8px;
|
||||
}
|
||||
|
||||
&.is-dark {
|
||||
.#{$namespace}-normal-menu__item {
|
||||
@apply text-foreground/80;
|
||||
// color: hsl(var(--foreground) / 80%);
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse {
|
||||
.#{$namespace}-normal-menu__name {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// max-width: 64px;
|
||||
// max-height: 64px;
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: var(--menu-item-margin-y) var(--menu-item-margin-x);
|
||||
color: hsl(var(--foreground) / 90%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.is-active {
|
||||
@apply text-primary bg-primary dark:bg-accent;
|
||||
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply text-primary-foreground font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply dark:bg-accent text-primary bg-heavy dark:text-foreground;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
max-height: 20px;
|
||||
font-size: 20px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuItemProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { ChevronDown, ChevronRight } from '@vben-core/icons';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useMenuContext } from '../hooks';
|
||||
|
||||
interface Props extends MenuItemProps {
|
||||
isMenuMore: boolean;
|
||||
isTopLevelMenuSubmenu: boolean;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenuContent' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isMenuMore: false,
|
||||
level: 0,
|
||||
});
|
||||
|
||||
const rootMenu = useMenuContext();
|
||||
const { b, e, is } = useNamespace('sub-menu-content');
|
||||
const nsMenu = useNamespace('menu');
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
|
||||
const collapse = computed(() => {
|
||||
return rootMenu.props.collapse;
|
||||
});
|
||||
|
||||
const isFirstLevel = computed(() => {
|
||||
return props.level === 1;
|
||||
});
|
||||
|
||||
const getCollapseShowTitle = computed(() => {
|
||||
return (
|
||||
rootMenu.props.collapseShowTitle && isFirstLevel.value && collapse.value
|
||||
);
|
||||
});
|
||||
|
||||
const mode = computed(() => {
|
||||
return rootMenu?.props.mode;
|
||||
});
|
||||
|
||||
const showArrowIcon = computed(() => {
|
||||
return mode.value === 'horizontal' || !(isFirstLevel.value && collapse.value);
|
||||
});
|
||||
|
||||
const hiddenTitle = computed(() => {
|
||||
return (
|
||||
mode.value === 'vertical' &&
|
||||
isFirstLevel.value &&
|
||||
collapse.value &&
|
||||
!getCollapseShowTitle.value
|
||||
);
|
||||
});
|
||||
|
||||
const iconComp = computed(() => {
|
||||
return (mode.value === 'horizontal' && !isFirstLevel.value) ||
|
||||
(mode.value === 'vertical' && collapse.value)
|
||||
? ChevronRight
|
||||
: ChevronDown;
|
||||
});
|
||||
|
||||
const iconArrowStyle = computed(() => {
|
||||
return opened.value ? { transform: `rotate(180deg)` } : {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
b(),
|
||||
is('collapse-show-title', getCollapseShowTitle),
|
||||
is('more', isMenuMore),
|
||||
]"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<VbenIcon
|
||||
v-if="!isMenuMore"
|
||||
:class="nsMenu.e('icon')"
|
||||
:icon="icon"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<div v-if="!hiddenTitle" :class="[e('title')]">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="iconComp"
|
||||
v-if="!isMenuMore"
|
||||
v-show="showArrowIcon"
|
||||
:class="[e('icon-arrow')]"
|
||||
:style="iconArrowStyle"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import type { MenuItemRegistered, MenuProvider, SubMenuProps } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenHoverCard } from '@vben-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
createSubMenuContext,
|
||||
useMenu,
|
||||
useMenuContext,
|
||||
useMenuStyle,
|
||||
useSubMenuContext,
|
||||
} from '../hooks';
|
||||
import CollapseTransition from './collapse-transition.vue';
|
||||
import SubMenuContent from './sub-menu-content.vue';
|
||||
|
||||
interface Props extends SubMenuProps {
|
||||
isSubMenuMore?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
isSubMenuMore: false,
|
||||
});
|
||||
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
const { b, is } = useNamespace('sub-menu');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const subMenuStyle = useMenuStyle(subMenu);
|
||||
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
handleMouseleave,
|
||||
level: (subMenu?.level ?? 0) + 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
const isTopLevelMenuSubmenu = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
|
||||
const rounded = computed(() => rootMenu?.props.rounded);
|
||||
const currentLevel = computed(() => subMenu?.level ?? 0);
|
||||
const isFirstLevel = computed(() => {
|
||||
return currentLevel.value === 1;
|
||||
});
|
||||
|
||||
const contentProps = computed((): HoverCardContentProps => {
|
||||
const isHorizontal = mode.value === 'horizontal';
|
||||
const side = isHorizontal && isFirstLevel.value ? 'bottom' : 'right';
|
||||
return {
|
||||
collisionPadding: { top: 20 },
|
||||
side,
|
||||
sideOffset: isHorizontal ? 5 : 10,
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed(() => {
|
||||
let isActive = false;
|
||||
|
||||
Object.values(items.value).forEach((item) => {
|
||||
if (item.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(subMenus.value).forEach((subItem) => {
|
||||
if (subItem.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
return isActive;
|
||||
});
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击submenu展开/关闭
|
||||
*/
|
||||
function handleClick() {
|
||||
const mode = rootMenu?.props.mode;
|
||||
if (
|
||||
// 当前菜单禁用时,不展开
|
||||
props.disabled ||
|
||||
(rootMenu?.props.collapse && mode === 'vertical') ||
|
||||
// 水平模式下不展开
|
||||
mode === 'horizontal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootMenu?.handleSubMenuClick({
|
||||
active: active.value,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
|
||||
if (event.type === 'focus') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
|
||||
props.disabled
|
||||
) {
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
rootMenu?.openMenu(props.path, parentPaths.value);
|
||||
}, showTimeout);
|
||||
parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
}
|
||||
|
||||
function handleMouseleave(deepDispatch = false) {
|
||||
if (
|
||||
!rootMenu?.props.collapse &&
|
||||
rootMenu?.props.mode === 'vertical' &&
|
||||
subMenu
|
||||
) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
}
|
||||
timer.value = setTimeout(() => {
|
||||
!mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
|
||||
}, 300);
|
||||
|
||||
if (deepDispatch) {
|
||||
subMenu?.handleMouseleave?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const item = reactive({
|
||||
active,
|
||||
parentPaths,
|
||||
path: props.path,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addSubMenu?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeSubMenu?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
b(),
|
||||
is('opened', opened),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
]"
|
||||
@focus="handleMouseenter"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="() => handleMouseleave()"
|
||||
>
|
||||
<template v-if="rootMenu.isMenuPopup">
|
||||
<VbenHoverCard
|
||||
:content-class="[
|
||||
rootMenu.theme,
|
||||
nsMenu.e('popup-container'),
|
||||
is(rootMenu.theme, true),
|
||||
opened ? '' : 'hidden',
|
||||
'overflow-auto',
|
||||
'max-h-[calc(var(--radix-hover-card-content-available-height)-20px)]',
|
||||
]"
|
||||
:content-props="contentProps"
|
||||
:open="true"
|
||||
:open-delay="0"
|
||||
>
|
||||
<template #trigger>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
</template>
|
||||
<div
|
||||
:class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
|
||||
@focus="(e) => handleMouseenter(e, 100)"
|
||||
@mouseenter="(e) => handleMouseenter(e, 100)"
|
||||
@mouseleave="() => handleMouseleave(true)"
|
||||
>
|
||||
<ul
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</VbenHoverCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
<CollapseTransition>
|
||||
<ul
|
||||
v-show="opened"
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</CollapseTransition>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-menu';
|
||||
export * from './use-menu-context';
|
||||
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { MenuProvider, SubMenuProvider } from '../types';
|
||||
|
||||
import { getCurrentInstance, inject, provide } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
const menuContextKey = Symbol('menuContext');
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createMenuContext(injectMenuData: MenuProvider) {
|
||||
provide(menuContextKey, injectMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createSubMenuContext(injectSubMenuData: SubMenuProvider) {
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
provide(`subMenu:${instance?.uid}`, injectSubMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const rootMenu = inject(menuContextKey) as MenuProvider;
|
||||
return rootMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useSubMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
|
||||
return subMenu;
|
||||
}
|
||||
|
||||
export {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuContext,
|
||||
useSubMenuContext,
|
||||
};
|
||||
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { SubMenuProvider } from '../types';
|
||||
|
||||
import { computed, getCurrentInstance } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
function useMenu() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 获取所有父级菜单链路
|
||||
*/
|
||||
const parentPaths = computed(() => {
|
||||
let parent = instance.parent;
|
||||
const paths: string[] = [instance.props.path as string];
|
||||
while (parent?.type.name !== 'Menu') {
|
||||
if (parent?.props.path) {
|
||||
paths.unshift(parent.props.path as string);
|
||||
}
|
||||
parent = parent?.parent ?? null;
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const parentMenu = computed(() => {
|
||||
return findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
});
|
||||
|
||||
return {
|
||||
parentMenu,
|
||||
parentPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function useMenuStyle(menu?: SubMenuProvider) {
|
||||
const subMenuStyle = computed(() => {
|
||||
return {
|
||||
'--menu-level': menu ? (menu?.level ?? 0 + 1) : 0,
|
||||
};
|
||||
});
|
||||
return subMenuStyle;
|
||||
}
|
||||
|
||||
export { useMenu, useMenuStyle };
|
||||
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as MenuBadge } from './components/menu-badge.vue';
|
||||
export * from './components/normal-menu';
|
||||
export { default as Menu } from './menu.vue';
|
||||
export type * from './types';
|
||||
38
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
38
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import type { MenuProps } from './types';
|
||||
|
||||
import { useForwardProps } from '@vben-core/composables';
|
||||
|
||||
import { Menu } from './components';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuView',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapse: false,
|
||||
// theme: 'dark',
|
||||
});
|
||||
|
||||
const forward = useForwardProps(props);
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'update:openKeys': [key: Key[]];
|
||||
// 'update:selectedKeys': [key: Key[]];
|
||||
// }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu v-bind="forward">
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<SubMenu :menu="menu" />
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { MenuBadge, MenuItem, SubMenu as SubMenuComp } from './components';
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menu: MenuRecordRaw;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'SubMenuUi',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
/**
|
||||
* 判断是否有子节点,动态渲染 menu-item/sub-menu-item
|
||||
*/
|
||||
const hasChildren = computed(() => {
|
||||
const { menu } = props;
|
||||
return (
|
||||
Reflect.has(menu, 'children') && !!menu.children && menu.children.length > 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuItem
|
||||
v-if="!hasChildren"
|
||||
:key="menu.path"
|
||||
:active-icon="menu.activeIcon"
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
</MenuItem>
|
||||
<SubMenuComp
|
||||
v-else
|
||||
:key="`${menu.path}_sub`"
|
||||
:active-icon="menu.activeIcon"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #content>
|
||||
<MenuBadge
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
class="right-6"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
<template v-for="childItem in menu.children || []" :key="childItem.path">
|
||||
<SubMenu :menu="childItem" />
|
||||
</template>
|
||||
</SubMenuComp>
|
||||
</template>
|
||||
139
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
139
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { MenuRecordBadgeRaw, ThemeModeType } from '@vben-core/typings';
|
||||
|
||||
interface MenuProps {
|
||||
/**
|
||||
* @zh_CN 是否开启手风琴模式
|
||||
* @default true
|
||||
*/
|
||||
accordion?: boolean;
|
||||
/**
|
||||
* @zh_CN 菜单是否折叠
|
||||
* @default false
|
||||
*/
|
||||
collapse?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单折叠时是否显示菜单名称
|
||||
* @default false
|
||||
*/
|
||||
collapseShowTitle?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认激活的菜单
|
||||
*/
|
||||
defaultActive?: string;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认展开的菜单
|
||||
*/
|
||||
defaultOpeneds?: string[];
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单模式
|
||||
* @default vertical
|
||||
*/
|
||||
mode?: 'horizontal' | 'vertical';
|
||||
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单主题
|
||||
* @default dark
|
||||
*/
|
||||
theme?: ThemeModeType;
|
||||
}
|
||||
|
||||
interface SubMenuProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 激活图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN submenu 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN menuitem 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemRegistered {
|
||||
active: boolean;
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemClicked {
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuProvider {
|
||||
activePath?: string;
|
||||
addMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
closeMenu: (path: string, parentLinks: string[]) => void;
|
||||
handleMenuItemClick: (item: MenuItemClicked) => void;
|
||||
handleSubMenuClick: (subMenu: MenuItemRegistered) => void;
|
||||
isMenuPopup: boolean;
|
||||
items: Record<string, MenuItemRegistered>;
|
||||
|
||||
openedMenus: string[];
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
props: MenuProps;
|
||||
removeMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
|
||||
subMenus: Record<string, MenuItemRegistered>;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
interface SubMenuProvider {
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
handleMouseleave?: (deepDispatch: boolean) => void;
|
||||
level: number;
|
||||
mouseInChild: Ref<boolean>;
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
MenuItemClicked,
|
||||
MenuItemProps,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
SubMenuProps,
|
||||
SubMenuProvider,
|
||||
};
|
||||
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ComponentInternalInstance,
|
||||
VNode,
|
||||
VNodeChild,
|
||||
VNodeNormalizedChildren,
|
||||
} from 'vue';
|
||||
|
||||
import { isVNode } from 'vue';
|
||||
|
||||
type VNodeChildAtom = Exclude<VNodeChild, Array<any>>;
|
||||
type RawSlots = Exclude<VNodeNormalizedChildren, Array<any> | null | string>;
|
||||
|
||||
type FlattenVNodes = Array<RawSlots | VNodeChildAtom>;
|
||||
|
||||
/**
|
||||
* @zh_CN Find the parent component upward
|
||||
* @param instance
|
||||
* @param parentNames
|
||||
*/
|
||||
function findComponentUpward(
|
||||
instance: ComponentInternalInstance,
|
||||
parentNames: string[],
|
||||
) {
|
||||
let parent = instance.parent;
|
||||
while (parent && !parentNames.includes(parent?.type?.name ?? '')) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
const flattedChildren = (
|
||||
children: FlattenVNodes | VNode | VNodeNormalizedChildren,
|
||||
): FlattenVNodes => {
|
||||
const vNodes = Array.isArray(children) ? children : [children];
|
||||
const result: FlattenVNodes = [];
|
||||
|
||||
vNodes.forEach((child) => {
|
||||
if (Array.isArray(child)) {
|
||||
result.push(...flattedChildren(child));
|
||||
} else if (isVNode(child) && Array.isArray(child.children)) {
|
||||
result.push(...flattedChildren(child.children));
|
||||
} else {
|
||||
result.push(child);
|
||||
if (isVNode(child) && child.component?.subTree) {
|
||||
result.push(...flattedChildren(child.component.subTree));
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export { findComponentUpward, flattedChildren };
|
||||
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
48
packages/@core/ui-kit/popup-ui/package.json
Normal file
48
packages/@core/ui-kit/popup-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/popup-ui",
|
||||
"version": "5.2.1",
|
||||
"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/@vben-core/uikit/popup-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
244
packages/@core/ui-kit/popup-ui/src/alert/AlertBuilder.ts
Normal file
244
packages/@core/ui-kit/popup-ui/src/alert/AlertBuilder.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Component, VNode } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { AlertProps, BeforeCloseScope, PromptProps } from './alert';
|
||||
|
||||
import { h, nextTick, ref, render } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { Input, VbenRenderContent } from '@vben-core/shadcn-ui';
|
||||
import { isFunction, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import Alert from './alert.vue';
|
||||
|
||||
const alerts = ref<Array<{ container: HTMLElement; instance: Component }>>([]);
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
export function vbenAlert(options: AlertProps): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenAlert(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: AlertProps = isString(arg0)
|
||||
? {
|
||||
content: arg0,
|
||||
}
|
||||
: { ...arg0 };
|
||||
if (arg1) {
|
||||
if (isString(arg1)) {
|
||||
options.title = arg1;
|
||||
} else if (!isString(arg1)) {
|
||||
// 如果第二个参数是对象,则合并到选项中
|
||||
Object.assign(options, arg1);
|
||||
}
|
||||
}
|
||||
|
||||
if (arg2 && !isString(arg2)) {
|
||||
Object.assign(options, arg2);
|
||||
}
|
||||
// 创建容器元素
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
// 创建一个引用,用于在回调中访问实例
|
||||
const alertRef = { container, instance: null as any };
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
onClosed: (isConfirm: boolean) => {
|
||||
// 移除组件实例以及创建的所有dom(恢复页面到打开前的状态)
|
||||
// 从alerts数组中移除该实例
|
||||
alerts.value = alerts.value.filter((item) => item !== alertRef);
|
||||
|
||||
// 从DOM中移除容器
|
||||
render(null, container);
|
||||
if (container.parentNode) {
|
||||
container.remove();
|
||||
}
|
||||
|
||||
// 解析 Promise,传递用户操作结果
|
||||
if (isConfirm) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('dialog cancelled'));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
open: true,
|
||||
title: options.title ?? $t.value('prompt'),
|
||||
};
|
||||
|
||||
// 创建Alert组件的VNode
|
||||
const vnode = h(Alert, props);
|
||||
|
||||
// 渲染组件到容器
|
||||
render(vnode, container);
|
||||
|
||||
// 保存组件实例引用
|
||||
alertRef.instance = vnode.component?.proxy as Component;
|
||||
|
||||
// 将实例和容器添加到alerts数组中
|
||||
alerts.value.push(alertRef);
|
||||
});
|
||||
}
|
||||
|
||||
export function vbenConfirm(options: AlertProps): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenConfirm(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
const defaultProps: Partial<AlertProps> = {
|
||||
showCancel: true,
|
||||
};
|
||||
if (!arg1) {
|
||||
return isString(arg0)
|
||||
? vbenAlert(arg0, defaultProps)
|
||||
: vbenAlert({ ...defaultProps, ...arg0 });
|
||||
} else if (!arg2) {
|
||||
return isString(arg1)
|
||||
? vbenAlert(arg0 as string, arg1, defaultProps)
|
||||
: vbenAlert(arg0 as string, { ...defaultProps, ...arg1 });
|
||||
}
|
||||
return vbenAlert(arg0 as string, arg1 as string, {
|
||||
...defaultProps,
|
||||
...arg2,
|
||||
});
|
||||
}
|
||||
|
||||
export async function vbenPrompt<T = any>(
|
||||
options: PromptProps<T>,
|
||||
): Promise<T | undefined> {
|
||||
const {
|
||||
component: _component,
|
||||
componentProps: _componentProps,
|
||||
componentSlots,
|
||||
content,
|
||||
defaultValue,
|
||||
modelPropName: _modelPropName,
|
||||
...delegated
|
||||
} = options;
|
||||
|
||||
const modelValue = ref<T | undefined>(defaultValue);
|
||||
const inputComponentRef = ref<null | VNode>(null);
|
||||
const staticContents: Component[] = [];
|
||||
|
||||
staticContents.push(h(VbenRenderContent, { content, renderBr: true }));
|
||||
|
||||
const modelPropName = _modelPropName || 'modelValue';
|
||||
const componentProps = { ..._componentProps };
|
||||
|
||||
// 每次渲染时都会重新计算的内容函数
|
||||
const contentRenderer = () => {
|
||||
const currentProps = { ...componentProps };
|
||||
|
||||
// 设置当前值
|
||||
currentProps[modelPropName] = modelValue.value;
|
||||
|
||||
// 设置更新处理函数
|
||||
currentProps[`onUpdate:${modelPropName}`] = (val: T) => {
|
||||
modelValue.value = val;
|
||||
};
|
||||
|
||||
// 创建输入组件
|
||||
inputComponentRef.value = h(
|
||||
_component || Input,
|
||||
currentProps,
|
||||
componentSlots,
|
||||
);
|
||||
|
||||
// 返回包含静态内容和输入组件的数组
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-col gap-2' },
|
||||
{ default: () => [...staticContents, inputComponentRef.value] },
|
||||
);
|
||||
};
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
...delegated,
|
||||
async beforeClose(scope: BeforeCloseScope) {
|
||||
if (delegated.beforeClose) {
|
||||
return await delegated.beforeClose({
|
||||
...scope,
|
||||
value: modelValue.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
// 使用函数形式,每次渲染都会重新计算内容
|
||||
content: contentRenderer,
|
||||
contentMasking: true,
|
||||
async onOpened() {
|
||||
await nextTick();
|
||||
const componentRef: null | VNode = inputComponentRef.value;
|
||||
if (componentRef) {
|
||||
if (
|
||||
componentRef.component?.exposed &&
|
||||
isFunction(componentRef.component.exposed.focus)
|
||||
) {
|
||||
componentRef.component.exposed.focus();
|
||||
} else {
|
||||
if (componentRef.el) {
|
||||
if (
|
||||
isFunction(componentRef.el.focus) &&
|
||||
['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(
|
||||
componentRef.el.tagName,
|
||||
)
|
||||
) {
|
||||
componentRef.el.focus();
|
||||
} else if (isFunction(componentRef.el.querySelector)) {
|
||||
const focusableElement = componentRef.el.querySelector(
|
||||
'input, select, textarea, button',
|
||||
);
|
||||
if (focusableElement && isFunction(focusableElement.focus)) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
} else if (
|
||||
componentRef.el.nextElementSibling &&
|
||||
isFunction(componentRef.el.nextElementSibling.focus)
|
||||
) {
|
||||
componentRef.el.nextElementSibling.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await vbenConfirm(props);
|
||||
return modelValue.value;
|
||||
}
|
||||
|
||||
export function clearAllAlerts() {
|
||||
alerts.value.forEach((alert) => {
|
||||
// 从DOM中移除容器
|
||||
render(null, alert.container);
|
||||
if (alert.container.parentNode) {
|
||||
alert.container.remove();
|
||||
}
|
||||
});
|
||||
alerts.value = [];
|
||||
}
|
||||
99
packages/@core/ui-kit/popup-ui/src/alert/alert.ts
Normal file
99
packages/@core/ui-kit/popup-ui/src/alert/alert.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Component, VNode, VNodeArrayChildren } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
|
||||
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
|
||||
|
||||
export type BeforeCloseScope = {
|
||||
isConfirm: boolean;
|
||||
};
|
||||
|
||||
export type AlertProps = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (
|
||||
scope: BeforeCloseScope,
|
||||
) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 边框 */
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 按钮对齐方式
|
||||
* @default 'end'
|
||||
*/
|
||||
buttonAlign?: 'center' | 'end' | 'start';
|
||||
/** 取消按钮的标题 */
|
||||
cancelText?: string;
|
||||
/** 是否居中显示 */
|
||||
centered?: boolean;
|
||||
/** 确认按钮的标题 */
|
||||
confirmText?: string;
|
||||
/** 弹窗容器的额外样式 */
|
||||
containerClass?: string;
|
||||
/** 弹窗提示内容 */
|
||||
content: Component | string;
|
||||
/** 弹窗内容的额外样式 */
|
||||
contentClass?: string;
|
||||
/** 执行beforeClose回调期间,在内容区域显示一个loading遮罩*/
|
||||
contentMasking?: boolean;
|
||||
/** 弹窗底部内容(与按钮在同一个容器中) */
|
||||
footer?: Component | string;
|
||||
/** 弹窗的图标(在标题的前面) */
|
||||
icon?: Component | IconType;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/** 是否显示取消按钮 */
|
||||
showCancel?: boolean;
|
||||
/** 弹窗标题 */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** Prompt属性 */
|
||||
export type PromptProps<T = any> = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (scope: {
|
||||
isConfirm: boolean;
|
||||
value: T | undefined;
|
||||
}) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 用于接受用户输入的组件 */
|
||||
component?: Component;
|
||||
/** 输入组件的属性 */
|
||||
componentProps?: Recordable<any>;
|
||||
/** 输入组件的插槽 */
|
||||
componentSlots?:
|
||||
| (() => any)
|
||||
| Recordable<unknown>
|
||||
| VNode
|
||||
| VNodeArrayChildren;
|
||||
/** 默认值 */
|
||||
defaultValue?: T;
|
||||
/** 输入组件的值属性名 */
|
||||
modelPropName?: string;
|
||||
} & Omit<AlertProps, 'beforeClose'>;
|
||||
|
||||
/**
|
||||
* Alert上下文
|
||||
*/
|
||||
export type AlertContext = {
|
||||
/** 执行取消操作 */
|
||||
doCancel: () => void;
|
||||
/** 执行确认操作 */
|
||||
doConfirm: () => void;
|
||||
};
|
||||
|
||||
export const [injectAlertContext, provideAlertContext] =
|
||||
createContext<AlertContext>('VbenAlertContext');
|
||||
|
||||
/**
|
||||
* 获取Alert上下文
|
||||
* @returns AlertContext
|
||||
*/
|
||||
export function useAlertContext() {
|
||||
const context = injectAlertContext();
|
||||
if (!context) {
|
||||
throw new Error('useAlertContext must be used within an AlertProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
211
packages/@core/ui-kit/popup-ui/src/alert/alert.vue
Normal file
211
packages/@core/ui-kit/popup-ui/src/alert/alert.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AlertProps } from './alert';
|
||||
|
||||
import { computed, h, nextTick, ref } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
CircleX,
|
||||
Info,
|
||||
X,
|
||||
} from '@vben-core/icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
VbenButton,
|
||||
VbenLoading,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { provideAlertContext } from './alert';
|
||||
|
||||
const props = withDefaults(defineProps<AlertProps>(), {
|
||||
bordered: true,
|
||||
buttonAlign: 'end',
|
||||
centered: true,
|
||||
containerClass: 'w-[520px]',
|
||||
});
|
||||
const emits = defineEmits(['closed', 'confirm', 'opened']);
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
const { $t } = useSimpleLocale();
|
||||
const components = globalShareState.getComponents();
|
||||
const isConfirm = ref(false);
|
||||
|
||||
function onAlertClosed() {
|
||||
emits('closed', isConfirm.value);
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
function onEscapeKeyDown() {
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
const getIconRender = computed(() => {
|
||||
let iconRender: Component | null = null;
|
||||
if (props.icon) {
|
||||
if (typeof props.icon === 'string') {
|
||||
switch (props.icon) {
|
||||
case 'error': {
|
||||
iconRender = h(CircleX, {
|
||||
style: { color: 'hsl(var(--destructive))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
iconRender = h(Info, { style: { color: 'hsl(var(--info))' } });
|
||||
break;
|
||||
}
|
||||
case 'question': {
|
||||
iconRender = CircleHelp;
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
iconRender = h(CircleCheckBig, {
|
||||
style: { color: 'hsl(var(--success))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
iconRender = h(CircleAlert, {
|
||||
style: { color: 'hsl(var(--warning))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
iconRender = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
iconRender = props.icon ?? null;
|
||||
}
|
||||
return iconRender;
|
||||
});
|
||||
|
||||
function doCancel() {
|
||||
handleCancel();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
function doConfirm() {
|
||||
handleConfirm();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
provideAlertContext({
|
||||
doCancel,
|
||||
doConfirm,
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
isConfirm.value = true;
|
||||
emits('confirm');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
async function handleOpenChange(val: boolean) {
|
||||
await nextTick(); // 等待标记isConfirm状态
|
||||
if (!val && props.beforeClose) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await props.beforeClose({ isConfirm: isConfirm.value });
|
||||
if (res !== false) {
|
||||
open.value = false;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
open.value = val;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AlertDialog :open="open" @update:open="handleOpenChange">
|
||||
<AlertDialogContent
|
||||
:open="open"
|
||||
:centered="centered"
|
||||
:overlay-blur="overlayBlur"
|
||||
@opened="emits('opened')"
|
||||
@closed="onAlertClosed"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
:class="
|
||||
cn(
|
||||
containerClass,
|
||||
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:rounded-[var(--radius)] md:w-[520px] md:max-w-[80%]',
|
||||
{
|
||||
'border-border border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('relative flex-1 overflow-y-auto p-3', contentClass)">
|
||||
<AlertDialogTitle v-if="title">
|
||||
<div class="flex items-center">
|
||||
<component :is="getIconRender" class="mr-2" />
|
||||
<span class="flex-auto">{{ $t(title) }}</span>
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<VbenButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="text-muted-foreground size-4" />
|
||||
</VbenButton>
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div class="m-4 min-h-[30px]">
|
||||
<VbenRenderContent :content="content" render-br />
|
||||
</div>
|
||||
<VbenLoading v-if="loading && contentMasking" :spinning="loading" />
|
||||
</AlertDialogDescription>
|
||||
<div
|
||||
class="flex items-center justify-end gap-x-2"
|
||||
:class="`justify-${buttonAlign}`"
|
||||
>
|
||||
<VbenRenderContent :content="footer" />
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
:disabled="loading"
|
||||
variant="ghost"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</component>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</component>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
14
packages/@core/ui-kit/popup-ui/src/alert/index.ts
Normal file
14
packages/@core/ui-kit/popup-ui/src/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AlertProps,
|
||||
BeforeCloseScope,
|
||||
IconType,
|
||||
PromptProps,
|
||||
} from './alert';
|
||||
export { useAlertContext } from './alert';
|
||||
export { default as Alert } from './alert.vue';
|
||||
export {
|
||||
vbenAlert as alert,
|
||||
clearAllAlerts,
|
||||
vbenConfirm as confirm,
|
||||
vbenPrompt as prompt,
|
||||
} from './AlertBuilder';
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { DrawerState } from '../drawer';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DrawerApi } from '../drawer-api';
|
||||
|
||||
// 模拟 Store 类
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: DrawerState;
|
||||
|
||||
private options: any;
|
||||
|
||||
constructor(initialState: DrawerState, options: any) {
|
||||
this._state = initialState;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
batch(cb: () => void) {
|
||||
cb();
|
||||
}
|
||||
|
||||
setState(fn: (prev: DrawerState) => DrawerState) {
|
||||
this._state = fn(this._state);
|
||||
this.options.onUpdate();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('drawerApi', () => {
|
||||
let drawerApi: DrawerApi;
|
||||
let drawerState: DrawerState;
|
||||
|
||||
beforeEach(() => {
|
||||
drawerApi = new DrawerApi();
|
||||
drawerState = drawerApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(drawerState.isOpen).toBe(false);
|
||||
expect(drawerState.cancelText).toBe(undefined);
|
||||
expect(drawerState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the drawer', () => {
|
||||
drawerApi.open();
|
||||
expect(drawerApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the drawer if onBeforeClose allows it', () => {
|
||||
drawerApi.close();
|
||||
expect(drawerApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the drawer if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const drawerApiWithHook = new DrawerApi({ onBeforeClose });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.close();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and keep drawer open if onCancel is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onCancel });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true); // 关闭逻辑不在 onCancel 内
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
drawerApi.setData(testData);
|
||||
expect(drawerApi.getData()).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should set state correctly using an object', () => {
|
||||
drawerApi.setState({ title: 'New Title' });
|
||||
expect(drawerApi.store.state.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
drawerApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(drawerApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpenChange });
|
||||
drawerApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onClosed });
|
||||
drawerApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpened });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
183
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal file
183
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { DrawerApiOptions, DrawerState } from './drawer';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class DrawerApi {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', any> = {
|
||||
payload: {},
|
||||
};
|
||||
public store: Store<DrawerState>;
|
||||
|
||||
private api: Pick<
|
||||
DrawerApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: DrawerState;
|
||||
private state!: DrawerState;
|
||||
|
||||
constructor(options: DrawerApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: DrawerState = {
|
||||
class: '',
|
||||
closable: true,
|
||||
closeIconPlacement: 'right',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
footer: true,
|
||||
header: true,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
placement: 'right',
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
submitting: false,
|
||||
title: '',
|
||||
};
|
||||
|
||||
this.store = new Store<DrawerState>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
const state = this.store.state;
|
||||
if (state?.isOpen === this.state?.isOpen) {
|
||||
this.state = state;
|
||||
} else {
|
||||
this.state = state;
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
this.state = this.store.state;
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭抽屉
|
||||
* @description 关闭抽屉时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData<T extends object = Record<string, any>>() {
|
||||
return (this.sharedData?.payload ?? {}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定抽屉状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked: boolean = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({ ...prev, isOpen: true }));
|
||||
}
|
||||
|
||||
setData<T>(payload: T) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: DrawerState) => Partial<DrawerState>)
|
||||
| Partial<DrawerState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除抽屉的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
179
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal file
179
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { ClassType, MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { DrawerApi } from './drawer-api';
|
||||
|
||||
export type DrawerPlacement = 'bottom' | 'left' | 'right' | 'top';
|
||||
|
||||
export type CloseIconPlacement = 'left' | 'right';
|
||||
|
||||
export interface DrawerProps {
|
||||
/**
|
||||
* 是否挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
class?: ClassType;
|
||||
/**
|
||||
* 是否显示关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 关闭按钮的位置
|
||||
*/
|
||||
closeIconPlacement?: CloseIconPlacement;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @default true
|
||||
*/
|
||||
footer?: boolean;
|
||||
/**
|
||||
* 弹窗底部样式
|
||||
*/
|
||||
footerClass?: ClassType;
|
||||
/**
|
||||
* 是否显示顶栏
|
||||
* @default true
|
||||
*/
|
||||
header?: boolean;
|
||||
/**
|
||||
* 弹窗头部样式
|
||||
*/
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 是否显示遮罩
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
|
||||
/**
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/**
|
||||
* 抽屉位置
|
||||
* @default right
|
||||
*/
|
||||
placement?: DrawerPlacement;
|
||||
|
||||
/**
|
||||
* 是否显示取消按钮
|
||||
* @default true
|
||||
*/
|
||||
showCancelButton?: boolean;
|
||||
/**
|
||||
* 是否显示确认按钮
|
||||
* @default true
|
||||
*/
|
||||
showConfirmButton?: boolean;
|
||||
/**
|
||||
* 提交中(锁定抽屉状态)
|
||||
*/
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* 弹窗标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 弹窗标题提示
|
||||
*/
|
||||
titleTooltip?: string;
|
||||
/**
|
||||
* 抽屉层级
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface DrawerState extends DrawerProps {
|
||||
/** 弹窗打开状态 */
|
||||
isOpen?: boolean;
|
||||
/**
|
||||
* 共享数据
|
||||
*/
|
||||
sharedData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ExtendedDrawerApi = DrawerApi & {
|
||||
useStore: <T = NoInfer<DrawerState>>(
|
||||
selector?: (state: NoInfer<DrawerState>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface DrawerApiOptions extends DrawerState {
|
||||
/**
|
||||
* 独立的抽屉组件
|
||||
*/
|
||||
connectedComponent?: Component;
|
||||
/**
|
||||
* 在关闭时销毁抽屉。仅在使用 connectedComponent 时有效
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
293
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal file
293
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
|
||||
|
||||
import { computed, provide, ref, useId, watch } from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
usePriorityValues,
|
||||
useSimpleLocale,
|
||||
} from '@vben-core/composables';
|
||||
import { X } from '@vben-core/icons';
|
||||
import {
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props extends DrawerProps {
|
||||
drawerApi?: ExtendedDrawerApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
closeIconPlacement: 'right',
|
||||
drawerApi: undefined,
|
||||
submitting: false,
|
||||
zIndex: 1000,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const id = useId();
|
||||
provide('DISMISSABLE_DRAWER_ID', id);
|
||||
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const { $t } = useSimpleLocale();
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
const state = props.drawerApi?.useStore?.();
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
cancelText,
|
||||
class: drawerClass,
|
||||
closable,
|
||||
closeIconPlacement,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
header: showHeader,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
placement,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
watch(
|
||||
() => showLoading.value,
|
||||
(v) => {
|
||||
if (v && wrapperRef.value) {
|
||||
wrapperRef.value.scrollTo({
|
||||
// behavior: 'smooth',
|
||||
top: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const dismissableDrawer = target?.dataset.dismissableDrawer;
|
||||
if (
|
||||
submitting.value ||
|
||||
!closeOnClickModal.value ||
|
||||
dismissableDrawer !== id
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handerOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Sheet
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => drawerApi?.close()"
|
||||
>
|
||||
<SheetContent
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn('flex w-[520px] flex-col', drawerClass, {
|
||||
'!w-full': isMobile || placement === 'bottom' || placement === 'top',
|
||||
'max-h-[100vh]': placement === 'bottom' || placement === 'top',
|
||||
})
|
||||
"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:side="placement"
|
||||
:z-index="zIndex"
|
||||
:overlay-blur="overlayBlur"
|
||||
@close-auto-focus="handleFocusOutside"
|
||||
@closed="() => drawerApi?.onClosed()"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handerOpenAutoFocus"
|
||||
@opened="() => drawerApi?.onOpened()"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<SheetHeader
|
||||
v-if="showHeader"
|
||||
:class="
|
||||
cn(
|
||||
'!flex flex-row items-center justify-between border-b px-6 py-5',
|
||||
headerClass,
|
||||
{
|
||||
'px-4 py-3': closable,
|
||||
'pl-2': closable && closeIconPlacement === 'left',
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
<Separator
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
class="ml-1 mr-2 h-8"
|
||||
decorative
|
||||
orientation="vertical"
|
||||
/>
|
||||
<SheetTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<VbenHelpTooltip v-if="titleTooltip" trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</SheetTitle>
|
||||
<SheetDescription v-if="description" class="mt-1 text-xs">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<SheetTitle v-if="!title" />
|
||||
<SheetDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
|
||||
<div class="flex-center">
|
||||
<slot name="extra"></slot>
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'right'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<template v-else>
|
||||
<VisuallyHidden>
|
||||
<SheetTitle />
|
||||
<SheetDescription />
|
||||
</VisuallyHidden>
|
||||
</template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'overflow-hidden': showLoading,
|
||||
})
|
||||
"
|
||||
>
|
||||
<VbenLoading
|
||||
v-if="showLoading || submitting"
|
||||
class="size-full"
|
||||
spinning
|
||||
/>
|
||||
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<SheetFooter
|
||||
v-if="showFooter"
|
||||
:class="
|
||||
cn(
|
||||
'w-full flex-row items-center justify-end border-t p-2 px-3',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="ghost"
|
||||
:disabled="submitting"
|
||||
@click="() => drawerApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => drawerApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './drawer';
|
||||
export { default as VbenDrawer } from './drawer.vue';
|
||||
export { setDefaultDrawerProps, useVbenDrawer } from './use-drawer';
|
||||
139
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal file
139
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
DrawerApiOptions,
|
||||
DrawerProps,
|
||||
ExtendedDrawerApi,
|
||||
} from './drawer';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
nextTick,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { useStore } from '@vben-core/shared/store';
|
||||
|
||||
import { DrawerApi } from './drawer-api';
|
||||
import VbenDrawer from './drawer.vue';
|
||||
|
||||
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT');
|
||||
|
||||
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {};
|
||||
|
||||
export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
|
||||
Object.assign(DEFAULT_DRAWER_PROPS, props);
|
||||
}
|
||||
|
||||
export function useVbenDrawer<
|
||||
TParentDrawerProps extends DrawerProps = DrawerProps,
|
||||
>(options: DrawerApiOptions = {}) {
|
||||
// Drawer一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
// 外部的Drawer通过provide/inject传递api
|
||||
|
||||
const { connectedComponent } = options;
|
||||
if (connectedComponent) {
|
||||
const extendedApi = reactive({});
|
||||
const isDrawerReady = ref(true);
|
||||
const Drawer = defineComponent(
|
||||
(props: TParentDrawerProps, { attrs, slots }) => {
|
||||
provide(USER_DRAWER_INJECT_KEY, {
|
||||
extendApi(api: ExtendedDrawerApi) {
|
||||
// 不能直接给 reactive 赋值,会丢失响应
|
||||
// 不能用 Object.assign,会丢失 api 的原型函数
|
||||
Object.setPrototypeOf(extendedApi, api);
|
||||
},
|
||||
options,
|
||||
async reCreateDrawer() {
|
||||
isDrawerReady.value = false;
|
||||
await nextTick();
|
||||
isDrawerReady.value = true;
|
||||
},
|
||||
});
|
||||
checkProps(extendedApi as ExtendedDrawerApi, {
|
||||
...props,
|
||||
...attrs,
|
||||
...slots,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
isDrawerReady.value ? connectedComponent : 'div',
|
||||
{ ...props, ...attrs },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenParentDrawer',
|
||||
},
|
||||
);
|
||||
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
|
||||
}
|
||||
|
||||
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {});
|
||||
|
||||
const mergedOptions = {
|
||||
...DEFAULT_DRAWER_PROPS,
|
||||
...injectData.options,
|
||||
...options,
|
||||
} as DrawerApiOptions;
|
||||
|
||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
options.onOpenChange?.(isOpen);
|
||||
injectData.options?.onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
const onClosed = mergedOptions.onClosed;
|
||||
mergedOptions.onClosed = () => {
|
||||
onClosed?.();
|
||||
if (mergedOptions.destroyOnClose) {
|
||||
injectData.reCreateDrawer?.();
|
||||
}
|
||||
};
|
||||
const api = new DrawerApi(mergedOptions);
|
||||
|
||||
const extendedApi: ExtendedDrawerApi = api as never;
|
||||
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Drawer = defineComponent(
|
||||
(props: DrawerProps, { attrs, slots }) => {
|
||||
return () =>
|
||||
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenDrawer',
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
return [Drawer, extendedApi] as const;
|
||||
}
|
||||
|
||||
async function checkProps(api: ExtendedDrawerApi, attrs: Record<string, any>) {
|
||||
if (!attrs || Object.keys(attrs).length === 0) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Drawer的props,会造成复杂度提升,如果你需要修改Drawer的props,请使用 useVbenDrawer 或者api
|
||||
console.warn(
|
||||
`[Vben Drawer]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Drawer, please use useVbenDrawer or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './alert';
|
||||
export * from './drawer';
|
||||
export * from './modal';
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ModalState } from '../modal';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ModalApi } from '../modal-api';
|
||||
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: ModalState;
|
||||
|
||||
private options: any;
|
||||
|
||||
constructor(initialState: ModalState, options: any) {
|
||||
this._state = initialState;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
batch(cb: () => void) {
|
||||
cb();
|
||||
}
|
||||
|
||||
setState(fn: (prev: ModalState) => ModalState) {
|
||||
this._state = fn(this._state);
|
||||
this.options.onUpdate();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('modalApi', () => {
|
||||
let modalApi: ModalApi;
|
||||
// 使用 modalState 而不是 state
|
||||
let modalState: ModalState;
|
||||
|
||||
beforeEach(() => {
|
||||
modalApi = new ModalApi();
|
||||
// 获取 modalApi 内的 state
|
||||
modalState = modalApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(modalState.isOpen).toBe(false);
|
||||
expect(modalState.cancelText).toBe(undefined);
|
||||
expect(modalState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the modal', () => {
|
||||
modalApi.open();
|
||||
expect(modalApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the modal if onBeforeClose allows it', () => {
|
||||
modalApi.close();
|
||||
expect(modalApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the modal if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const modalApiWithHook = new ModalApi({ onBeforeClose });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.close();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and close the modal if no onCancel hook is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onCancel });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
modalApi.setData(testData);
|
||||
expect(modalApi.getData()).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should set state correctly using an object', () => {
|
||||
modalApi.setState({ title: 'New Title' });
|
||||
expect(modalApi.store.state.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
modalApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(modalApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpenChange });
|
||||
modalApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onClosed });
|
||||
modalApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpened });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './modal';
|
||||
export { default as VbenModal } from './modal.vue';
|
||||
export { setDefaultModalProps, useVbenModal } from './use-modal';
|
||||
192
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal file
192
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ModalApiOptions, ModalState } from './modal';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class ModalApi {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', any> = {
|
||||
payload: {},
|
||||
};
|
||||
public store: Store<ModalState>;
|
||||
|
||||
private api: Pick<
|
||||
ModalApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: ModalState;
|
||||
private state!: ModalState;
|
||||
|
||||
constructor(options: ModalApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: ModalState = {
|
||||
bordered: true,
|
||||
centered: false,
|
||||
class: '',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmDisabled: false,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
destroyOnClose: true,
|
||||
draggable: false,
|
||||
footer: true,
|
||||
footerClass: '',
|
||||
fullscreen: false,
|
||||
fullscreenButton: true,
|
||||
header: true,
|
||||
headerClass: '',
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
title: '',
|
||||
};
|
||||
|
||||
this.store = new Store<ModalState>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
const state = this.store.state;
|
||||
|
||||
// 每次更新状态时,都会调用 onOpenChange 回调函数
|
||||
if (state?.isOpen === this.state?.isOpen) {
|
||||
this.state = state;
|
||||
} else {
|
||||
this.state = state;
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.state = this.store.state;
|
||||
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
* @description 关闭弹窗时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData<T extends object = Record<string, any>>() {
|
||||
return (this.sharedData?.payload ?? {}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定弹窗状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖弹窗内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({ ...prev, isOpen: true }));
|
||||
}
|
||||
|
||||
setData<T>(payload: T) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: ModalState) => Partial<ModalState>)
|
||||
| Partial<ModalState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除弹窗的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
189
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal file
189
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { ModalApi } from './modal-api';
|
||||
|
||||
export interface ModalProps {
|
||||
/**
|
||||
* 是否要挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 是否显示边框
|
||||
* @default false
|
||||
*/
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
/**
|
||||
* 是否居中
|
||||
* @default false
|
||||
*/
|
||||
centered?: boolean;
|
||||
|
||||
class?: string;
|
||||
|
||||
/**
|
||||
* 是否显示右上角的关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 禁用确认按钮
|
||||
*/
|
||||
confirmDisabled?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 在关闭时销毁弹窗
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 是否可拖拽
|
||||
* @default false
|
||||
*/
|
||||
draggable?: boolean;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @default true
|
||||
*/
|
||||
footer?: boolean;
|
||||
footerClass?: string;
|
||||
/**
|
||||
* 是否全屏
|
||||
* @default false
|
||||
*/
|
||||
fullscreen?: boolean;
|
||||
/**
|
||||
* 是否显示全屏按钮
|
||||
* @default true
|
||||
*/
|
||||
fullscreenButton?: boolean;
|
||||
/**
|
||||
* 是否显示顶栏
|
||||
* @default true
|
||||
*/
|
||||
header?: boolean;
|
||||
headerClass?: string;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 是否显示遮罩
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
/**
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/**
|
||||
* 是否显示取消按钮
|
||||
* @default true
|
||||
*/
|
||||
showCancelButton?: boolean;
|
||||
/**
|
||||
* 是否显示确认按钮
|
||||
* @default true
|
||||
*/
|
||||
showConfirmButton?: boolean;
|
||||
/**
|
||||
* 提交中(锁定弹窗状态)
|
||||
*/
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* 弹窗标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 弹窗标题提示
|
||||
*/
|
||||
titleTooltip?: string;
|
||||
/**
|
||||
* 弹窗层级
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface ModalState extends ModalProps {
|
||||
/** 弹窗打开状态 */
|
||||
isOpen?: boolean;
|
||||
/**
|
||||
* 共享数据
|
||||
*/
|
||||
sharedData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ExtendedModalApi = ModalApi & {
|
||||
useStore: <T = NoInfer<ModalState>>(
|
||||
selector?: (state: NoInfer<ModalState>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface ModalApiOptions extends ModalState {
|
||||
/**
|
||||
* 独立的弹窗组件
|
||||
*/
|
||||
connectedComponent?: Component;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
341
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal file
341
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ExtendedModalApi, ModalProps } from './modal';
|
||||
|
||||
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
usePriorityValues,
|
||||
useSimpleLocale,
|
||||
} from '@vben-core/composables';
|
||||
import { Expand, Shrink } from '@vben-core/icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { useModalDraggable } from './use-modal-draggable';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
modalApi?: ExtendedModalApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
destroyOnClose: true,
|
||||
modalApi: undefined,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const contentRef = ref();
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const dialogRef = ref();
|
||||
const headerRef = ref();
|
||||
const footerRef = ref();
|
||||
|
||||
const id = useId();
|
||||
|
||||
provide('DISMISSABLE_MODAL_ID', id);
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
const { isMobile } = useIsMobile();
|
||||
const state = props.modalApi?.useStore?.();
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
bordered,
|
||||
cancelText,
|
||||
centered,
|
||||
class: modalClass,
|
||||
closable,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmDisabled,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
destroyOnClose,
|
||||
draggable,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
fullscreen,
|
||||
fullscreenButton,
|
||||
header,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
const shouldFullscreen = computed(
|
||||
() => (fullscreen.value && header.value) || isMobile.value,
|
||||
);
|
||||
|
||||
const shouldDraggable = computed(
|
||||
() => draggable.value && !shouldFullscreen.value && header.value,
|
||||
);
|
||||
|
||||
const { dragging, transform } = useModalDraggable(
|
||||
dialogRef,
|
||||
headerRef,
|
||||
shouldDraggable,
|
||||
);
|
||||
|
||||
const firstOpened = ref(false);
|
||||
const isClosed = ref(true);
|
||||
|
||||
watch(
|
||||
() => state?.value?.isOpen,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
isClosed.value = false;
|
||||
if (!firstOpened.value) firstOpened.value = true;
|
||||
await nextTick();
|
||||
if (!contentRef.value) return;
|
||||
const innerContentRef = contentRef.value.getContentRef();
|
||||
dialogRef.value = innerContentRef.$el;
|
||||
// reopen modal reassign value
|
||||
const { offsetX, offsetY } = transform;
|
||||
dialogRef.value.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [showLoading.value, submitting.value],
|
||||
([l, s]) => {
|
||||
if ((s || l) && wrapperRef.value) {
|
||||
wrapperRef.value.scrollTo({
|
||||
// behavior: 'smooth',
|
||||
top: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleFullscreen() {
|
||||
props.modalApi?.setState((prev) => {
|
||||
// if (prev.fullscreen) {
|
||||
// resetPosition();
|
||||
// }
|
||||
return { ...prev, fullscreen: !fullscreen.value };
|
||||
});
|
||||
}
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handerOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const isDismissableModal = target?.dataset.dismissableModal;
|
||||
if (
|
||||
!closeOnClickModal.value ||
|
||||
isDismissableModal !== id ||
|
||||
submitting.value
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(firstOpened);
|
||||
});
|
||||
|
||||
function handleClosed() {
|
||||
isClosed.value = true;
|
||||
props.modalApi?.onClosed();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Dialog
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => (!submitting ? modalApi?.close() : undefined)"
|
||||
>
|
||||
<DialogContent
|
||||
ref="contentRef"
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn(
|
||||
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0 sm:rounded-[var(--radius)]',
|
||||
modalClass,
|
||||
{
|
||||
'border-border border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
|
||||
shouldFullscreen,
|
||||
'top-1/2 !-translate-y-1/2': centered && !shouldFullscreen,
|
||||
'duration-300': !dragging,
|
||||
hidden: isClosed,
|
||||
},
|
||||
)
|
||||
"
|
||||
:force-mount="getForceMount"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:show-close="closable"
|
||||
:z-index="zIndex"
|
||||
:overlay-blur="overlayBlur"
|
||||
close-class="top-3"
|
||||
@close-auto-focus="handleFocusOutside"
|
||||
@closed="handleClosed"
|
||||
:close-disabled="submitting"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handerOpenAutoFocus"
|
||||
@opened="() => modalApi?.onOpened()"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<DialogHeader
|
||||
ref="headerRef"
|
||||
:class="
|
||||
cn(
|
||||
'px-5 py-4',
|
||||
{
|
||||
'border-b': bordered,
|
||||
hidden: !header,
|
||||
'cursor-move select-none': shouldDraggable,
|
||||
},
|
||||
headerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<DialogTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<slot v-if="titleTooltip" name="titleTooltip">
|
||||
<VbenHelpTooltip trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</slot>
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="description">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</DialogDescription>
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<DialogTitle v-if="!title" />
|
||||
<DialogDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative min-h-40 flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'overflow-hidden': showLoading || submitting,
|
||||
})
|
||||
"
|
||||
>
|
||||
<VbenLoading
|
||||
v-if="showLoading || submitting"
|
||||
class="size-full h-auto min-h-full"
|
||||
spinning
|
||||
/>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<VbenIconButton
|
||||
v-if="fullscreenButton"
|
||||
class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-3 hidden size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none sm:block"
|
||||
@click="handleFullscreen"
|
||||
>
|
||||
<Shrink v-if="fullscreen" class="size-3.5" />
|
||||
<Expand v-else class="size-3.5" />
|
||||
</VbenIconButton>
|
||||
|
||||
<DialogFooter
|
||||
v-if="showFooter"
|
||||
ref="footerRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex-row items-center justify-end p-2',
|
||||
{
|
||||
'border-t': bordered,
|
||||
},
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="ghost"
|
||||
:disabled="submitting"
|
||||
@click="() => modalApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:disabled="confirmDisabled"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => modalApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
117
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal file
117
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-draggable/index.ts
|
||||
* 调整部分细节
|
||||
*/
|
||||
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue';
|
||||
|
||||
import { unrefElement } from '@vueuse/core';
|
||||
|
||||
export function useModalDraggable(
|
||||
targetRef: Ref<HTMLElement | undefined>,
|
||||
dragRef: Ref<HTMLElement | undefined>,
|
||||
draggable: ComputedRef<boolean>,
|
||||
) {
|
||||
const transform = reactive({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
const dragging = ref(false);
|
||||
|
||||
const onMousedown = (e: MouseEvent) => {
|
||||
const downX = e.clientX;
|
||||
const downY = e.clientY;
|
||||
|
||||
if (!targetRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = targetRef.value.getBoundingClientRect();
|
||||
|
||||
const { offsetX, offsetY } = transform;
|
||||
const targetLeft = targetRect.left;
|
||||
const targetTop = targetRect.top;
|
||||
const targetWidth = targetRect.width;
|
||||
const targetHeight = targetRect.height;
|
||||
const docElement = document.documentElement;
|
||||
const clientWidth = docElement.clientWidth;
|
||||
const clientHeight = docElement.clientHeight;
|
||||
|
||||
const minLeft = -targetLeft + offsetX;
|
||||
const minTop = -targetTop + offsetY;
|
||||
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
|
||||
const maxTop = clientHeight - targetTop - targetHeight + offsetY;
|
||||
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
let moveX = offsetX + e.clientX - downX;
|
||||
let moveY = offsetY + e.clientY - downY;
|
||||
|
||||
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
|
||||
moveY = Math.min(Math.max(moveY, minTop), maxTop);
|
||||
|
||||
transform.offsetX = moveX;
|
||||
transform.offsetY = moveY;
|
||||
|
||||
if (targetRef.value) {
|
||||
targetRef.value.style.transform = `translate(${moveX}px, ${moveY}px)`;
|
||||
dragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseup = () => {
|
||||
dragging.value = false;
|
||||
document.removeEventListener('mousemove', onMousemove);
|
||||
document.removeEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMousemove);
|
||||
document.addEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
const onDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const offDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
transform.offsetX = 0;
|
||||
transform.offsetY = 0;
|
||||
|
||||
const target = unrefElement(targetRef);
|
||||
if (target) {
|
||||
target.style.transform = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
if (draggable.value) {
|
||||
onDraggable();
|
||||
} else {
|
||||
offDraggable();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offDraggable();
|
||||
});
|
||||
|
||||
return {
|
||||
dragging,
|
||||
resetPosition,
|
||||
transform,
|
||||
};
|
||||
}
|
||||
146
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal file
146
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
nextTick,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { useStore } from '@vben-core/shared/store';
|
||||
|
||||
import { ModalApi } from './modal-api';
|
||||
import VbenModal from './modal.vue';
|
||||
|
||||
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT');
|
||||
|
||||
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {};
|
||||
|
||||
export function setDefaultModalProps(props: Partial<ModalProps>) {
|
||||
Object.assign(DEFAULT_MODAL_PROPS, props);
|
||||
}
|
||||
|
||||
export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
options: ModalApiOptions = {},
|
||||
) {
|
||||
// Modal一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
// 外部的Modal通过provide/inject传递api
|
||||
|
||||
const { connectedComponent } = options;
|
||||
if (connectedComponent) {
|
||||
const extendedApi = reactive({});
|
||||
const isModalReady = ref(true);
|
||||
const Modal = defineComponent(
|
||||
(props: TParentModalProps, { attrs, slots }) => {
|
||||
provide(USER_MODAL_INJECT_KEY, {
|
||||
extendApi(api: ExtendedModalApi) {
|
||||
// 不能直接给 reactive 赋值,会丢失响应
|
||||
// 不能用 Object.assign,会丢失 api 的原型函数
|
||||
Object.setPrototypeOf(extendedApi, api);
|
||||
},
|
||||
options,
|
||||
async reCreateModal() {
|
||||
isModalReady.value = false;
|
||||
await nextTick();
|
||||
isModalReady.value = true;
|
||||
},
|
||||
});
|
||||
checkProps(extendedApi as ExtendedModalApi, {
|
||||
...props,
|
||||
...attrs,
|
||||
...slots,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
isModalReady.value ? connectedComponent : 'div',
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenParentModal',
|
||||
},
|
||||
);
|
||||
return [Modal, extendedApi as ExtendedModalApi] as const;
|
||||
}
|
||||
|
||||
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
|
||||
|
||||
const mergedOptions = {
|
||||
...DEFAULT_MODAL_PROPS,
|
||||
...injectData.options,
|
||||
...options,
|
||||
} as ModalApiOptions;
|
||||
|
||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
options.onOpenChange?.(isOpen);
|
||||
injectData.options?.onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
mergedOptions.onClosed = () => {
|
||||
options.onClosed?.();
|
||||
if (options.destroyOnClose) {
|
||||
injectData.reCreateModal?.();
|
||||
}
|
||||
};
|
||||
|
||||
const api = new ModalApi(mergedOptions);
|
||||
|
||||
const extendedApi: ExtendedModalApi = api as never;
|
||||
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Modal = defineComponent(
|
||||
(props: ModalProps, { attrs, slots }) => {
|
||||
return () =>
|
||||
h(
|
||||
VbenModal,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
modalApi: extendedApi,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenModal',
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
return [Modal, extendedApi] as const;
|
||||
}
|
||||
|
||||
async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) {
|
||||
if (!attrs || Object.keys(attrs).length === 0) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Modal的props,会造成复杂度提升,如果你需要修改Modal的props,请使用 useModal 或者api
|
||||
console.warn(
|
||||
`[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useVbenModal or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
27
packages/@core/ui-kit/shadcn-ui/build.config.ts
Normal file
27
packages/@core/ui-kit/shadcn-ui/build.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
|
||||
pattern: ['**/*'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
16
packages/@core/ui-kit/shadcn-ui/components.json
Normal file
16
packages/@core/ui-kit/shadcn-ui/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.mjs",
|
||||
"css": "src/assets/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@vben-core/shadcn-ui/components",
|
||||
"utils": "@vben-core/shared/utils"
|
||||
}
|
||||
}
|
||||
56
packages/@core/ui-kit/shadcn-ui/package.json
Normal file
56
packages/@core/ui-kit/shadcn-ui/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@vben-core/shadcn-ui",
|
||||
"version": "5.5.4",
|
||||
"#main": "./dist/index.mjs",
|
||||
"#module": "./dist/index.mjs",
|
||||
"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/@vben-core/uikit/shadcn-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"#build": "pnpm unbuild",
|
||||
"#prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts",
|
||||
"//default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@wangeditor/editor": "catalog:",
|
||||
"@wangeditor/editor-for-vue": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"lucide-vue-next": "catalog:",
|
||||
"radix-vue": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/shadcn-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/shadcn-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AvatarFallbackProps,
|
||||
AvatarImageProps,
|
||||
AvatarRootProps,
|
||||
} from 'radix-vue';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../../ui';
|
||||
|
||||
interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
|
||||
alt?: string;
|
||||
class?: ClassType;
|
||||
dot?: boolean;
|
||||
dotClass?: ClassType;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: 'avatar',
|
||||
as: 'button',
|
||||
dot: false,
|
||||
dotClass: 'bg-green-500',
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
return props.alt.slice(-2).toUpperCase();
|
||||
});
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
return props.size !== undefined && props.size > 0
|
||||
? {
|
||||
height: `${props.size}px`,
|
||||
width: `${props.size}px`,
|
||||
}
|
||||
: {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="props.class"
|
||||
:style="rootStyle"
|
||||
class="relative flex flex-shrink-0 items-center"
|
||||
>
|
||||
<Avatar :class="props.class" class="size-full">
|
||||
<AvatarImage :alt="alt" :src="src" />
|
||||
<AvatarFallback>{{ text }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
v-if="dot"
|
||||
:class="dotClass"
|
||||
class="border-background absolute bottom-0 right-0 size-3 rounded-full border-2"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenAvatar } from './avatar.vue';
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BacktopProps } from './backtop';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ArrowUpToLine } from '@vben-core/icons';
|
||||
|
||||
import { VbenButton } from '../button';
|
||||
import { useBackTop } from './use-backtop';
|
||||
|
||||
interface Props extends BacktopProps {}
|
||||
|
||||
defineOptions({ name: 'BackTop' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
bottom: 20,
|
||||
isGroup: false,
|
||||
right: 24,
|
||||
target: '',
|
||||
visibilityHeight: 200,
|
||||
});
|
||||
|
||||
const backTopStyle = computed(() => ({
|
||||
bottom: `${props.bottom}px`,
|
||||
right: `${props.right}px`,
|
||||
}));
|
||||
|
||||
const { handleClick, visible } = useBackTop(props);
|
||||
</script>
|
||||
<template>
|
||||
<transition name="fade-down">
|
||||
<VbenButton
|
||||
v-if="visible"
|
||||
:style="backTopStyle"
|
||||
class="dark:bg-accent dark:hover:bg-heavy bg-background hover:bg-heavy data shadow-float z-popup fixed bottom-10 size-10 rounded-full duration-500"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
@click="handleClick"
|
||||
>
|
||||
<ArrowUpToLine class="size-4" />
|
||||
</VbenButton>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
export const backtopProps = {
|
||||
/**
|
||||
* @zh_CN bottom distance.
|
||||
*/
|
||||
bottom: {
|
||||
default: 40,
|
||||
type: Number,
|
||||
},
|
||||
/**
|
||||
* @zh_CN right distance.
|
||||
*/
|
||||
right: {
|
||||
default: 40,
|
||||
type: Number,
|
||||
},
|
||||
/**
|
||||
* @zh_CN the target to trigger scroll.
|
||||
*/
|
||||
target: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
/**
|
||||
* @zh_CN the button will not show until the scroll height reaches this value.
|
||||
*/
|
||||
visibilityHeight: {
|
||||
default: 200,
|
||||
type: Number,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface BacktopProps {
|
||||
bottom?: number;
|
||||
isGroup?: boolean;
|
||||
right?: number;
|
||||
target?: string;
|
||||
visibilityHeight?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenBackTop } from './back-top.vue';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BacktopProps } from './backtop';
|
||||
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useEventListener, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
export const useBackTop = (props: BacktopProps) => {
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const container = shallowRef<Document | HTMLElement>();
|
||||
const visible = ref(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (el.value) {
|
||||
visible.value = el.value.scrollTop >= (props?.visibilityHeight ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
el.value?.scrollTo({ behavior: 'smooth', top: 0 });
|
||||
};
|
||||
|
||||
const handleScrollThrottled = useThrottleFn(handleScroll, 300, true);
|
||||
|
||||
useEventListener(container, 'scroll', handleScrollThrottled);
|
||||
onMounted(() => {
|
||||
container.value = document;
|
||||
el.value = document.documentElement;
|
||||
|
||||
if (props.target) {
|
||||
el.value = document.querySelector<HTMLElement>(props.target) ?? undefined;
|
||||
|
||||
if (!el.value) {
|
||||
throw new Error(`target does not exist: ${props.target}`);
|
||||
}
|
||||
container.value = el.value;
|
||||
}
|
||||
// Give visible an initial value, fix #13066
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
return {
|
||||
handleClick,
|
||||
visible,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user