mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-09 03:51:25 +08:00
Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
This commit is contained in:
@@ -44,6 +44,7 @@ describe('formApi', () => {
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.isMounted).toBe(true);
|
||||
expect(formApi.form).toEqual(formActions);
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get values from form', async () => {
|
||||
@@ -52,11 +53,54 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should format schema values when getting values', async () => {
|
||||
formApi.setState({
|
||||
schema: [
|
||||
{
|
||||
component: 'range-picker',
|
||||
fieldName: 'filters.range',
|
||||
valueFormat: (value, setValue) => {
|
||||
setValue('filters.startTime', value?.[0]);
|
||||
setValue('filters.endTime', value?.[1]);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
values: {
|
||||
filters: {
|
||||
range: [1_710_000_000_000, 1_720_000_000_000],
|
||||
},
|
||||
},
|
||||
};
|
||||
const originalValuesSnapshot = structuredClone(formActions.values);
|
||||
|
||||
await formApi.mount(formActions, new Map());
|
||||
|
||||
expect(formApi.getLatestSubmissionValues()).toEqual({
|
||||
filters: {
|
||||
endTime: 1_720_000_000_000,
|
||||
startTime: 1_710_000_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({
|
||||
filters: {
|
||||
endTime: 1_720_000_000_000,
|
||||
startTime: 1_710_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(formActions.values).toEqual(originalValuesSnapshot);
|
||||
});
|
||||
|
||||
it('should set field value', async () => {
|
||||
const setFieldValueMock = vi.fn();
|
||||
const formActions: any = {
|
||||
@@ -65,7 +109,7 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
await formApi.setFieldValue('name', 'new value');
|
||||
expect(setFieldValueMock).toHaveBeenCalledWith(
|
||||
'name',
|
||||
@@ -82,7 +126,7 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
await formApi.resetForm();
|
||||
expect(resetFormMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -100,7 +144,7 @@ describe('formApi', () => {
|
||||
};
|
||||
|
||||
formApi.setState(state);
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
|
||||
const result = await formApi.submitForm();
|
||||
expect(formActions.submitForm).toHaveBeenCalled();
|
||||
@@ -113,6 +157,39 @@ describe('formApi', () => {
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear component refs on unmount before mounting again', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: vi.fn(),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
const staleMap = new Map<string, unknown>([
|
||||
[
|
||||
'name',
|
||||
{
|
||||
$: {
|
||||
type: { name: 'MockComponent' },
|
||||
},
|
||||
$el: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await formApi.mount(formActions, staleMap);
|
||||
expect(formApi.getFieldComponentRef('name')).toEqual({
|
||||
$: {
|
||||
type: { name: 'MockComponent' },
|
||||
},
|
||||
$el: {},
|
||||
});
|
||||
|
||||
formApi.unmount();
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate form', async () => {
|
||||
const validateMock = vi.fn().mockResolvedValue(true);
|
||||
const formActions: any = {
|
||||
@@ -120,7 +197,7 @@ describe('formApi', () => {
|
||||
validate: validateMock,
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
const isValid = await formApi.validate();
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
@@ -51,5 +51,8 @@
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-defaults": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@ const submitButtonOptions = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// const isQueryForm = computed(() => {
|
||||
// return !!unref(rootProps).showCollapseButton;
|
||||
// });
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
14
packages/@core/ui-kit/form-ui/src/field-name.ts
Normal file
14
packages/@core/ui-kit/form-ui/src/field-name.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function resolveFieldNamePath(fieldName: string) {
|
||||
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
|
||||
const rawKey = fieldName.slice(1, -1);
|
||||
return {
|
||||
pathSegments: [rawKey],
|
||||
rawKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pathSegments: fieldName.match(/[^.[\]]+/g) ?? [],
|
||||
rawKey: undefined,
|
||||
};
|
||||
}
|
||||
@@ -16,16 +16,21 @@ import { isRef, toRaw } from 'vue';
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
cloneDeep,
|
||||
createMerge,
|
||||
formatDate,
|
||||
get,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
isFunction,
|
||||
isObject,
|
||||
mergeWithArrayOverride,
|
||||
set,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
import { resolveFieldNamePath } from './field-name';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
actionWrapperClass: '',
|
||||
@@ -158,7 +163,10 @@ export class FormApi {
|
||||
|
||||
async getValues<T = Recordable<any>>() {
|
||||
const form = await this.getForm();
|
||||
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
|
||||
const values = form.values
|
||||
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
|
||||
: {};
|
||||
return this.handleValueFormat(values) as T;
|
||||
}
|
||||
|
||||
async isFieldValid(fieldName: string) {
|
||||
@@ -206,14 +214,18 @@ export class FormApi {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
|
||||
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
|
||||
if (!this.isMounted) {
|
||||
Object.assign(this.form, formActions);
|
||||
this.stateHandler.setConditionTrue();
|
||||
const initialValues = this.form.values
|
||||
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
|
||||
: {};
|
||||
this.setLatestSubmissionValues({
|
||||
...toRaw(this.handleRangeTimeValue(this.form.values)),
|
||||
...this.handleValueFormat(initialValues),
|
||||
});
|
||||
this.componentRefMap = componentRefMap;
|
||||
this.componentRefMap =
|
||||
componentRefMap ?? this.componentRefMap ?? new Map();
|
||||
this.isMounted = true;
|
||||
}
|
||||
}
|
||||
@@ -363,6 +375,7 @@ export class FormApi {
|
||||
unmount() {
|
||||
this.form?.resetForm?.();
|
||||
// this.state = null;
|
||||
this.componentRefMap = new Map();
|
||||
this.latestSubmissionValues = null;
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
@@ -443,6 +456,42 @@ export class FormApi {
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
private deleteValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
) {
|
||||
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
Reflect.deleteProperty(values, rawKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pathSegments || pathSegments.length === 0) {
|
||||
Reflect.deleteProperty(values, fieldName);
|
||||
return;
|
||||
}
|
||||
|
||||
let target: Record<string, any> | undefined = values;
|
||||
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
if (!target || !isObject(target)) {
|
||||
return;
|
||||
}
|
||||
target = target[segment];
|
||||
}
|
||||
|
||||
if (!target || !isObject(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPathSegment = pathSegments.at(-1);
|
||||
if (!lastPathSegment) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(target, lastPathSegment);
|
||||
}
|
||||
|
||||
private async getForm() {
|
||||
if (!this.isMounted) {
|
||||
// 等待form挂载
|
||||
@@ -560,6 +609,36 @@ export class FormApi {
|
||||
return values;
|
||||
};
|
||||
|
||||
private handleValueFormat = (originValues: Record<string, any>) => {
|
||||
const values = { ...originValues };
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
|
||||
currentSchema.forEach((schema) => {
|
||||
if (!schema.valueFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName = schema.fieldName;
|
||||
const value = this.resolveValueByFieldName(values, fieldName);
|
||||
|
||||
this.deleteValueByFieldName(values, fieldName);
|
||||
|
||||
const formattedValue = schema.valueFormat(
|
||||
value,
|
||||
(key, nextValue) => {
|
||||
this.setValueByFieldName(values, key, nextValue);
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
if (formattedValue !== undefined) {
|
||||
this.setValueByFieldName(values, fieldName, formattedValue);
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
private processFields = (
|
||||
fields: string[],
|
||||
separator: string,
|
||||
@@ -575,6 +654,32 @@ export class FormApi {
|
||||
});
|
||||
};
|
||||
|
||||
private resolveValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
) {
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
return values[rawKey];
|
||||
}
|
||||
|
||||
return get(values, fieldName);
|
||||
}
|
||||
|
||||
private setValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
value: any,
|
||||
) {
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
values[rawKey] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
set(values, fieldName, value);
|
||||
}
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type {
|
||||
ExtendedFormApi,
|
||||
FormItemDependencies,
|
||||
FormSchemaRuleType,
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, isRef, ref, watch } from 'vue';
|
||||
|
||||
import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { useFormValues } from 'vee-validate';
|
||||
|
||||
import { resolveFieldNamePath } from '../field-name';
|
||||
import { injectFormProps } from '../use-form-context';
|
||||
import { injectRenderFormProps } from './context';
|
||||
|
||||
/**
|
||||
@@ -22,20 +25,21 @@ function resolveValueByFieldName(
|
||||
fieldName: string,
|
||||
) {
|
||||
// vee-validate:[] 表示禁用嵌套
|
||||
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
|
||||
const rawKey = fieldName.slice(1, -1);
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
return values[rawKey];
|
||||
}
|
||||
|
||||
return get(values, fieldName);
|
||||
}
|
||||
|
||||
export default function useDependencies(
|
||||
getDependencies: () => FormItemDependencies | undefined,
|
||||
) {
|
||||
const values = useFormValues();
|
||||
|
||||
const [extendApi] = injectFormProps();
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
|
||||
const formApi = formRenderProps.form;
|
||||
|
||||
if (!formApi) {
|
||||
@@ -46,6 +50,19 @@ export default function useDependencies(
|
||||
throw new Error('useDependencies should be used within <VbenForm>');
|
||||
}
|
||||
|
||||
// 在 dependencies 里提供访问extendApi的能力
|
||||
const getController = (): ExtendedFormApi => {
|
||||
const controller = isRef(extendApi)
|
||||
? extendApi.value.formApi
|
||||
: extendApi.formApi;
|
||||
|
||||
if (!controller) {
|
||||
throw new Error('formApi is required in useDependencies');
|
||||
}
|
||||
|
||||
return controller;
|
||||
};
|
||||
|
||||
const isIf = ref(true);
|
||||
const isDisabled = ref(false);
|
||||
const isShow = ref(true);
|
||||
@@ -91,7 +108,7 @@ export default function useDependencies(
|
||||
const formValues = values.value;
|
||||
|
||||
if (isFunction(whenIf)) {
|
||||
isIf.value = !!(await whenIf(formValues, formApi));
|
||||
isIf.value = !!(await whenIf(formValues, formApi, getController()));
|
||||
// 不渲染
|
||||
if (!isIf.value) return;
|
||||
} else if (isBoolean(whenIf)) {
|
||||
@@ -101,31 +118,43 @@ export default function useDependencies(
|
||||
|
||||
// 2. 判断show,如果show为false,则隐藏
|
||||
if (isFunction(show)) {
|
||||
isShow.value = !!(await show(formValues, formApi));
|
||||
isShow.value = !!(await show(formValues, formApi, getController()));
|
||||
} else if (isBoolean(show)) {
|
||||
isShow.value = show;
|
||||
}
|
||||
|
||||
if (isFunction(componentProps)) {
|
||||
dynamicComponentProps.value = await componentProps(formValues, formApi);
|
||||
dynamicComponentProps.value = await componentProps(
|
||||
formValues,
|
||||
formApi,
|
||||
getController(),
|
||||
);
|
||||
}
|
||||
|
||||
if (isFunction(rules)) {
|
||||
dynamicRules.value = await rules(formValues, formApi);
|
||||
dynamicRules.value = await rules(formValues, formApi, getController());
|
||||
}
|
||||
|
||||
if (isFunction(disabled)) {
|
||||
isDisabled.value = !!(await disabled(formValues, formApi));
|
||||
isDisabled.value = !!(await disabled(
|
||||
formValues,
|
||||
formApi,
|
||||
getController(),
|
||||
));
|
||||
} else if (isBoolean(disabled)) {
|
||||
isDisabled.value = disabled;
|
||||
}
|
||||
|
||||
if (isFunction(required)) {
|
||||
isRequired.value = !!(await required(formValues, formApi));
|
||||
isRequired.value = !!(await required(
|
||||
formValues,
|
||||
formApi,
|
||||
getController(),
|
||||
));
|
||||
}
|
||||
|
||||
if (isFunction(trigger)) {
|
||||
trigger(formValues, formApi);
|
||||
trigger(formValues, formApi, getController());
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
|
||||
@@ -7,15 +7,24 @@ import type {
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { CircleAlert } from '@vben-core/icons';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { ChevronsDown, CircleAlert } from '@vben-core/icons';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
VbenCollapsible,
|
||||
VbenRenderContent,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
@@ -53,6 +62,8 @@ const {
|
||||
renderComponentContent,
|
||||
rules,
|
||||
help,
|
||||
collapsible,
|
||||
defaultCollapsed = false,
|
||||
} = defineProps<
|
||||
Props & {
|
||||
commonComponentProps: MaybeComponentProps;
|
||||
@@ -67,6 +78,7 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
|
||||
const formApi = formRenderProps.form;
|
||||
const compact = computed(() => formRenderProps.compact);
|
||||
const isInValid = computed(() => errors.value?.length > 0);
|
||||
const collapseOpen = ref(!defaultCollapsed);
|
||||
|
||||
function getFormApi(): FormActions {
|
||||
if (!formApi) {
|
||||
@@ -77,7 +89,9 @@ function getFormApi(): FormActions {
|
||||
}
|
||||
|
||||
const FieldComponent = computed(() => {
|
||||
const finalComponent = isString(component) ? componentMap.value[component] : component;
|
||||
const finalComponent = isString(component)
|
||||
? componentMap.value[component]
|
||||
: component;
|
||||
if (!finalComponent) {
|
||||
// 组件未注册
|
||||
console.warn(`Component ${component} is not registered`);
|
||||
@@ -85,8 +99,14 @@ const FieldComponent = computed(() => {
|
||||
return finalComponent;
|
||||
});
|
||||
|
||||
const { dynamicComponentProps, dynamicRules, isDisabled, isIf, isRequired, isShow } =
|
||||
useDependencies(() => dependencies);
|
||||
const {
|
||||
dynamicComponentProps,
|
||||
dynamicRules,
|
||||
isDisabled,
|
||||
isIf,
|
||||
isRequired,
|
||||
isShow,
|
||||
} = useDependencies(() => dependencies);
|
||||
|
||||
const labelStyle = computed(() => {
|
||||
return labelClass?.includes('w-') || isVertical.value
|
||||
@@ -225,7 +245,8 @@ function fieldBindEvent(slotProps: Record<string, any>) {
|
||||
const handler = slotProps.componentField['onUpdate:modelValue'];
|
||||
|
||||
const bindEventField =
|
||||
modelPropName || (isString(component) ? componentBindEventMap.value?.[component] : null);
|
||||
modelPropName ||
|
||||
(isString(component) ? componentBindEventMap.value?.[component] : null);
|
||||
|
||||
let value = modelValue;
|
||||
// antd design 的一些组件会传递一个 event 对象
|
||||
@@ -287,6 +308,15 @@ function autofocus() {
|
||||
fieldComponentRef.value?.focus?.();
|
||||
}
|
||||
}
|
||||
|
||||
const shouldCollapsible = computed(() => {
|
||||
return collapsible; /* && isVertical.value; */
|
||||
});
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapseOpen.value = !collapseOpen.value;
|
||||
}
|
||||
|
||||
const componentRefMap = injectComponentRefMap();
|
||||
watch(fieldComponentRef, (componentRef) => {
|
||||
componentRefMap?.set(fieldName, componentRef);
|
||||
@@ -299,7 +329,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-if="!hide && isIf" v-bind="fieldProps" v-slot="slotProps" :name="fieldName">
|
||||
<FormField
|
||||
v-if="!hide && isIf"
|
||||
v-bind="fieldProps"
|
||||
v-slot="slotProps"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem
|
||||
v-show="isShow"
|
||||
:class="{
|
||||
@@ -321,6 +356,7 @@ onUnmounted(() => {
|
||||
{
|
||||
'mr-2 shrink-0 justify-end': !isVertical,
|
||||
'mb-1 flex-row': isVertical,
|
||||
'self-start': shouldCollapsible && !isVertical,
|
||||
},
|
||||
labelClass,
|
||||
)
|
||||
@@ -334,58 +370,87 @@ onUnmounted(() => {
|
||||
<template v-if="label">
|
||||
<VbenRenderContent :content="label" />
|
||||
</template>
|
||||
</FormLabel>
|
||||
<!-- overflow-hidden导致radio的波纹效果无法显示 -->
|
||||
<div class="flex-auto p-[1px]">
|
||||
<div :class="cn('relative flex w-full items-center', wrapperClass)">
|
||||
<FormControl :class="cn(controlClass)">
|
||||
<slot
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
...createComponentProps(slotProps),
|
||||
disabled: shouldDisabled,
|
||||
isInValid,
|
||||
<template #extra>
|
||||
<Button
|
||||
class="ml-0.5"
|
||||
variant="icon"
|
||||
size="icon"
|
||||
@click.prevent="toggleCollapsed"
|
||||
v-if="shouldCollapsible"
|
||||
>
|
||||
<ChevronsDown
|
||||
:size="16"
|
||||
class="transition-transform"
|
||||
:class="{
|
||||
'rotate-180': !collapseOpen,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</FormLabel>
|
||||
<div class="flex-auto p-px">
|
||||
<VbenCollapsible :show-trigger="false" v-model:open="collapseOpen">
|
||||
<template #collapsibleContent>
|
||||
<div :class="cn('relative flex w-full items-center', wrapperClass)">
|
||||
<FormControl :class="cn(controlClass)">
|
||||
<slot
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
...createComponentProps(slotProps),
|
||||
disabled: shouldDisabled,
|
||||
isInValid,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
>
|
||||
<template v-for="name in renderContentKey" :key="name" #[name]="renderSlotProps">
|
||||
<VbenRenderContent
|
||||
:content="customContentRender[name]"
|
||||
v-bind="{ ...renderSlotProps, formContext: slotProps }"
|
||||
/>
|
||||
</template>
|
||||
<!-- <slot></slot> -->
|
||||
</component>
|
||||
<VbenTooltip v-if="compact && isInValid" :delay-duration="300" side="left">
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<CircleAlert
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<FormMessage />
|
||||
</VbenTooltip>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<!-- 自定义后缀 -->
|
||||
<div v-if="suffix" class="ml-1">
|
||||
<VbenRenderContent :content="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
isInValid,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
>
|
||||
<template
|
||||
v-for="name in renderContentKey"
|
||||
:key="name"
|
||||
#[name]="renderSlotProps"
|
||||
>
|
||||
<VbenRenderContent
|
||||
:content="customContentRender[name]"
|
||||
v-bind="{ ...renderSlotProps, formContext: slotProps }"
|
||||
/>
|
||||
</template>
|
||||
<!-- <slot></slot> -->
|
||||
</component>
|
||||
<VbenTooltip
|
||||
v-if="compact && isInValid"
|
||||
:delay-duration="300"
|
||||
side="left"
|
||||
>
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<CircleAlert
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<FormMessage />
|
||||
</VbenTooltip>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<!-- 自定义后缀 -->
|
||||
<div v-if="suffix" class="ml-1">
|
||||
<VbenRenderContent :content="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VbenCollapsible>
|
||||
|
||||
<FormDescription v-if="description" class="text-xs">
|
||||
<VbenRenderContent :content="description" />
|
||||
</FormDescription>
|
||||
|
||||
@@ -26,6 +26,7 @@ const props = defineProps<Props>();
|
||||
</span>
|
||||
<!-- <VbenRenderContent :content="help" /> -->
|
||||
</VbenHelpTooltip>
|
||||
<slot name="extra"></slot>
|
||||
<span v-if="colon && label" class="ml-0.5">:</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,9 @@ export { setupVbenForm } from './config';
|
||||
export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
FormSchema as VbenFormSchema,
|
||||
FormLayout,
|
||||
VbenFormProps,
|
||||
FormSchema as VbenFormSchema,
|
||||
} from './types';
|
||||
|
||||
export * from './use-vben-form';
|
||||
|
||||
@@ -85,16 +85,19 @@ export type FormSchemaRuleType =
|
||||
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
) => T;
|
||||
|
||||
type FormItemDependenciesConditionWithRules = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
|
||||
|
||||
type FormItemDependenciesConditionWithProps = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
|
||||
|
||||
export interface FormItemDependencies {
|
||||
@@ -145,6 +148,11 @@ type ComponentProps =
|
||||
| MaybeComponentProps;
|
||||
|
||||
export interface FormCommonConfig {
|
||||
/**
|
||||
* 是否可折叠的
|
||||
* @default false
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
/**
|
||||
* 在Label后显示一个冒号
|
||||
*/
|
||||
@@ -157,6 +165,11 @@ export interface FormCommonConfig {
|
||||
* 所有表单项的控件样式
|
||||
*/
|
||||
controlClass?: string;
|
||||
/**
|
||||
* 默认折叠
|
||||
* @default false
|
||||
*/
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* 所有表单项的禁用状态
|
||||
* @default false
|
||||
@@ -228,6 +241,19 @@ type MappedComponentProps<P> =
|
||||
) => P & Record<string, any>)
|
||||
| (P & Record<string, any>);
|
||||
|
||||
/**
|
||||
* 格式化 `getValues()` 输出中的当前字段值。
|
||||
* - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)`
|
||||
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
|
||||
* - 返回其他值:会将当前字段恢复/写回为该返回值
|
||||
* - `setValue` 回调签名为 `(key, nextValue) => void`
|
||||
*/
|
||||
export type FormValueFormat = (
|
||||
value: any,
|
||||
setValue: (fieldName: string, value: any) => void,
|
||||
values: Record<string, any>,
|
||||
) => any;
|
||||
|
||||
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
@@ -249,6 +275,12 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
|
||||
rules?: FormSchemaRuleType;
|
||||
/** 后缀 */
|
||||
suffix?: CustomRenderType;
|
||||
/**
|
||||
* 获取表单值时格式化当前字段。
|
||||
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
|
||||
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
|
||||
*/
|
||||
valueFormat?: FormValueFormat;
|
||||
}
|
||||
|
||||
type FormSchemaDiscriminated<
|
||||
|
||||
@@ -47,5 +47,8 @@
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qs": "catalog:"
|
||||
"@types/qs": "catalog:",
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,5 +47,8 @@
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export interface DrawerProps {
|
||||
*/
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* 抽屉加载状态
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ export class ModalApi {
|
||||
contentClass: '',
|
||||
destroyOnClose: true,
|
||||
draggable: false,
|
||||
overflow: false,
|
||||
footer: true,
|
||||
footerClass: '',
|
||||
fullscreen: false,
|
||||
|
||||
@@ -97,7 +97,7 @@ export interface ModalProps {
|
||||
header?: boolean;
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* 弹窗加载状态
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
@@ -110,6 +110,11 @@ export interface ModalProps {
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 拖动范围是否可以超出可视区
|
||||
* @default false
|
||||
*/
|
||||
overflow?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
|
||||
@@ -81,6 +81,7 @@ const {
|
||||
description,
|
||||
destroyOnClose,
|
||||
draggable,
|
||||
overflow,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
fullscreen,
|
||||
@@ -122,6 +123,7 @@ const { dragging, transform } = useModalDraggable(
|
||||
shouldDraggable,
|
||||
getAppendTo,
|
||||
shouldCentered,
|
||||
overflow,
|
||||
);
|
||||
|
||||
const firstOpened = ref(false);
|
||||
@@ -246,7 +248,8 @@ function handleClosed() {
|
||||
{
|
||||
'border border-border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
'top-0 left-0 size-full max-h-full translate-0!': shouldFullscreen,
|
||||
'top-0 left-0 size-full max-h-full transform-[translate(0,0)]!':
|
||||
shouldFullscreen,
|
||||
'top-1/2': centered && !shouldFullscreen,
|
||||
'duration-300': !dragging,
|
||||
hidden: isClosed,
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useModalDraggable(
|
||||
draggable: ComputedRef<boolean>,
|
||||
containerSelector?: ComputedRef<string | undefined>,
|
||||
centered?: ComputedRef<boolean>,
|
||||
overflow?: ComputedRef<boolean>,
|
||||
) {
|
||||
const transform = reactive({
|
||||
offsetX: 0,
|
||||
@@ -67,8 +68,10 @@ export function useModalDraggable(
|
||||
let moveX = offsetX + e.clientX - downX;
|
||||
let moveY = offsetY + e.clientY - downY;
|
||||
|
||||
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
|
||||
moveY = Math.min(Math.max(moveY, minTop), maxTop);
|
||||
if (!overflow?.value) {
|
||||
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
|
||||
moveY = Math.min(Math.max(moveY, minTop), maxTop);
|
||||
}
|
||||
|
||||
transform.offsetX = moveX;
|
||||
transform.offsetY = moveY;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleParamSchema } from './type';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
|
||||
interface Props {
|
||||
data: CollapsibleParamSchema;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const modelValue = defineModel('value');
|
||||
|
||||
const finalOption = computed(() => {
|
||||
const { type, ...otherOption } = props.data.option;
|
||||
|
||||
if (type === 'number' || type === 'exponential') {
|
||||
return {
|
||||
step: props.data.option.step ?? 1,
|
||||
min: props.data.option.min,
|
||||
max: props.data.option.max,
|
||||
precision: props.data.option.precision,
|
||||
...otherOption,
|
||||
};
|
||||
}
|
||||
|
||||
return otherOption;
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const FieldComponent = computed(() => {
|
||||
switch (props.data.option.type) {
|
||||
case 'exponential':
|
||||
case 'number': {
|
||||
return components.InputNumber;
|
||||
}
|
||||
case 'select': {
|
||||
return components.Select;
|
||||
}
|
||||
case 'string': {
|
||||
return components.Input;
|
||||
}
|
||||
|
||||
default: {
|
||||
return components.InputNumber;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const limitDisplay = computed(() => {
|
||||
if (
|
||||
props.data.option.min !== null &&
|
||||
props.data.option.min !== undefined &&
|
||||
props.data.option.max !== null &&
|
||||
props.data.option.max !== undefined
|
||||
) {
|
||||
return `[${props.data.option.min},${props.data.option.max}]`;
|
||||
}
|
||||
|
||||
if (props.data.option.min !== null && props.data.option.min !== undefined) {
|
||||
return `min:${props.data.option.min}`;
|
||||
}
|
||||
|
||||
if (props.data.option.max !== null && props.data.option.max !== undefined) {
|
||||
return `max:${props.data.option.max}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
function reset() {
|
||||
modelValue.value = props.data.defaultValue;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="body-row flex items-center w-full flex-nowrap not-last-of-type:border-b"
|
||||
>
|
||||
<div
|
||||
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
|
||||
>
|
||||
{{ data.key }}
|
||||
</div>
|
||||
<div
|
||||
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
|
||||
>
|
||||
<div class="flex-auto w-full">
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
v-bind="finalOption"
|
||||
v-model:value="modelValue"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center flex-none text-muted-foreground pl-2 gap-2">
|
||||
<span v-if="limitDisplay">
|
||||
{{ limitDisplay }}
|
||||
</span>
|
||||
<span v-if="data.option.step && data.option.step !== 1">
|
||||
step:{{ data.option.step }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap w-full"
|
||||
>
|
||||
<p
|
||||
class="line-clamp-2"
|
||||
v-tippy="{
|
||||
content: data.description,
|
||||
}"
|
||||
>
|
||||
{{ data.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { CollapsibleParamSchema } from './type';
|
||||
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
|
||||
import { ChevronsDown } from 'lucide-vue-next';
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
} from 'reka-ui';
|
||||
|
||||
import CollapsibleParamsItem from './collapsible-params-item.vue';
|
||||
|
||||
interface Props {
|
||||
defaultOpen?: boolean;
|
||||
maxHeight?: number | string;
|
||||
params: CollapsibleParamSchema[];
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visibleCount: 3,
|
||||
defaultOpen: false,
|
||||
maxHeight: undefined,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{ 'update:value': [any, string] }>();
|
||||
|
||||
const modelValue = defineModel('value', {
|
||||
default: {} as Recordable<CollapsibleParamSchema['defaultValue']>,
|
||||
});
|
||||
|
||||
const visibleRefs = useTemplateRef('visibleRefs');
|
||||
const collapsibleRefs = useTemplateRef('collapsibleRefs');
|
||||
|
||||
const { b } = useNamespace('collapsible-params');
|
||||
|
||||
const open = ref(props.defaultOpen);
|
||||
|
||||
// 最小可见为1
|
||||
const finalVisibleCount = computed(() =>
|
||||
Math.max(1, Math.floor(props.visibleCount)),
|
||||
);
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
return props.params.slice(0, finalVisibleCount.value);
|
||||
});
|
||||
|
||||
const collapsibleRows = computed(() => {
|
||||
return props.params.slice(finalVisibleCount.value);
|
||||
});
|
||||
|
||||
const bodyStyle = computed(() => {
|
||||
if (!open.value || props.maxHeight == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
maxHeight:
|
||||
typeof props.maxHeight === 'number'
|
||||
? `${props.maxHeight}px`
|
||||
: props.maxHeight,
|
||||
};
|
||||
});
|
||||
|
||||
function init(force = false) {
|
||||
const nextValue: Recordable<CollapsibleParamSchema['defaultValue']> = {
|
||||
...modelValue.value,
|
||||
};
|
||||
|
||||
for (const param of props.params) {
|
||||
if (force || nextValue[param.key] === undefined) {
|
||||
nextValue[param.key] = param.defaultValue ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
modelValue.value = nextValue;
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
async function onParamValueChange(_: any, key: string) {
|
||||
await nextTick();
|
||||
emits('update:value', modelValue.value, key);
|
||||
}
|
||||
|
||||
function resetValues() {
|
||||
if (visibleRefs.value)
|
||||
for (const rowRef of visibleRefs.value) {
|
||||
rowRef?.reset();
|
||||
}
|
||||
|
||||
if (collapsibleRefs.value)
|
||||
for (const rowRef of collapsibleRefs.value) {
|
||||
rowRef?.reset();
|
||||
}
|
||||
|
||||
init(true);
|
||||
}
|
||||
|
||||
function updateValues(
|
||||
values: Recordable<CollapsibleParamSchema['defaultValue']>,
|
||||
) {
|
||||
const allowedKeys = new Set(props.params.map((param) => param.key));
|
||||
const patch = {} as Recordable<CollapsibleParamSchema['defaultValue']>;
|
||||
|
||||
for (const key in values) {
|
||||
if (!Object.hasOwn(values, key)) continue;
|
||||
if (!allowedKeys.has(key)) continue;
|
||||
|
||||
patch[key] = values[key];
|
||||
}
|
||||
|
||||
modelValue.value = { ...modelValue.value, ...patch };
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.params,
|
||||
() => init(),
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
toggleCollapsed,
|
||||
resetValues,
|
||||
updateValues,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="open"
|
||||
class="border rounded-[0.5rem] flex flex-col w-full overflow-hidden"
|
||||
:class="[b()]"
|
||||
:unmount-on-hide="false"
|
||||
>
|
||||
<div class="wrapper w-full relative flex flex-col overflow-x-auto">
|
||||
<div class="w-full min-w-fit">
|
||||
<div
|
||||
class="header bg-accent w-full flex-none flex items-center rounded-t-[0.5rem] border-b"
|
||||
>
|
||||
<div
|
||||
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
<div
|
||||
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
|
||||
>
|
||||
Value
|
||||
</div>
|
||||
<div
|
||||
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
|
||||
>
|
||||
Description
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="body w-full flex-none flex flex-col overflow-x-hidden"
|
||||
:class="[
|
||||
open && !!props.maxHeight ? 'overflow-y-auto' : 'overflow-y-hidden',
|
||||
]"
|
||||
:style="bodyStyle"
|
||||
>
|
||||
<CollapsibleParamsItem
|
||||
:data="row"
|
||||
v-for="row in visibleRows"
|
||||
:key="row.key"
|
||||
ref="visibleRefs"
|
||||
v-model:value="modelValue[row.key]"
|
||||
@update:value="(v) => onParamValueChange(v, row.key)"
|
||||
/>
|
||||
<CollapsibleContent
|
||||
class="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up"
|
||||
>
|
||||
<CollapsibleParamsItem
|
||||
:data="row"
|
||||
v-for="row in collapsibleRows"
|
||||
:key="row.key"
|
||||
ref="collapsibleRefs"
|
||||
v-model:value="modelValue[row.key]"
|
||||
@update:value="(v) => onParamValueChange(v, row.key)"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="gutter h-[1.5rem]"
|
||||
v-if="!open && collapsibleRows.length > 0"
|
||||
></div>
|
||||
<div
|
||||
class="trigger-bar flex min-h-[2rem] border-t px-5 pt-1 pb-1 rounded-b-[0.5rem] z-1"
|
||||
:class="{
|
||||
'collapsed absolute bottom-[1px] left-[1px] right-[1px] border-t-0 pt-6':
|
||||
!open,
|
||||
}"
|
||||
v-if="collapsibleRows.length > 0"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
class="cursor-pointer h-[2rem] flex items-center gap-2"
|
||||
>
|
||||
<ChevronsDown
|
||||
class="transition-transform"
|
||||
:size="16"
|
||||
:class="{
|
||||
'rotate-180': open,
|
||||
}"
|
||||
/>
|
||||
{{ open ? 'Fold' : 'Unfold' }}
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
<style>
|
||||
.vben-collapsible-params {
|
||||
.wrapper {
|
||||
--column1: 11.25rem;
|
||||
--column2: 18.25rem;
|
||||
--column3: 27.5rem;
|
||||
|
||||
.header-cell,
|
||||
.body-cell {
|
||||
&:nth-of-type(1) {
|
||||
flex: 0 0 var(--column1);
|
||||
|
||||
/* min-width: var(--column1); */
|
||||
}
|
||||
|
||||
&:nth-of-type(2) {
|
||||
flex: 0 0 var(--column2);
|
||||
|
||||
/* min-width: var(--column2); */
|
||||
}
|
||||
|
||||
&:nth-of-type(3) {
|
||||
flex: 1 1 var(--column3);
|
||||
min-width: var(--column3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-bar {
|
||||
&.collapsed {
|
||||
background-image: linear-gradient(
|
||||
hsl(var(--foreground) / 0%) 0%,
|
||||
hsl(var(--foreground) / 12%) 31.76%,
|
||||
var(--color-border) 31.76%,
|
||||
var(--color-border) 33.43%,
|
||||
var(--color-background) 31.76%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue
Executable file
79
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue
Executable file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ChevronsDown } from 'lucide-vue-next';
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui';
|
||||
|
||||
const props = defineProps<
|
||||
CollapsibleRootProps & {
|
||||
class?: ClassType;
|
||||
showTrigger?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const emits = defineEmits<CollapsibleRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _cls, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const open = defineModel('open', { default: true });
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-bind="forwarded"
|
||||
v-model:open="open"
|
||||
class="flex flex-col"
|
||||
:unmount-on-hide="false"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between"
|
||||
v-if="$slots.label || showTrigger"
|
||||
>
|
||||
<slot name="label" v-if="$slots.label"> </slot>
|
||||
<CollapsibleTrigger
|
||||
v-if="showTrigger"
|
||||
class="cursor-pointer rounded-full h-[25px] w-[25px] inline-flex items-center justify-center outline-none data-[state=closed]:bg-white data-[state=open]:bg-primary/20 hover:bg-primary/20 text-primary"
|
||||
>
|
||||
<slot name="trigger" :open>
|
||||
<ChevronsDown
|
||||
class="h-3.5 w-3.5 transition-transform"
|
||||
:class="{
|
||||
'rotate-180': open,
|
||||
}"
|
||||
/>
|
||||
</slot>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<slot name="visibleContent" :open></slot>
|
||||
|
||||
<CollapsibleContent
|
||||
class="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden justify-start"
|
||||
>
|
||||
<slot name="collapsibleContent" :open></slot>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
4
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts
Executable file
4
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export { default as VbenCollapsibleParams } from './collapsible-params.vue';
|
||||
export { default as VbenCollapsible } from './collapsible.vue';
|
||||
|
||||
export * from './type';
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface CollapsibleParamsProps {
|
||||
defaultOpen?: boolean;
|
||||
maxHeight?: number | string;
|
||||
params: CollapsibleParamSchema[];
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
export interface CollapsibleParamOption {
|
||||
[key: string]: any;
|
||||
max?: number;
|
||||
min?: number;
|
||||
precision?: number;
|
||||
step?: number;
|
||||
type?: 'exponential' | 'number' | 'select' | 'string';
|
||||
}
|
||||
|
||||
export interface CollapsibleParamSchema {
|
||||
defaultValue?: number | number[] | string | string[];
|
||||
description: string;
|
||||
key: string;
|
||||
option: CollapsibleParamOption;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './back-top';
|
||||
export * from './breadcrumb';
|
||||
export * from './button';
|
||||
export * from './checkbox';
|
||||
export * from './collapsible';
|
||||
export * from './context-menu';
|
||||
export * from './count-to-animator';
|
||||
export * from './dropdown-menu';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
|
||||
@@ -47,5 +47,8 @@
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user