第一基础版

This commit is contained in:
2025-06-08 20:16:51 +08:00
commit 08e79c60e7
1469 changed files with 127477 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# ui-kit
用于管理公共组件、不同UI组件库封装的组件

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

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

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

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

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

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

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

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

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

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

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

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

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

View 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;
/**
* 所有表单项的空状态值,默认都是undefinednaive-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;
};
}

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

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { default as SidebarCollapseButton } from './sidebar-collapse-button.vue';
export { default as SidebarFixedButton } from './sidebar-fixed-button.vue';

View File

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

View File

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

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

View File

@@ -0,0 +1,2 @@
export type * from './vben-layout';
export { default as VbenAdminLayout } from './vben-layout.vue';

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

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

View File

@@ -0,0 +1 @@
# 菜单组件

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

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

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

View File

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

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

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

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

View File

@@ -0,0 +1,2 @@
export type * from './normal-menu';
export { default as NormalMenu } from './normal-menu.vue';

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,2 @@
export * from './use-menu';
export * from './use-menu-context';

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

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

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

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

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

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
export type * from './drawer';
export { default as VbenDrawer } from './drawer.vue';
export { setDefaultDrawerProps, useVbenDrawer } from './use-drawer';

View 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.`,
);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './alert';
export * from './drawer';
export * from './modal';

View File

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

View File

@@ -0,0 +1,3 @@
export type * from './modal';
export { default as VbenModal } from './modal.vue';
export { setDefaultModalProps, useVbenModal } from './use-modal';

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

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

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

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

View 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.`,
);
}
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

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

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

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

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

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenBackTop } from './back-top.vue';

View File

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