更新最新代码
This commit is contained in:
3
packages/@core/ui-kit/README.md
Normal file
3
packages/@core/ui-kit/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ui-kit
|
||||
|
||||
用于管理公共组件、不同UI组件库封装的组件
|
||||
189
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
Normal file
189
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FormApi } from '../src/form-api';
|
||||
|
||||
describe('formApi', () => {
|
||||
let formApi: FormApi;
|
||||
|
||||
beforeEach(() => {
|
||||
formApi = new FormApi();
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(formApi.state).toEqual(
|
||||
expect.objectContaining({
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
commonConfig: {},
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
}),
|
||||
);
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should mount form actions', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
setValues: vi.fn(),
|
||||
submitForm: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.isMounted).toBe(true);
|
||||
expect(formApi.form).toEqual(formActions);
|
||||
});
|
||||
|
||||
it('should get values from form', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should set field value', async () => {
|
||||
const setFieldValueMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
setFieldValue: setFieldValueMock,
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.setFieldValue('name', 'new value');
|
||||
expect(setFieldValueMock).toHaveBeenCalledWith(
|
||||
'name',
|
||||
'new value',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset form', async () => {
|
||||
const resetFormMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: resetFormMock,
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.resetForm();
|
||||
expect(resetFormMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call handleSubmit on submit', async () => {
|
||||
const handleSubmitMock = vi.fn();
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
submitForm: vi.fn().mockResolvedValue(true),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
const state = {
|
||||
handleSubmit: handleSubmitMock,
|
||||
};
|
||||
|
||||
formApi.setState(state);
|
||||
await formApi.mount(formActions);
|
||||
|
||||
const result = await formApi.submitForm();
|
||||
expect(formActions.submitForm).toHaveBeenCalled();
|
||||
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
|
||||
expect(result).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should unmount form and reset state', () => {
|
||||
formApi.unmount();
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate form', async () => {
|
||||
const validateMock = vi.fn().mockResolvedValue(true);
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
validate: validateMock,
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
const isValid = await formApi.validate();
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSchema', () => {
|
||||
let instance: FormApi;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new FormApi();
|
||||
instance.state = {
|
||||
schema: [
|
||||
{ component: 'text', fieldName: 'name' },
|
||||
{ component: 'number', fieldName: 'age', label: 'Age' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('should update the schema correctly when fieldName matches', () => {
|
||||
const newSchema = [
|
||||
{ component: 'text', fieldName: 'name' },
|
||||
{ component: 'number', fieldName: 'age', label: 'Age' },
|
||||
];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.label).toBe('Age');
|
||||
});
|
||||
|
||||
it('should log an error if fieldName is missing in some items', () => {
|
||||
const newSchema: any[] = [
|
||||
{ component: 'textarea', fieldName: 'name' },
|
||||
{ component: 'number' },
|
||||
];
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'All items in the schema array must have a valid `fieldName` property to be updated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update schema if fieldName does not match', () => {
|
||||
const newSchema = [{ component: 'textarea', fieldName: 'unknown' }];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.component).toBe('number');
|
||||
});
|
||||
|
||||
it('should not update schema if updatedMap is empty', () => {
|
||||
const newSchema: any[] = [{ component: 'textarea' }];
|
||||
|
||||
instance.updateSchema(newSchema);
|
||||
|
||||
expect(instance.state?.schema?.[0]?.component).toBe('text');
|
||||
expect(instance.state?.schema?.[1]?.component).toBe('number');
|
||||
});
|
||||
});
|
||||
21
packages/@core/ui-kit/form-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/form-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
1
packages/@core/ui-kit/form-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/form-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
FormCommonConfig,
|
||||
VbenFormAdapterOptions,
|
||||
} from './types';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
VbenButton,
|
||||
VbenCheckbox,
|
||||
Input as VbenInput,
|
||||
VbenInputPassword,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
|
||||
import { defineRule } from 'vee-validate';
|
||||
|
||||
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
|
||||
|
||||
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
|
||||
|
||||
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
|
||||
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
|
||||
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
|
||||
VbenCheckbox,
|
||||
VbenInput,
|
||||
VbenInputPassword,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
};
|
||||
|
||||
export const COMPONENT_BIND_EVENT_MAP: Partial<
|
||||
Record<BaseFormComponentType, string>
|
||||
> = {
|
||||
VbenCheckbox: 'checked',
|
||||
};
|
||||
|
||||
export function setupVbenForm<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
>(options: VbenFormAdapterOptions<T>) {
|
||||
const { config, defineRules } = options;
|
||||
|
||||
const {
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
} = (config || {}) as FormCommonConfig;
|
||||
|
||||
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
});
|
||||
|
||||
if (defineRules) {
|
||||
for (const key of Object.keys(defineRules)) {
|
||||
defineRule(key, defineRules[key as never]);
|
||||
}
|
||||
}
|
||||
|
||||
const baseModelPropName =
|
||||
config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME;
|
||||
const modelPropNameMap = config?.modelPropNameMap as
|
||||
| Record<BaseFormComponentType, string>
|
||||
| undefined;
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
for (const component of Object.keys(components)) {
|
||||
const key = component as BaseFormComponentType;
|
||||
COMPONENT_MAP[key] = components[component as never];
|
||||
|
||||
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
|
||||
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
|
||||
}
|
||||
|
||||
// 覆盖特殊组件的modelPropName
|
||||
if (modelPropNameMap && modelPropNameMap[key]) {
|
||||
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FormRenderProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
|
||||
export const [injectRenderFormProps, provideFormRenderProps] =
|
||||
createContext<FormRenderProps>('FormRenderProps');
|
||||
|
||||
export const useFormContext = () => {
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
|
||||
const isVertical = computed(() => formRenderProps.layout === 'vertical');
|
||||
|
||||
const componentMap = computed(() => formRenderProps.componentMap);
|
||||
const componentBindEventMap = computed(
|
||||
() => formRenderProps.componentBindEventMap,
|
||||
);
|
||||
return {
|
||||
componentBindEventMap,
|
||||
componentMap,
|
||||
isVertical,
|
||||
};
|
||||
};
|
||||
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
FormItemDependencies,
|
||||
FormSchemaRuleType,
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { isBoolean, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { useFormValues } from 'vee-validate';
|
||||
|
||||
import { injectRenderFormProps } from './context';
|
||||
|
||||
export default function useDependencies(
|
||||
getDependencies: () => FormItemDependencies | undefined,
|
||||
) {
|
||||
const values = useFormValues();
|
||||
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const formApi = formRenderProps.form!;
|
||||
|
||||
if (!values) {
|
||||
throw new Error('useDependencies should be used within <VbenForm>');
|
||||
}
|
||||
|
||||
const isIf = ref(true);
|
||||
const isDisabled = ref(false);
|
||||
const isShow = ref(true);
|
||||
const isRequired = ref(false);
|
||||
const dynamicComponentProps = ref<MaybeComponentProps>({});
|
||||
const dynamicRules = ref<FormSchemaRuleType>();
|
||||
|
||||
const triggerFieldValues = computed(() => {
|
||||
// 该字段可能会被多个字段触发
|
||||
const triggerFields = getDependencies()?.triggerFields ?? [];
|
||||
return triggerFields.map((dep) => {
|
||||
return values.value[dep];
|
||||
});
|
||||
});
|
||||
|
||||
const resetConditionState = () => {
|
||||
isDisabled.value = false;
|
||||
isIf.value = true;
|
||||
isShow.value = true;
|
||||
isRequired.value = false;
|
||||
dynamicRules.value = undefined;
|
||||
dynamicComponentProps.value = {};
|
||||
};
|
||||
|
||||
watch(
|
||||
[triggerFieldValues, getDependencies],
|
||||
async ([_values, dependencies]) => {
|
||||
if (!dependencies || !dependencies?.triggerFields?.length) {
|
||||
return;
|
||||
}
|
||||
resetConditionState();
|
||||
const {
|
||||
componentProps,
|
||||
disabled,
|
||||
if: whenIf,
|
||||
required,
|
||||
rules,
|
||||
show,
|
||||
trigger,
|
||||
} = dependencies;
|
||||
|
||||
// 1. 优先判断if,如果if为false,则不渲染dom,后续判断也不再执行
|
||||
const formValues = values.value;
|
||||
|
||||
if (isFunction(whenIf)) {
|
||||
isIf.value = !!(await whenIf(formValues, formApi));
|
||||
// 不渲染
|
||||
if (!isIf.value) return;
|
||||
} else if (isBoolean(whenIf)) {
|
||||
isIf.value = whenIf;
|
||||
if (!isIf.value) return;
|
||||
}
|
||||
|
||||
// 2. 判断show,如果show为false,则隐藏
|
||||
if (isFunction(show)) {
|
||||
isShow.value = !!(await show(formValues, formApi));
|
||||
if (!isShow.value) return;
|
||||
} else if (isBoolean(show)) {
|
||||
isShow.value = show;
|
||||
if (!isShow.value) return;
|
||||
}
|
||||
|
||||
if (isFunction(componentProps)) {
|
||||
dynamicComponentProps.value = await componentProps(formValues, formApi);
|
||||
}
|
||||
|
||||
if (isFunction(rules)) {
|
||||
dynamicRules.value = await rules(formValues, formApi);
|
||||
}
|
||||
|
||||
if (isFunction(disabled)) {
|
||||
isDisabled.value = !!(await disabled(formValues, formApi));
|
||||
} else if (isBoolean(disabled)) {
|
||||
isDisabled.value = disabled;
|
||||
}
|
||||
|
||||
if (isFunction(required)) {
|
||||
isRequired.value = !!(await required(formValues, formApi));
|
||||
}
|
||||
|
||||
if (isFunction(trigger)) {
|
||||
await trigger(formValues, formApi);
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
dynamicComponentProps,
|
||||
dynamicRules,
|
||||
isDisabled,
|
||||
isIf,
|
||||
isRequired,
|
||||
isShow,
|
||||
};
|
||||
}
|
||||
105
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
105
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FormRenderProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints,
|
||||
useElementVisibility,
|
||||
} from '@vueuse/core';
|
||||
|
||||
/**
|
||||
* 动态计算行数
|
||||
*/
|
||||
export function useExpandable(props: FormRenderProps) {
|
||||
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
|
||||
const isVisible = useElementVisibility(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,
|
||||
() => isVisible.value,
|
||||
],
|
||||
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 };
|
||||
}
|
||||
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
AnyZodObject,
|
||||
ZodDefault,
|
||||
ZodEffects,
|
||||
ZodNumber,
|
||||
ZodString,
|
||||
ZodTypeAny,
|
||||
} from 'zod';
|
||||
|
||||
import { isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
/**
|
||||
* Get the lowest level Zod type.
|
||||
* This will unpack optionals, refinements, etc.
|
||||
*/
|
||||
export function getBaseRules<
|
||||
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
|
||||
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
|
||||
if (!schema || isString(schema)) return null;
|
||||
if ('innerType' in schema._def)
|
||||
return getBaseRules(schema._def.innerType as ChildType);
|
||||
|
||||
if ('schema' in schema._def)
|
||||
return getBaseRules(schema._def.schema as ChildType);
|
||||
|
||||
return schema as ChildType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a "ZodDefault" in the Zod stack and return its value.
|
||||
*/
|
||||
export function getDefaultValueInZodStack(schema: ZodTypeAny): any {
|
||||
if (!schema || isString(schema)) {
|
||||
return;
|
||||
}
|
||||
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
|
||||
|
||||
if (typedSchema._def.typeName === 'ZodDefault')
|
||||
return typedSchema._def.defaultValue();
|
||||
|
||||
if ('innerType' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
typedSchema._def.innerType as unknown as ZodTypeAny,
|
||||
);
|
||||
}
|
||||
if ('schema' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
(typedSchema._def as any).schema as ZodTypeAny,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isEventObjectLike(obj: any) {
|
||||
if (!obj || !isObject(obj)) {
|
||||
return false;
|
||||
}
|
||||
return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation');
|
||||
}
|
||||
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FormField } from './form-field.vue';
|
||||
export { default as FormLabel } from './form-label.vue';
|
||||
export { default as Form } from './form.vue';
|
||||
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { setupVbenForm } from './config';
|
||||
|
||||
export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
VbenFormProps,
|
||||
FormSchema as VbenFormSchema,
|
||||
} from './types';
|
||||
|
||||
export * from './use-vben-form';
|
||||
// export { default as VbenForm } from './vben-form.vue';
|
||||
export * as z from 'zod';
|
||||
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
VbenFormProps,
|
||||
} from './types';
|
||||
|
||||
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
import { useStore } from '@vben-core/shared/store';
|
||||
|
||||
import { FormApi } from './form-api';
|
||||
import VbenUseForm from './vben-use-form.vue';
|
||||
|
||||
export function useVbenForm<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
>(options: VbenFormProps<T>) {
|
||||
const IS_REACTIVE = isReactive(options);
|
||||
const api = new FormApi(options);
|
||||
const extendedApi: ExtendedFormApi = api as never;
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Form = defineComponent(
|
||||
(props: VbenFormProps, { attrs, slots }) => {
|
||||
onBeforeUnmount(() => {
|
||||
api.unmount();
|
||||
});
|
||||
api.setState({ ...props, ...attrs });
|
||||
return () =>
|
||||
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
|
||||
},
|
||||
{
|
||||
name: 'VbenUseForm',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
if (IS_REACTIVE) {
|
||||
watch(
|
||||
() => options.schema,
|
||||
() => {
|
||||
api.setState({ schema: options.schema });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
return [Form, extendedApi] as const;
|
||||
}
|
||||
1
packages/@core/ui-kit/form-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/form-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/form-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/form-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src", "__tests__"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
packages/@core/ui-kit/layout-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/layout-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
1
packages/@core/ui-kit/layout-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/layout-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
5
packages/@core/ui-kit/layout-ui/src/components/index.ts
Normal file
5
packages/@core/ui-kit/layout-ui/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutContent } from './layout-content.vue';
|
||||
export { default as LayoutFooter } from './layout-footer.vue';
|
||||
export { default as LayoutHeader } from './layout-header.vue';
|
||||
export { default as LayoutSidebar } from './layout-sidebar.vue';
|
||||
export { default as LayoutTabbar } from './layout-tabbar.vue';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SidebarCollapseButton } from './sidebar-collapse-button.vue';
|
||||
export { default as SidebarFixedButton } from './sidebar-fixed-button.vue';
|
||||
53
packages/@core/ui-kit/layout-ui/src/hooks/use-layout.ts
Normal file
53
packages/@core/ui-kit/layout-ui/src/hooks/use-layout.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { LayoutType } from '@vben-core/typings';
|
||||
|
||||
import type { VbenLayoutProps } from '../vben-layout';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useLayout(props: VbenLayoutProps) {
|
||||
const currentLayout = computed(() =>
|
||||
props.isMobile ? 'sidebar-nav' : (props.layout as LayoutType),
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否全屏显示content,不需要侧边、底部、顶部、tab区域
|
||||
*/
|
||||
const isFullContent = computed(() => currentLayout.value === 'full-content');
|
||||
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
*/
|
||||
const isSidebarMixedNav = computed(
|
||||
() => currentLayout.value === 'sidebar-mixed-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部导航模式
|
||||
*/
|
||||
const isHeaderNav = computed(() => currentLayout.value === 'header-nav');
|
||||
|
||||
/**
|
||||
* 是否为混合导航模式
|
||||
*/
|
||||
const isMixedNav = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部混合模式
|
||||
*/
|
||||
const isHeaderMixedNav = computed(
|
||||
() => currentLayout.value === 'header-mixed-nav',
|
||||
);
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
};
|
||||
}
|
||||
2
packages/@core/ui-kit/layout-ui/src/index.ts
Normal file
2
packages/@core/ui-kit/layout-ui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type * from './vben-layout';
|
||||
export { default as VbenAdminLayout } from './vben-layout.vue';
|
||||
175
packages/@core/ui-kit/layout-ui/src/vben-layout.ts
Normal file
175
packages/@core/ui-kit/layout-ui/src/vben-layout.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
ContentCompactType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
ThemeModeType,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
interface VbenLayoutProps {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
* @default 'wide'
|
||||
*/
|
||||
contentCompact?: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
* @default 1200
|
||||
*/
|
||||
contentCompactWidth?: number;
|
||||
/**
|
||||
* padding
|
||||
* @default 16
|
||||
*/
|
||||
contentPadding?: number;
|
||||
/**
|
||||
* paddingBottom
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
/**
|
||||
* paddingLeft
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingLeft?: number;
|
||||
/**
|
||||
* paddingRight
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingRight?: number;
|
||||
/**
|
||||
* paddingTop
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingTop?: number;
|
||||
/**
|
||||
* footer 是否可见
|
||||
* @default false
|
||||
*/
|
||||
footerEnable?: boolean;
|
||||
/**
|
||||
* footer 是否固定
|
||||
* @default true
|
||||
*/
|
||||
footerFixed?: boolean;
|
||||
/**
|
||||
* footer 高度
|
||||
* @default 32
|
||||
*/
|
||||
footerHeight?: number;
|
||||
|
||||
/**
|
||||
* header高度
|
||||
* @default 48
|
||||
*/
|
||||
headerHeight?: number;
|
||||
/**
|
||||
* 顶栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
headerHidden?: boolean;
|
||||
/**
|
||||
* header 显示模式
|
||||
* @default 'fixed'
|
||||
*/
|
||||
headerMode?: LayoutHeaderModeType;
|
||||
/**
|
||||
* header 顶栏主题
|
||||
*/
|
||||
headerTheme?: ThemeModeType;
|
||||
/**
|
||||
* 是否显示header切换侧边栏按钮
|
||||
* @default
|
||||
*/
|
||||
headerToggleSidebarButton?: boolean;
|
||||
/**
|
||||
* header是否显示
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* 是否移动端显示
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 布局方式
|
||||
* sidebar-nav 侧边菜单布局
|
||||
* header-nav 顶部菜单布局
|
||||
* mixed-nav 侧边&顶部菜单布局
|
||||
* sidebar-mixed-nav 侧边混合菜单布局
|
||||
* full-content 全屏内容布局
|
||||
* @default sidebar-nav
|
||||
*/
|
||||
layout?: LayoutType;
|
||||
/**
|
||||
* 侧边菜单折叠状态
|
||||
* @default false
|
||||
*/
|
||||
sidebarCollapse?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapsedButton?: boolean;
|
||||
/**
|
||||
* 侧边菜单是否折叠时,是否显示title
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapseShowTitle?: boolean;
|
||||
/**
|
||||
* 侧边栏是否可见
|
||||
* @default true
|
||||
*/
|
||||
sidebarEnable?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠额外宽度
|
||||
* @default 48
|
||||
*/
|
||||
sidebarExtraCollapsedWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠按钮是否固定
|
||||
* @default true
|
||||
*/
|
||||
sidebarFixedButton?: boolean;
|
||||
/**
|
||||
* 侧边栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
sidebarHidden?: boolean;
|
||||
/**
|
||||
* 混合侧边栏宽度
|
||||
* @default 80
|
||||
*/
|
||||
sidebarMixedWidth?: number;
|
||||
/**
|
||||
* 侧边栏
|
||||
* @default dark
|
||||
*/
|
||||
sidebarTheme?: ThemeModeType;
|
||||
/**
|
||||
* 侧边栏宽度
|
||||
* @default 210
|
||||
*/
|
||||
sidebarWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* tab是否可见
|
||||
* @default true
|
||||
*/
|
||||
tabbarEnable?: boolean;
|
||||
/**
|
||||
* tab高度
|
||||
* @default 30
|
||||
*/
|
||||
tabbarHeight?: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 100
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
export type { VbenLayoutProps };
|
||||
1
packages/@core/ui-kit/layout-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/layout-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/layout-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/layout-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 菜单组件
|
||||
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
pattern: ['**/*'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RendererElement } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'CollapseTransition',
|
||||
});
|
||||
|
||||
const reset = (el: RendererElement) => {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
};
|
||||
|
||||
const on = {
|
||||
afterEnter(el: RendererElement) {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
},
|
||||
|
||||
afterLeave(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
beforeEnter(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
if (el.style.height) el.dataset.elExistsHeight = el.style.height;
|
||||
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginBottom = 0;
|
||||
},
|
||||
|
||||
beforeLeave(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
enter(el: RendererElement) {
|
||||
requestAnimationFrame(() => {
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
if (el.dataset.elExistsHeight) {
|
||||
el.style.maxHeight = el.dataset.elExistsHeight;
|
||||
} else if (el.scrollHeight === 0) {
|
||||
el.style.maxHeight = 0;
|
||||
} else {
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
el.style.marginTop = el.dataset.oldMarginTop;
|
||||
el.style.marginBottom = el.dataset.oldMarginBottom;
|
||||
el.style.overflow = 'hidden';
|
||||
});
|
||||
},
|
||||
|
||||
enterCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
leave(el: RendererElement) {
|
||||
if (el.scrollHeight !== 0) {
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.marginBottom = 0;
|
||||
}
|
||||
},
|
||||
|
||||
leaveCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="collapse-transition" v-on="on">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
||||
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as MenuBadge } from './menu-badge.vue';
|
||||
export { default as MenuItem } from './menu-item.vue';
|
||||
export { default as Menu } from './menu.vue';
|
||||
export { default as SubMenu } from './sub-menu.vue';
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
interface Props {
|
||||
dotClass?: string;
|
||||
dotStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
dotClass: '',
|
||||
dotStyle: () => ({}),
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative mr-1 flex size-1.5">
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="relative inline-flex size-1.5 rounded-full"
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
||||
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuItemProps, MenuItemRegistered } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { MenuBadge } from '../components';
|
||||
import { useMenu, useMenuContext, useSubMenuContext } from '../hooks';
|
||||
|
||||
interface Props extends MenuItemProps {}
|
||||
|
||||
defineOptions({ name: 'MenuItem' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ click: [MenuItemRegistered] }>();
|
||||
|
||||
const slots = useSlots();
|
||||
const { b, e, is } = useNamespace('menu-item');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
|
||||
const active = computed(() => props.path === rootMenu?.activePath);
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const isTopLevelMenuItem = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
|
||||
const collapseShowTitle = computed(
|
||||
() =>
|
||||
rootMenu.props?.collapseShowTitle &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props.collapse,
|
||||
);
|
||||
|
||||
const showTooltip = computed(
|
||||
() =>
|
||||
rootMenu.props.mode === 'vertical' &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props?.collapse &&
|
||||
slots.title,
|
||||
);
|
||||
|
||||
const item: MenuItemRegistered = reactive({
|
||||
active,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单项点击事件
|
||||
*/
|
||||
function handleClick() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
rootMenu?.handleMenuItemClick?.({
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
emit('click', item);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addMenuItem?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeMenuItem?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
rootMenu.theme,
|
||||
b(),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
is('collapse-show-title', collapseShowTitle),
|
||||
]"
|
||||
role="menuitem"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<VbenTooltip
|
||||
v-if="showTooltip"
|
||||
:content-class="[rootMenu.theme]"
|
||||
side="right"
|
||||
>
|
||||
<template #trigger>
|
||||
<div :class="[nsMenu.be('tooltip', 'trigger')]">
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" fallback />
|
||||
<slot></slot>
|
||||
<span v-if="collapseShowTitle" :class="nsMenu.e('name')">
|
||||
<slot name="title"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="title"></slot>
|
||||
</VbenTooltip>
|
||||
<div v-show="!showTooltip" :class="[e('content')]">
|
||||
<MenuBadge
|
||||
v-if="rootMenu.props.mode !== 'horizontal'"
|
||||
class="right-2"
|
||||
v-bind="props"
|
||||
/>
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
|
||||
<slot></slot>
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
872
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
872
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
@@ -0,0 +1,872 @@
|
||||
<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 { useMenuScroll } from '../hooks/use-menu-scroll';
|
||||
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',
|
||||
scrollToActive: false,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const enableScroll = computed(
|
||||
() => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
|
||||
);
|
||||
|
||||
const { scrollToActiveItem } = useMenuScroll(activePath, {
|
||||
enable: enableScroll,
|
||||
delay: 320,
|
||||
});
|
||||
|
||||
// 监听 activePath 变化,自动滚动到激活项
|
||||
watch(activePath, () => {
|
||||
scrollToActiveItem();
|
||||
});
|
||||
|
||||
// 默认展开菜单
|
||||
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);
|
||||
}
|
||||
|
||||
function getActivePaths() {
|
||||
const activeItem = activePath.value && items.value[activePath.value];
|
||||
|
||||
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeItem.parentPaths;
|
||||
}
|
||||
</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);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
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;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&.is-active {
|
||||
fill: var(--menu-item-active-color);
|
||||
|
||||
@include menu-item-active;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--menu-item-height);
|
||||
|
||||
span {
|
||||
@include menu-title;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
flex-direction: column;
|
||||
}
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__name {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: var(--menu-item-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-hover-background-color) !important;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--menu-item-padding-x);
|
||||
font-size: var(--menu-font-size);
|
||||
line-height: var(--menu-item-height);
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--menu-submenu-background-color);
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
&.is-active {
|
||||
div[data-state='open'] > .#{$namespace}-sub-menu-content,
|
||||
> .#{$namespace}-sub-menu-content {
|
||||
// font-weight: 500;
|
||||
color: var(--menu-submenu-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-active-background-color);
|
||||
fill: var(--menu-submenu-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content {
|
||||
height: var(--menu-item-height);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
width: inherit;
|
||||
margin-top: -8px;
|
||||
margin-right: 0;
|
||||
// font-size: 16px;
|
||||
font-weight: normal;
|
||||
opacity: 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include menu-title;
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
flex-direction: column;
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.#{$namespace}-sub-menu-content__title {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-more {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
// &:not(.is-active):hover {
|
||||
&:hover {
|
||||
color: var(--menu-submenu-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-hover-background-color) !important;
|
||||
|
||||
// svg {
|
||||
// fill: var(--menu-submenu-hover-color);
|
||||
// }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export type * from './normal-menu';
|
||||
export { default as NormalMenu } from './normal-menu.vue';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
interface NormalMenuProps {
|
||||
/**
|
||||
* 菜单数据
|
||||
*/
|
||||
activePath?: string;
|
||||
/**
|
||||
* 是否折叠
|
||||
*/
|
||||
collapse?: boolean;
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menus?: MenuRecordRaw[];
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export type { NormalMenuProps };
|
||||
@@ -0,0 +1,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>
|
||||
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-menu';
|
||||
export * from './use-menu-context';
|
||||
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { MenuProvider, SubMenuProvider } from '../types';
|
||||
|
||||
import { getCurrentInstance, inject, provide } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
const menuContextKey = Symbol('menuContext');
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createMenuContext(injectMenuData: MenuProvider) {
|
||||
provide(menuContextKey, injectMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createSubMenuContext(injectSubMenuData: SubMenuProvider) {
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
provide(`subMenu:${instance?.uid}`, injectSubMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const rootMenu = inject(menuContextKey) as MenuProvider;
|
||||
return rootMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useSubMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
|
||||
return subMenu;
|
||||
}
|
||||
|
||||
export {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuContext,
|
||||
useSubMenuContext,
|
||||
};
|
||||
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal file
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
interface UseMenuScrollOptions {
|
||||
delay?: number;
|
||||
enable?: boolean | Ref<boolean>;
|
||||
}
|
||||
|
||||
export function useMenuScroll(
|
||||
activePath: Ref<string | undefined>,
|
||||
options: UseMenuScrollOptions = {},
|
||||
) {
|
||||
const { enable = true, delay = 320 } = options;
|
||||
|
||||
function scrollToActiveItem() {
|
||||
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
|
||||
if (!isEnabled) return;
|
||||
|
||||
const activeElement = document.querySelector(
|
||||
`aside li[role=menuitem].is-active`,
|
||||
);
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedScroll = useDebounceFn(scrollToActiveItem, delay);
|
||||
|
||||
watch(activePath, () => {
|
||||
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
|
||||
if (!isEnabled) return;
|
||||
|
||||
debouncedScroll();
|
||||
});
|
||||
|
||||
return {
|
||||
scrollToActiveItem,
|
||||
};
|
||||
}
|
||||
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { SubMenuProvider } from '../types';
|
||||
|
||||
import { computed, getCurrentInstance } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
function useMenu() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 获取所有父级菜单链路
|
||||
*/
|
||||
const parentPaths = computed(() => {
|
||||
let parent = instance.parent;
|
||||
const paths: string[] = [instance.props.path as string];
|
||||
while (parent?.type.name !== 'Menu') {
|
||||
if (parent?.props.path) {
|
||||
paths.unshift(parent.props.path as string);
|
||||
}
|
||||
parent = parent?.parent ?? null;
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const parentMenu = computed(() => {
|
||||
return findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
});
|
||||
|
||||
return {
|
||||
parentMenu,
|
||||
parentPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function useMenuStyle(menu?: SubMenuProvider) {
|
||||
const subMenuStyle = computed(() => {
|
||||
return {
|
||||
'--menu-level': menu ? (menu?.level ?? 0 + 1) : 0,
|
||||
};
|
||||
});
|
||||
return subMenuStyle;
|
||||
}
|
||||
|
||||
export { useMenu, useMenuStyle };
|
||||
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as MenuBadge } from './components/menu-badge.vue';
|
||||
export * from './components/normal-menu';
|
||||
export { default as Menu } from './menu.vue';
|
||||
export type * from './types';
|
||||
32
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
32
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<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,
|
||||
});
|
||||
|
||||
const forward = useForwardProps(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu v-bind="forward">
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<SubMenu :menu="menu" />
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { MenuBadge, MenuItem, SubMenu as SubMenuComp } from './components';
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menu: MenuRecordRaw;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'SubMenuUi',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
/**
|
||||
* 判断是否有子节点,动态渲染 menu-item/sub-menu-item
|
||||
*/
|
||||
const hasChildren = computed(() => {
|
||||
const { menu } = props;
|
||||
return (
|
||||
Reflect.has(menu, 'children') && !!menu.children && menu.children.length > 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuItem
|
||||
v-if="!hasChildren"
|
||||
:key="menu.path"
|
||||
:active-icon="menu.activeIcon"
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
</MenuItem>
|
||||
<SubMenuComp
|
||||
v-else
|
||||
:key="`${menu.path}_sub`"
|
||||
:active-icon="menu.activeIcon"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #content>
|
||||
<MenuBadge
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
class="right-6"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
<template v-for="childItem in menu.children || []" :key="childItem.path">
|
||||
<SubMenu :menu="childItem" />
|
||||
</template>
|
||||
</SubMenuComp>
|
||||
</template>
|
||||
145
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
145
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 false
|
||||
*/
|
||||
scrollToActive?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单主题
|
||||
* @default dark
|
||||
*/
|
||||
theme?: ThemeModeType;
|
||||
}
|
||||
|
||||
interface SubMenuProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 激活图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN submenu 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN menuitem 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemRegistered {
|
||||
active: boolean;
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemClicked {
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuProvider {
|
||||
activePath?: string;
|
||||
addMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
closeMenu: (path: string, parentLinks: string[]) => void;
|
||||
handleMenuItemClick: (item: MenuItemClicked) => void;
|
||||
handleSubMenuClick: (subMenu: MenuItemRegistered) => void;
|
||||
isMenuPopup: boolean;
|
||||
items: Record<string, MenuItemRegistered>;
|
||||
|
||||
openedMenus: string[];
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
props: MenuProps;
|
||||
removeMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
|
||||
subMenus: Record<string, MenuItemRegistered>;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
interface SubMenuProvider {
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
handleMouseleave?: (deepDispatch: boolean) => void;
|
||||
level: number;
|
||||
mouseInChild: Ref<boolean>;
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
MenuItemClicked,
|
||||
MenuItemProps,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
SubMenuProps,
|
||||
SubMenuProvider,
|
||||
};
|
||||
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ComponentInternalInstance,
|
||||
VNode,
|
||||
VNodeChild,
|
||||
VNodeNormalizedChildren,
|
||||
} from 'vue';
|
||||
|
||||
import { isVNode } from 'vue';
|
||||
|
||||
type VNodeChildAtom = Exclude<VNodeChild, Array<any>>;
|
||||
type RawSlots = Exclude<VNodeNormalizedChildren, Array<any> | null | string>;
|
||||
|
||||
type FlattenVNodes = Array<RawSlots | VNodeChildAtom>;
|
||||
|
||||
/**
|
||||
* @zh_CN Find the parent component upward
|
||||
* @param instance
|
||||
* @param parentNames
|
||||
*/
|
||||
function findComponentUpward(
|
||||
instance: ComponentInternalInstance,
|
||||
parentNames: string[],
|
||||
) {
|
||||
let parent = instance.parent;
|
||||
while (parent && !parentNames.includes(parent?.type?.name ?? '')) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
const flattedChildren = (
|
||||
children: FlattenVNodes | VNode | VNodeNormalizedChildren,
|
||||
): FlattenVNodes => {
|
||||
const vNodes = Array.isArray(children) ? children : [children];
|
||||
const result: FlattenVNodes = [];
|
||||
|
||||
vNodes.forEach((child) => {
|
||||
if (Array.isArray(child)) {
|
||||
result.push(...flattedChildren(child));
|
||||
} else if (isVNode(child) && Array.isArray(child.children)) {
|
||||
result.push(...flattedChildren(child.children));
|
||||
} else {
|
||||
result.push(child);
|
||||
if (isVNode(child) && child.component?.subTree) {
|
||||
result.push(...flattedChildren(child.component.subTree));
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export { findComponentUpward, flattedChildren };
|
||||
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
48
packages/@core/ui-kit/popup-ui/package.json
Normal file
48
packages/@core/ui-kit/popup-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/popup-ui",
|
||||
"version": "5.2.1",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/popup-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
99
packages/@core/ui-kit/popup-ui/src/alert/alert.ts
Normal file
99
packages/@core/ui-kit/popup-ui/src/alert/alert.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Component, VNode, VNodeArrayChildren } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
|
||||
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
|
||||
|
||||
export type BeforeCloseScope = {
|
||||
isConfirm: boolean;
|
||||
};
|
||||
|
||||
export type AlertProps = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (
|
||||
scope: BeforeCloseScope,
|
||||
) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 边框 */
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 按钮对齐方式
|
||||
* @default 'end'
|
||||
*/
|
||||
buttonAlign?: 'center' | 'end' | 'start';
|
||||
/** 取消按钮的标题 */
|
||||
cancelText?: string;
|
||||
/** 是否居中显示 */
|
||||
centered?: boolean;
|
||||
/** 确认按钮的标题 */
|
||||
confirmText?: string;
|
||||
/** 弹窗容器的额外样式 */
|
||||
containerClass?: string;
|
||||
/** 弹窗提示内容 */
|
||||
content: Component | string;
|
||||
/** 弹窗内容的额外样式 */
|
||||
contentClass?: string;
|
||||
/** 执行beforeClose回调期间,在内容区域显示一个loading遮罩*/
|
||||
contentMasking?: boolean;
|
||||
/** 弹窗底部内容(与按钮在同一个容器中) */
|
||||
footer?: Component | string;
|
||||
/** 弹窗的图标(在标题的前面) */
|
||||
icon?: Component | IconType;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/** 是否显示取消按钮 */
|
||||
showCancel?: boolean;
|
||||
/** 弹窗标题 */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** Prompt属性 */
|
||||
export type PromptProps<T = any> = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (scope: {
|
||||
isConfirm: boolean;
|
||||
value: T | undefined;
|
||||
}) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 用于接受用户输入的组件 */
|
||||
component?: Component;
|
||||
/** 输入组件的属性 */
|
||||
componentProps?: Recordable<any>;
|
||||
/** 输入组件的插槽 */
|
||||
componentSlots?:
|
||||
| (() => any)
|
||||
| Recordable<unknown>
|
||||
| VNode
|
||||
| VNodeArrayChildren;
|
||||
/** 默认值 */
|
||||
defaultValue?: T;
|
||||
/** 输入组件的值属性名 */
|
||||
modelPropName?: string;
|
||||
} & Omit<AlertProps, 'beforeClose'>;
|
||||
|
||||
/**
|
||||
* Alert上下文
|
||||
*/
|
||||
export type AlertContext = {
|
||||
/** 执行取消操作 */
|
||||
doCancel: () => void;
|
||||
/** 执行确认操作 */
|
||||
doConfirm: () => void;
|
||||
};
|
||||
|
||||
export const [injectAlertContext, provideAlertContext] =
|
||||
createContext<AlertContext>('VbenAlertContext');
|
||||
|
||||
/**
|
||||
* 获取Alert上下文
|
||||
* @returns AlertContext
|
||||
*/
|
||||
export function useAlertContext() {
|
||||
const context = injectAlertContext();
|
||||
if (!context) {
|
||||
throw new Error('useAlertContext must be used within an AlertProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
14
packages/@core/ui-kit/popup-ui/src/alert/index.ts
Normal file
14
packages/@core/ui-kit/popup-ui/src/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AlertProps,
|
||||
BeforeCloseScope,
|
||||
IconType,
|
||||
PromptProps,
|
||||
} from './alert';
|
||||
export { useAlertContext } from './alert';
|
||||
export { default as Alert } from './alert.vue';
|
||||
export {
|
||||
vbenAlert as alert,
|
||||
clearAllAlerts,
|
||||
vbenConfirm as confirm,
|
||||
vbenPrompt as prompt,
|
||||
} from './AlertBuilder';
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { DrawerState } from '../drawer';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DrawerApi } from '../drawer-api';
|
||||
|
||||
// 模拟 Store 类
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: DrawerState;
|
||||
|
||||
private options: any;
|
||||
|
||||
constructor(initialState: DrawerState, options: any) {
|
||||
this._state = initialState;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
batch(cb: () => void) {
|
||||
cb();
|
||||
}
|
||||
|
||||
setState(fn: (prev: DrawerState) => DrawerState) {
|
||||
this._state = fn(this._state);
|
||||
this.options.onUpdate();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('drawerApi', () => {
|
||||
let drawerApi: DrawerApi;
|
||||
let drawerState: DrawerState;
|
||||
|
||||
beforeEach(() => {
|
||||
drawerApi = new DrawerApi();
|
||||
drawerState = drawerApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(drawerState.isOpen).toBe(false);
|
||||
expect(drawerState.cancelText).toBe(undefined);
|
||||
expect(drawerState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the drawer', () => {
|
||||
drawerApi.open();
|
||||
expect(drawerApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the drawer if onBeforeClose allows it', () => {
|
||||
drawerApi.close();
|
||||
expect(drawerApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the drawer if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const drawerApiWithHook = new DrawerApi({ onBeforeClose });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.close();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and keep drawer open if onCancel is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onCancel });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true); // 关闭逻辑不在 onCancel 内
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
drawerApi.setData(testData);
|
||||
expect(drawerApi.getData()).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should set state correctly using an object', () => {
|
||||
drawerApi.setState({ title: 'New Title' });
|
||||
expect(drawerApi.store.state.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
drawerApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(drawerApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpenChange });
|
||||
drawerApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onClosed });
|
||||
drawerApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpened });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
183
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal file
183
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { DrawerApiOptions, DrawerState } from './drawer';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class DrawerApi {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', any> = {
|
||||
payload: {},
|
||||
};
|
||||
public store: Store<DrawerState>;
|
||||
|
||||
private api: Pick<
|
||||
DrawerApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: DrawerState;
|
||||
private state!: DrawerState;
|
||||
|
||||
constructor(options: DrawerApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: DrawerState = {
|
||||
class: '',
|
||||
closable: true,
|
||||
closeIconPlacement: 'right',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
footer: true,
|
||||
header: true,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
placement: 'right',
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
submitting: false,
|
||||
title: '',
|
||||
};
|
||||
|
||||
this.store = new Store<DrawerState>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
const state = this.store.state;
|
||||
if (state?.isOpen === this.state?.isOpen) {
|
||||
this.state = state;
|
||||
} else {
|
||||
this.state = state;
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
this.state = this.store.state;
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭抽屉
|
||||
* @description 关闭抽屉时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData<T extends object = Record<string, any>>() {
|
||||
return (this.sharedData?.payload ?? {}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定抽屉状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked: boolean = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({ ...prev, isOpen: true }));
|
||||
}
|
||||
|
||||
setData<T>(payload: T) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: DrawerState) => Partial<DrawerState>)
|
||||
| Partial<DrawerState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除抽屉的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
179
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal file
179
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { ClassType, MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { DrawerApi } from './drawer-api';
|
||||
|
||||
export type DrawerPlacement = 'bottom' | 'left' | 'right' | 'top';
|
||||
|
||||
export type CloseIconPlacement = 'left' | 'right';
|
||||
|
||||
export interface DrawerProps {
|
||||
/**
|
||||
* 是否挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
class?: ClassType;
|
||||
/**
|
||||
* 是否显示关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 关闭按钮的位置
|
||||
*/
|
||||
closeIconPlacement?: CloseIconPlacement;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 在关闭时销毁抽屉
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @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;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './drawer';
|
||||
export { default as VbenDrawer } from './drawer.vue';
|
||||
export { setDefaultDrawerProps, useVbenDrawer } from './use-drawer';
|
||||
142
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal file
142
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenParentDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
return [Drawer, extendedApi] as const;
|
||||
}
|
||||
|
||||
async function checkProps(api: ExtendedDrawerApi, attrs: Record<string, any>) {
|
||||
if (!attrs || Object.keys(attrs).length === 0) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Drawer的props,会造成复杂度提升,如果你需要修改Drawer的props,请使用 useVbenDrawer 或者api
|
||||
console.warn(
|
||||
`[Vben Drawer]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Drawer, please use useVbenDrawer or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './alert';
|
||||
export * from './drawer';
|
||||
export * from './modal';
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ModalState } from '../modal';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ModalApi } from '../modal-api';
|
||||
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: ModalState;
|
||||
|
||||
private options: any;
|
||||
|
||||
constructor(initialState: ModalState, options: any) {
|
||||
this._state = initialState;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
batch(cb: () => void) {
|
||||
cb();
|
||||
}
|
||||
|
||||
setState(fn: (prev: ModalState) => ModalState) {
|
||||
this._state = fn(this._state);
|
||||
this.options.onUpdate();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('modalApi', () => {
|
||||
let modalApi: ModalApi;
|
||||
// 使用 modalState 而不是 state
|
||||
let modalState: ModalState;
|
||||
|
||||
beforeEach(() => {
|
||||
modalApi = new ModalApi();
|
||||
// 获取 modalApi 内的 state
|
||||
modalState = modalApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(modalState.isOpen).toBe(false);
|
||||
expect(modalState.cancelText).toBe(undefined);
|
||||
expect(modalState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the modal', () => {
|
||||
modalApi.open();
|
||||
expect(modalApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the modal if onBeforeClose allows it', () => {
|
||||
modalApi.close();
|
||||
expect(modalApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the modal if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const modalApiWithHook = new ModalApi({ onBeforeClose });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.close();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and close the modal if no onCancel hook is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onCancel });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
modalApi.setData(testData);
|
||||
expect(modalApi.getData()).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should set state correctly using an object', () => {
|
||||
modalApi.setState({ title: 'New Title' });
|
||||
expect(modalApi.store.state.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
modalApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(modalApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpenChange });
|
||||
modalApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onClosed });
|
||||
modalApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpened });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './modal';
|
||||
export { default as VbenModal } from './modal.vue';
|
||||
export { setDefaultModalProps, useVbenModal } from './use-modal';
|
||||
151
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal file
151
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenParentModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const onClosed = mergedOptions.onClosed;
|
||||
mergedOptions.onClosed = () => {
|
||||
onClosed?.();
|
||||
if (mergedOptions.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,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
|
||||
return [Modal, extendedApi] as const;
|
||||
}
|
||||
|
||||
async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) {
|
||||
if (!attrs || Object.keys(attrs).length === 0) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Modal的props,会造成复杂度提升,如果你需要修改Modal的props,请使用 useModal 或者api
|
||||
console.warn(
|
||||
`[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useVbenModal or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
27
packages/@core/ui-kit/shadcn-ui/build.config.ts
Normal file
27
packages/@core/ui-kit/shadcn-ui/build.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
|
||||
pattern: ['**/*'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
16
packages/@core/ui-kit/shadcn-ui/components.json
Normal file
16
packages/@core/ui-kit/shadcn-ui/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.mjs",
|
||||
"css": "src/assets/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@vben-core/shadcn-ui/components",
|
||||
"utils": "@vben-core/shared/utils"
|
||||
}
|
||||
}
|
||||
1
packages/@core/ui-kit/shadcn-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/shadcn-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenAvatar } from './avatar.vue';
|
||||
@@ -0,0 +1,38 @@
|
||||
export const backtopProps = {
|
||||
/**
|
||||
* @zh_CN bottom distance.
|
||||
*/
|
||||
bottom: {
|
||||
default: 40,
|
||||
type: Number,
|
||||
},
|
||||
/**
|
||||
* @zh_CN right distance.
|
||||
*/
|
||||
right: {
|
||||
default: 40,
|
||||
type: Number,
|
||||
},
|
||||
/**
|
||||
* @zh_CN the target to trigger scroll.
|
||||
*/
|
||||
target: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
/**
|
||||
* @zh_CN the button will not show until the scroll height reaches this value.
|
||||
*/
|
||||
visibilityHeight: {
|
||||
default: 200,
|
||||
type: Number,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface BacktopProps {
|
||||
bottom?: number;
|
||||
isGroup?: boolean;
|
||||
right?: number;
|
||||
target?: string;
|
||||
visibilityHeight?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenBackTop } from './back-top.vue';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BacktopProps } from './backtop';
|
||||
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useEventListener, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
export const useBackTop = (props: BacktopProps) => {
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const container = shallowRef<Document | HTMLElement>();
|
||||
const visible = ref(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (el.value) {
|
||||
visible.value = el.value.scrollTop >= (props?.visibilityHeight ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
el.value?.scrollTo({ behavior: 'smooth', top: 0 });
|
||||
};
|
||||
|
||||
const handleScrollThrottled = useThrottleFn(handleScroll, 300, true);
|
||||
|
||||
useEventListener(container, 'scroll', handleScrollThrottled);
|
||||
onMounted(() => {
|
||||
container.value = document;
|
||||
el.value = document.documentElement;
|
||||
|
||||
if (props.target) {
|
||||
el.value = document.querySelector<HTMLElement>(props.target) ?? undefined;
|
||||
|
||||
if (!el.value) {
|
||||
throw new Error(`target does not exist: ${props.target}`);
|
||||
}
|
||||
container.value = el.value;
|
||||
}
|
||||
// Give visible an initial value, fix #13066
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
return {
|
||||
handleClick,
|
||||
visible,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BreadcrumbProps } from './types';
|
||||
|
||||
import { ChevronDown } from '@vben-core/icons';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui';
|
||||
import { VbenIcon } from '../icon';
|
||||
|
||||
interface Props extends BreadcrumbProps {}
|
||||
|
||||
defineOptions({ name: 'Breadcrumb' });
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ select: [string] }>();
|
||||
|
||||
function handleClick(path?: string) {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
emit('select', path);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<TransitionGroup name="breadcrumb-transition">
|
||||
<template
|
||||
v-for="(item, index) in breadcrumbs"
|
||||
:key="`${item.path}-${item.title}-${index}`"
|
||||
>
|
||||
<BreadcrumbItem>
|
||||
<div v-if="item.items?.length ?? 0 > 0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="flex items-center gap-1">
|
||||
<VbenIcon v-if="showIcon" :icon="item.icon" class="size-5" />
|
||||
{{ item.title }}
|
||||
<ChevronDown class="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<template
|
||||
v-for="menuItem in item.items"
|
||||
:key="`sub-${menuItem.path}`"
|
||||
>
|
||||
<DropdownMenuItem @click.stop="handleClick(menuItem.path)">
|
||||
{{ menuItem.title }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<BreadcrumbLink
|
||||
v-else-if="index !== breadcrumbs.length - 1"
|
||||
href="javascript:void 0"
|
||||
@click.stop="handleClick(item.path)"
|
||||
>
|
||||
<div class="flex-center">
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:class="{ 'size-5': item.isHome }"
|
||||
:icon="item.icon"
|
||||
class="mr-1 size-4"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbPage v-else>
|
||||
<div class="flex-center">
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:class="{ 'size-5': item.isHome }"
|
||||
:icon="item.icon"
|
||||
class="mr-1 size-4"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbSeparator
|
||||
v-if="index < breadcrumbs.length - 1 && !item.isHome"
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as VbenBreadcrumbView } from './breadcrumb-view.vue';
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { BreadcrumbStyleType } from '@vben-core/typings';
|
||||
|
||||
export interface IBreadcrumb {
|
||||
icon?: Component | string;
|
||||
isHome?: boolean;
|
||||
items?: IBreadcrumb[];
|
||||
path?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface BreadcrumbProps {
|
||||
breadcrumbs: IBreadcrumb[];
|
||||
showIcon?: boolean;
|
||||
styleType?: BreadcrumbStyleType;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
defineOptions({ name: 'VbenButtonGroup' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
border?: boolean;
|
||||
gap?: number;
|
||||
size?: 'large' | 'middle' | 'small';
|
||||
}>(),
|
||||
{ border: false, gap: 0, size: 'middle' },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'vben-button-group rounded-md',
|
||||
`size-${size}`,
|
||||
gap ? 'with-gap' : 'no-gap',
|
||||
$attrs.class as string,
|
||||
)
|
||||
"
|
||||
:style="{ gap: gap ? `${gap}px` : '0px' }"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vben-button-group {
|
||||
display: inline-flex;
|
||||
|
||||
&.size-large :deep(button) {
|
||||
height: 2.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
.icon-wrapper {
|
||||
margin-right: 0.4rem;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.size-middle :deep(button) {
|
||||
height: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
|
||||
.icon-wrapper {
|
||||
margin-right: 0.2rem;
|
||||
|
||||
svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.size-small :deep(button) {
|
||||
height: 1.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 0.75rem;
|
||||
|
||||
.icon-wrapper {
|
||||
margin-right: 0.1rem;
|
||||
|
||||
svg {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.no-gap > :deep(button):nth-of-type(1) {
|
||||
border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
&.no-gap > :deep(button):last-of-type {
|
||||
border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
|
||||
}
|
||||
|
||||
&.no-gap {
|
||||
:deep(button + button) {
|
||||
border-left-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Arrayable } from '@vueuse/core';
|
||||
|
||||
import type { ValueType, VbenButtonGroupProps } from './button';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Circle, CircleCheckBig, LoaderCircle } from '@vben-core/icons';
|
||||
import { cn, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { objectOmit } from '@vueuse/core';
|
||||
|
||||
import { VbenRenderContent } from '../render-content';
|
||||
import VbenButtonGroup from './button-group.vue';
|
||||
import Button from './button.vue';
|
||||
|
||||
const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
|
||||
gap: 0,
|
||||
multiple: false,
|
||||
showIcon: true,
|
||||
size: 'middle',
|
||||
allowClear: false,
|
||||
maxCount: 0,
|
||||
});
|
||||
const emit = defineEmits(['btnClick']);
|
||||
const btnDefaultProps = computed(() => {
|
||||
return {
|
||||
...objectOmit(props, ['options', 'btnClass', 'size', 'disabled']),
|
||||
class: cn(props.btnClass),
|
||||
};
|
||||
});
|
||||
const modelValue = defineModel<Arrayable<ValueType> | undefined>();
|
||||
|
||||
const innerValue = ref<Array<ValueType>>([]);
|
||||
const loadingValues = ref<Array<ValueType>>([]);
|
||||
watch(
|
||||
() => props.multiple,
|
||||
(val) => {
|
||||
if (val) {
|
||||
modelValue.value = innerValue.value;
|
||||
} else {
|
||||
modelValue.value =
|
||||
innerValue.value.length > 0 ? innerValue.value[0] : undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(val) => {
|
||||
if (Array.isArray(val)) {
|
||||
const arrVal = val.filter((v) => v !== undefined);
|
||||
if (arrVal.length > 0) {
|
||||
innerValue.value = props.multiple
|
||||
? [...arrVal]
|
||||
: [arrVal[0] as ValueType];
|
||||
} else {
|
||||
innerValue.value = [];
|
||||
}
|
||||
} else {
|
||||
innerValue.value = val === undefined ? [] : [val as ValueType];
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
async function onBtnClick(value: ValueType) {
|
||||
if (props.beforeChange && isFunction(props.beforeChange)) {
|
||||
try {
|
||||
loadingValues.value.push(value);
|
||||
const canChange = await props.beforeChange(
|
||||
value,
|
||||
!innerValue.value.includes(value),
|
||||
);
|
||||
if (canChange === false) {
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
loadingValues.value.splice(loadingValues.value.indexOf(value), 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
if (innerValue.value.includes(value)) {
|
||||
innerValue.value = innerValue.value.filter((item) => item !== value);
|
||||
} else {
|
||||
if (props.maxCount > 0 && innerValue.value.length >= props.maxCount) {
|
||||
innerValue.value = innerValue.value.slice(0, props.maxCount - 1);
|
||||
}
|
||||
innerValue.value.push(value);
|
||||
}
|
||||
modelValue.value = innerValue.value;
|
||||
} else {
|
||||
if (props.allowClear && innerValue.value.includes(value)) {
|
||||
innerValue.value = [];
|
||||
modelValue.value = undefined;
|
||||
emit('btnClick', undefined);
|
||||
return;
|
||||
} else {
|
||||
innerValue.value = [value];
|
||||
modelValue.value = value;
|
||||
}
|
||||
}
|
||||
emit('btnClick', value);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VbenButtonGroup
|
||||
:size="props.size"
|
||||
:gap="props.gap"
|
||||
class="vben-check-button-group"
|
||||
>
|
||||
<Button
|
||||
v-for="(btn, index) in props.options"
|
||||
:key="index"
|
||||
:class="cn('border', props.btnClass)"
|
||||
:disabled="
|
||||
props.disabled ||
|
||||
loadingValues.includes(btn.value) ||
|
||||
(!props.multiple && loadingValues.length > 0)
|
||||
"
|
||||
v-bind="btnDefaultProps"
|
||||
:variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
|
||||
@click="onBtnClick(btn.value)"
|
||||
type="button"
|
||||
>
|
||||
<div class="icon-wrapper" v-if="props.showIcon">
|
||||
<slot
|
||||
name="icon"
|
||||
:loading="loadingValues.includes(btn.value)"
|
||||
:checked="innerValue.includes(btn.value)"
|
||||
>
|
||||
<LoaderCircle
|
||||
class="animate-spin"
|
||||
v-if="loadingValues.includes(btn.value)"
|
||||
/>
|
||||
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
|
||||
<Circle v-else />
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="option" :label="btn.label" :value="btn.value" :data="btn">
|
||||
<VbenRenderContent :content="btn.label" />
|
||||
</slot>
|
||||
</Button>
|
||||
</VbenButtonGroup>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.vben-check-button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:deep(.size-large) button {
|
||||
.icon-wrapper {
|
||||
margin-right: 0.3rem;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:deep(.size-middle) button {
|
||||
.icon-wrapper {
|
||||
margin-right: 0.2rem;
|
||||
|
||||
svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:deep(.size-small) button {
|
||||
.icon-wrapper {
|
||||
margin-right: 0.1rem;
|
||||
|
||||
svg {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.no-gap > :deep(button):nth-of-type(1) {
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
&.no-gap {
|
||||
:deep(button + button) {
|
||||
margin-right: -1px;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { ButtonVariants } from '../../ui';
|
||||
import type { VbenButtonProps } from './button';
|
||||
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { VbenTooltip } from '../tooltip';
|
||||
import VbenButton from './button.vue';
|
||||
|
||||
interface Props extends VbenButtonProps {
|
||||
class?: any;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
tooltip?: string;
|
||||
tooltipDelayDuration?: number;
|
||||
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
|
||||
variant?: ButtonVariants;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
onClick: () => {},
|
||||
tooltipDelayDuration: 200,
|
||||
tooltipSide: 'bottom',
|
||||
variant: 'icon',
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenButton
|
||||
v-if="!showTooltip"
|
||||
:class="cn('rounded-full', props.class)"
|
||||
:disabled="disabled"
|
||||
:variant="variant"
|
||||
size="icon"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</VbenButton>
|
||||
|
||||
<VbenTooltip
|
||||
v-else
|
||||
:delay-duration="tooltipDelayDuration"
|
||||
:side="tooltipSide"
|
||||
>
|
||||
<template #trigger>
|
||||
<VbenButton
|
||||
:class="cn('rounded-full', props.class)"
|
||||
:disabled="disabled"
|
||||
:variant="variant"
|
||||
size="icon"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</VbenButton>
|
||||
</template>
|
||||
<slot v-if="slots.tooltip" name="tooltip"> </slot>
|
||||
<template v-else>
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export type * from './button';
|
||||
export { default as VbenButtonGroup } from './button-group.vue';
|
||||
export { default as VbenButton } from './button.vue';
|
||||
export { default as VbenCheckButtonGroup } from './check-button-group.vue';
|
||||
export { default as VbenIconButton } from './icon-button.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenCheckbox } from './checkbox.vue';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as VbenContextMenu } from './context-menu.vue';
|
||||
|
||||
export type * from './interface';
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
interface IContextMenuItem {
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 点击事件处理
|
||||
* @param data
|
||||
*/
|
||||
handler?: (data: any) => void;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component;
|
||||
/**
|
||||
* @zh_CN 是否显示图标
|
||||
*/
|
||||
inset?: boolean;
|
||||
/**
|
||||
* @zh_CN 唯一标识
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @zh_CN 是否是分割线
|
||||
*/
|
||||
separator?: boolean;
|
||||
/**
|
||||
* @zh_CN 快捷键
|
||||
*/
|
||||
shortcut?: string;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export type { IContextMenuItem };
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, unref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { isNumber } from '@vben-core/shared/utils';
|
||||
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
autoplay?: boolean;
|
||||
color?: string;
|
||||
decimal?: string;
|
||||
decimals?: number;
|
||||
duration?: number;
|
||||
endVal?: number;
|
||||
prefix?: string;
|
||||
separator?: string;
|
||||
startVal?: number;
|
||||
suffix?: string;
|
||||
transition?: keyof typeof TransitionPresets;
|
||||
useEasing?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CountToAnimator' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autoplay: true,
|
||||
color: '',
|
||||
decimal: '.',
|
||||
decimals: 0,
|
||||
duration: 1500,
|
||||
endVal: 2021,
|
||||
prefix: '',
|
||||
separator: ',',
|
||||
startVal: 0,
|
||||
suffix: '',
|
||||
transition: 'linear',
|
||||
useEasing: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
finished: [];
|
||||
/**
|
||||
* @deprecated 请使用{@link finished}事件
|
||||
*/
|
||||
onFinished: [];
|
||||
/**
|
||||
* @deprecated 请使用{@link started}事件
|
||||
*/
|
||||
onStarted: [];
|
||||
started: [];
|
||||
}>();
|
||||
|
||||
const source = ref(props.startVal);
|
||||
const disabled = ref(false);
|
||||
let outputValue = useTransition(source);
|
||||
|
||||
const value = computed(() => formatNumber(unref(outputValue)));
|
||||
|
||||
watchEffect(() => {
|
||||
source.value = props.startVal;
|
||||
});
|
||||
|
||||
watch([() => props.startVal, () => props.endVal], () => {
|
||||
if (props.autoplay) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
props.autoplay && start();
|
||||
});
|
||||
|
||||
function start() {
|
||||
run();
|
||||
source.value = props.endVal;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
source.value = props.startVal;
|
||||
run();
|
||||
}
|
||||
|
||||
function run() {
|
||||
outputValue = useTransition(source, {
|
||||
disabled,
|
||||
duration: props.duration,
|
||||
onFinished: () => {
|
||||
emit('finished');
|
||||
emit('onFinished');
|
||||
},
|
||||
onStarted: () => {
|
||||
emit('started');
|
||||
emit('onStarted');
|
||||
},
|
||||
...(props.useEasing
|
||||
? { transition: TransitionPresets[props.transition] }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(num: number | string) {
|
||||
if (!num && num !== 0) {
|
||||
return '';
|
||||
}
|
||||
const { decimal, decimals, prefix, separator, suffix } = props;
|
||||
num = Number(num).toFixed(decimals);
|
||||
num += '';
|
||||
|
||||
const x = num.split('.');
|
||||
let x1 = x[0];
|
||||
const x2 = x.length > 1 ? decimal + x[1] : '';
|
||||
|
||||
const rgx = /(\d+)(\d{3})/;
|
||||
if (separator && !isNumber(separator) && x1) {
|
||||
while (rgx.test(x1)) {
|
||||
x1 = x1.replace(rgx, `$1${separator}$2`);
|
||||
}
|
||||
}
|
||||
return prefix + x1 + x2 + suffix;
|
||||
}
|
||||
|
||||
defineExpose({ reset });
|
||||
</script>
|
||||
<template>
|
||||
<span :style="{ color }">
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenCountToAnimator } from './count-to-animator.vue';
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as VbenDropdownMenu } from './dropdown-menu.vue';
|
||||
export { default as VbenDropdownRadioMenu } from './dropdown-radio-menu.vue';
|
||||
|
||||
export type * from './interface';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
interface VbenDropdownMenuItem {
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 点击事件处理
|
||||
* @param data
|
||||
*/
|
||||
handler?: (data: any) => void;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* @zh_CN 是否是分割线
|
||||
*/
|
||||
separator?: boolean;
|
||||
/**
|
||||
* @zh_CN 唯一标识
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
menus: VbenDropdownMenuItem[];
|
||||
}
|
||||
|
||||
export type { DropdownMenuProps, VbenDropdownMenuItem };
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronDown } from '@vben-core/icons';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
// 控制箭头展开/收起状态
|
||||
const collapsed = defineModel({ default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('vben-link inline-flex items-center', props.class)"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<slot :is-expanded="collapsed">
|
||||
{{ collapsed }}
|
||||
<!-- <span>{{ isExpanded ? '收起' : '展开' }}</span> -->
|
||||
</slot>
|
||||
<div
|
||||
:class="{ 'rotate-180': !collapsed }"
|
||||
class="transition-transform duration-300"
|
||||
>
|
||||
<slot name="icon">
|
||||
<ChevronDown class="size-4" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenExpandableArrow } from './expandable-arrow.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenFullScreen } from './full-screen.vue';
|
||||
35
packages/@core/ui-kit/shadcn-ui/src/components/icon/icon.vue
Normal file
35
packages/@core/ui-kit/shadcn-ui/src/components/icon/icon.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconDefault, IconifyIcon } from '@vben-core/icons';
|
||||
import {
|
||||
isFunction,
|
||||
isHttpUrl,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
// 没有是否显示默认图标
|
||||
fallback?: boolean;
|
||||
icon?: Component | Function | string;
|
||||
}>();
|
||||
|
||||
const isRemoteIcon = computed(() => {
|
||||
return isString(props.icon) && isHttpUrl(props.icon);
|
||||
});
|
||||
|
||||
const isComponent = computed(() => {
|
||||
const { icon } = props;
|
||||
return !isString(icon) && (isObject(icon) || isFunction(icon));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="icon as Component" v-if="isComponent" v-bind="$attrs" />
|
||||
<img v-else-if="isRemoteIcon" :src="icon as string" v-bind="$attrs" />
|
||||
<IconifyIcon v-else-if="icon" v-bind="$attrs" :icon="icon as string" />
|
||||
<IconDefault v-else-if="fallback" v-bind="$attrs" />
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenIcon } from './icon.vue';
|
||||
23
packages/@core/ui-kit/shadcn-ui/src/components/index.ts
Normal file
23
packages/@core/ui-kit/shadcn-ui/src/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export * from './avatar';
|
||||
export * from './back-top';
|
||||
export * from './breadcrumb';
|
||||
export * from './button';
|
||||
export * from './checkbox';
|
||||
export * from './context-menu';
|
||||
export * from './count-to-animator';
|
||||
export * from './dropdown-menu';
|
||||
export * from './expandable-arrow';
|
||||
export * from './full-screen';
|
||||
export * from './hover-card';
|
||||
export * from './icon';
|
||||
export * from './input-password';
|
||||
export * from './logo';
|
||||
export * from './pin-input';
|
||||
export * from './popover';
|
||||
export * from './render-content';
|
||||
export * from './scrollbar';
|
||||
export * from './segmented';
|
||||
export * from './select';
|
||||
export * from './spine-text';
|
||||
export * from './spinner';
|
||||
export * from './tooltip';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenInputPassword } from './input-password.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenLogo } from './logo.vue';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as VbenPinInput } from './input.vue';
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,30 @@
|
||||
interface PinInputProps {
|
||||
class?: any;
|
||||
/**
|
||||
* 验证码长度
|
||||
*/
|
||||
codeLength?: number;
|
||||
/**
|
||||
* 发送验证码按钮文本
|
||||
*/
|
||||
createText?: (countdown: number) => string;
|
||||
/**
|
||||
* 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 自定义验证码发送逻辑
|
||||
* @returns
|
||||
*/
|
||||
handleSendCode?: () => Promise<void>;
|
||||
/**
|
||||
* 发送验证码按钮loading
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 最大重试时间
|
||||
*/
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
export type { PinInputProps };
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenPopover } from './popover.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenRenderContent } from './render-content.vue';
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { Component, PropType } from 'vue';
|
||||
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { isFunction, isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RenderContent',
|
||||
props: {
|
||||
content: {
|
||||
default: undefined as
|
||||
| PropType<(() => any) | Component | string>
|
||||
| undefined,
|
||||
type: [Object, String, Function],
|
||||
},
|
||||
renderBr: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => {
|
||||
if (!props.content) {
|
||||
return null;
|
||||
}
|
||||
const isComponent =
|
||||
(isObject(props.content) || isFunction(props.content)) &&
|
||||
props.content !== null;
|
||||
if (!isComponent) {
|
||||
if (props.renderBr && isString(props.content)) {
|
||||
const lines = props.content.split('\n');
|
||||
const result = [];
|
||||
for (const [i, line] of lines.entries()) {
|
||||
result.push(h('p', { key: i }, line));
|
||||
// if (i < lines.length - 1) {
|
||||
// result.push(h('br'));
|
||||
// }
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return props.content;
|
||||
}
|
||||
}
|
||||
return h(props.content as never, {
|
||||
...attrs,
|
||||
props: {
|
||||
...props,
|
||||
...attrs,
|
||||
},
|
||||
slots,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenScrollbar } from './scrollbar.vue';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as VbenSegmented } from './segmented.vue';
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,6 @@
|
||||
interface SegmentedItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type { SegmentedItem };
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VbenSelect } from './select.vue';
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import { CircleX } from '@vben-core/icons';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../ui';
|
||||
|
||||
interface Props {
|
||||
allowClear?: boolean;
|
||||
class?: any;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allowClear: false,
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
function handleClear() {
|
||||
modelValue.value = undefined;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Select v-model="modelValue">
|
||||
<SelectTrigger :class="props.class" class="flex w-full items-center">
|
||||
<SelectValue class="flex-auto text-left" :placeholder="placeholder" />
|
||||
<CircleX
|
||||
@pointerdown.stop
|
||||
@click.stop.prevent="handleClear"
|
||||
v-if="allowClear && modelValue"
|
||||
data-clear-button
|
||||
class="mr-1 size-4 cursor-pointer opacity-50 hover:opacity-100"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<template v-for="item in options" :key="item.value">
|
||||
<SelectItem :value="item.value"> {{ item.label }} </SelectItem>
|
||||
</template>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
button[role='combobox'][data-placeholder] {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
button {
|
||||
--ring: var(--primary);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user