Procházet zdrojové kódy

feat: add collapsible 组件,form表单增加单项可折叠,支持schema配置默认关闭/开启

feat: add collapsible 组件,form表单增加单项可折叠,支持schema配置默认关闭/开启
- shadcn-ui 增加 collapsible组件,collapsible-params组件
- form新增支持单项折叠
- collapsible-params组件在Form表单应用
allen před 2 měsíci
rodič
revize
6f18718c87

+ 1 - 0
apps/web-naive/package.json

@@ -26,6 +26,7 @@
     "#/*": "./src/*"
   },
   "dependencies": {
+    "@vben-core/shadcn-ui": "workspace:*",
     "@vben/access": "workspace:*",
     "@vben/common-ui": "workspace:*",
     "@vben/constants": "workspace:*",

+ 165 - 4
apps/web-naive/src/views/demos/form/basic.vue

@@ -1,7 +1,19 @@
 <script lang="ts" setup>
-import { Page, useVbenModal } from '@vben/common-ui';
+import type { CollapsibleParamSchema } from '@vben-core/shadcn-ui';
 
-import { NButton, NCard, useMessage } from 'naive-ui';
+import { ref } from 'vue';
+
+import { Page, useVbenModal, z } from '@vben/common-ui';
+
+import { VbenCollapsibleParams } from '@vben-core/shadcn-ui';
+
+import {
+  NButton,
+  NCard,
+  NRadioButton,
+  NRadioGroup,
+  useMessage,
+} from 'naive-ui';
 
 import { useVbenForm } from '#/adapter/form';
 import { getAllMenusApi } from '#/api';
@@ -9,6 +21,111 @@ import { getAllMenusApi } from '#/api';
 import modalDemo from './modal.vue';
 
 const message = useMessage();
+
+const layouts = [
+  { label: 'Vertical', value: 'vertical' },
+  { label: 'Horizontal', value: 'horizontal' },
+  { label: 'Inline', value: 'inline' },
+];
+const layout = ref(layouts[0].value);
+
+function getNumberValidator(key: string, limit?: [number, number]) {
+  const validator = z.number({
+    required_error: `${key} 值不能为空`,
+    invalid_type_error: `${key} 值只能为数字`,
+  });
+
+  if (limit) {
+    validator.min(limit[0], { message: `${key} 值不在区间范围内` });
+    validator.max(limit[1], { message: `${key} 值不在区间范围内` });
+  }
+
+  return validator;
+}
+
+const paramsSchema = [
+  {
+    key: 'micro_batch_size',
+    description: `批次大小,代表模型训练过程中,模型更新模型参数的数据步长,可理解为模型每看多少数据即更新一次模型参数,
+    一般建议的批次大小为16/32,表示模型每看16或32条数据即更新一次参数`,
+    // defaultValue: 8,
+    option: {
+      min: 8,
+      max: 1024,
+      step: 8,
+    },
+  },
+  {
+    key: 'learning_rate',
+    description:
+      '学习率,代表每次更新数据的增量参数权重,学习率数值越大参数变化越大,对模型影响越大',
+    // defaultValue: 1e-5,
+    option: {
+      step: 1e-4,
+      type: 'exponential',
+    },
+  },
+  {
+    key: 'eval_steps',
+    description:
+      '验证步数,训练阶段针模型的验证间隔步长,用于阶段性评估模型训练准确率、训练损失',
+    // defaultValue: 50,
+    option: {
+      min: 1,
+      max: 2_147_483_647,
+    },
+  },
+  {
+    key: 'num_train_epochs',
+    description:
+      '循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是1-3遍即可,可依据需求进行调整',
+    // defaultValue: 3,
+    option: {
+      min: 1,
+      max: 200,
+    },
+  },
+  {
+    key: 'max_length',
+    description: `序列长度,单个训练数据样本的最大长度,超出配置长度将丢弃`,
+    // defaultValue: 32_768,
+    option: {
+      min: 500,
+      max: 131_072,
+    },
+  },
+  {
+    key: 'warmup_ratio',
+    description: '学习率预热比例,学习率预热阶段占总训练步数的比例',
+    // defaultValue: 0.05,
+    option: {
+      min: 0,
+      max: 1,
+      precision: 2,
+      step: 0.01,
+    },
+  },
+  {
+    key: 'save_steps',
+    description: 'Checkpoint保存间隔',
+    // defaultValue: 50,
+    option: {
+      min: 1,
+      max: 2_147_483_647,
+    },
+  },
+] as CollapsibleParamSchema[];
+
+const paramsValidator = z.object({
+  micro_batch_size: getNumberValidator('micro_batch_size', [8, 1024]),
+  learning_rate: getNumberValidator('learning_rate'),
+  eval_steps: getNumberValidator('eval_steps', [1, 2_147_483_647]),
+  num_train_epochs: getNumberValidator('num_train_epochs', [1, 200]),
+  max_length: getNumberValidator('max_length', [1, 131_072]),
+  warmup_ratio: getNumberValidator('warmup_ratio', [0, 1]),
+  save_steps: getNumberValidator('save_steps', [1, 2_147_483_647]),
+});
+
 const [Form, formApi] = useVbenForm({
   commonConfig: {
     // 所有表单项
@@ -16,7 +133,7 @@ const [Form, formApi] = useVbenForm({
       class: 'w-full',
     },
   },
-  layout: 'horizontal',
+  layout: 'vertical',
   // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
   wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
   handleSubmit: (values) => {
@@ -133,8 +250,29 @@ const [Form, formApi] = useVbenForm({
       },
       rules: 'required',
     },
+    {
+      component: 'Input',
+      fieldName: 'collapsibleTextArea',
+      label: 'vertical时可折叠',
+      componentProps: {
+        type: 'textarea',
+      },
+      collapsible: true,
+    },
+    {
+      component: VbenCollapsibleParams,
+      componentProps: {
+        params: paramsSchema,
+      },
+      modelPropName: 'value',
+      fieldName: 'params',
+      label: '参数配置',
+      formItemClass: 'col-span-2',
+      rules: paramsValidator,
+    },
   ],
 });
+
 function setFormValues() {
   formApi.setValues({
     string: 'string',
@@ -143,20 +281,43 @@ function setFormValues() {
     radioButton: 'C',
     checkbox: ['A', 'C'],
     date: Date.now(),
+    params: {
+      micro_batch_size: 8,
+      learning_rate: 1e-5,
+      eval_steps: 50,
+      num_train_epochs: 3,
+      max_length: 32_768,
+      warmup_ratio: 0.05,
+      save_steps: 50,
+    },
   });
 }
 
 const [Modal, modalApi] = useVbenModal({
   connectedComponent: modalDemo,
 });
+
+function onLayoutChange(layout: string) {
+  formApi.setState({
+    layout,
+  });
+}
 </script>
 <template>
   <Page
     description="表单适配器重新包装了CheckboxGroup和RadioGroup,可以通过options属性传递选项数据(选项数据将作为子组件的属性)"
     title="表单演示"
   >
-    <NCard title="基础表单">
+    <NCard title="基础表单" header-extra-class="gap-4">
       <template #header-extra>
+        <NRadioGroup v-model:value="layout" @update:value="onLayoutChange">
+          <NRadioButton
+            v-for="layoutItem in layouts"
+            :key="layoutItem.value"
+            :value="layoutItem.value"
+            :label="layoutItem.label"
+          />
+        </NRadioGroup>
         <NButton type="primary" @click="setFormValues">设置表单值</NButton>
         <NButton type="primary" @click="modalApi.open()" class="ml-2">
           打开弹窗

+ 1 - 0
packages/@core/base/icons/src/lucide.ts

@@ -16,6 +16,7 @@ export {
   ChevronDown,
   ChevronLeft,
   ChevronRight,
+  ChevronsDown,
   ChevronsLeft,
   ChevronsRight,
   Circle,

+ 0 - 4
packages/@core/ui-kit/form-ui/src/components/form-actions.vue

@@ -30,10 +30,6 @@ const submitButtonOptions = computed(() => {
   };
 });
 
-// const isQueryForm = computed(() => {
-//   return !!unref(rootProps).showCollapseButton;
-// });
-
 async function handleSubmit(e: Event) {
   e?.preventDefault();
   e?.stopPropagation();

+ 102 - 58
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

@@ -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) {
@@ -296,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);
@@ -335,6 +356,7 @@ onUnmounted(() => {
             {
               'mr-2 shrink-0 justify-end': !isVertical,
               'mb-1 flex-row': isVertical,
+              'self-start': shouldCollapsible && !isVertical,
             },
             labelClass,
           )
@@ -348,65 +370,87 @@ onUnmounted(() => {
         <template v-if="label">
           <VbenRenderContent :content="label" />
         </template>
+        <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,
+              }"
+            />
+          </Button>
+        </template>
       </FormLabel>
       <div class="flex-auto overflow-hidden p-px">
-        <div :class="cn('relative flex w-full items-center', wrapperClass)">
-          <FormControl :class="cn(controlClass)">
-            <slot
-              v-bind="{
-                ...slotProps,
-                ...createComponentProps(slotProps),
-                disabled: shouldDisabled,
-                isInValid,
-              }"
-            >
-              <component
-                :is="FieldComponent"
-                ref="fieldComponentRef"
-                :class="{
-                  'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
+        <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>

+ 1 - 0
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue

@@ -26,6 +26,7 @@ const props = defineProps<Props>();
     <VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
       <VbenRenderContent :content="help" />
     </VbenHelpTooltip>
+    <slot name="extra"></slot>
     <span v-if="colon && label" class="ml-0.5">:</span>
   </FormLabel>
 </template>

+ 9 - 0
packages/@core/ui-kit/form-ui/src/types.ts

@@ -145,6 +145,10 @@ type ComponentProps =
   | MaybeComponentProps;
 
 export interface FormCommonConfig {
+  /**
+   * 是否可折叠的
+   */
+  collapsible?: boolean;
   /**
    * 在Label后显示一个冒号
    */
@@ -157,6 +161,11 @@ export interface FormCommonConfig {
    * 所有表单项的控件样式
    */
   controlClass?: string;
+  /**
+   * 默认折叠
+   * @default false
+   */
+  defaultCollapsed?: boolean;
   /**
    * 所有表单项的禁用状态
    * @default false

+ 98 - 0
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params-item.vue

@@ -0,0 +1,98 @@
+<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') {
+    return {
+      step: props.data.option.step ?? 1,
+      min: props.data.option.min,
+      max: props.data.option.max,
+      precision: props.data.option.precision ?? 0,
+    };
+  }
+
+  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;
+    }
+  }
+});
+
+function reset() {
+  modelValue.value = props.data.defaultValue;
+}
+
+defineExpose({
+  reset,
+});
+</script>
+
+<template>
+  <div class="body-row">
+    <div class="body-cell">{{ data.key }}</div>
+    <div class="body-cell">
+      <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="data.option.min && data.option.max">
+          [{{ data.option.min }},{{ data.option.max }}]
+        </span>
+        <span v-if="data.option.step && data.option.step !== 1">
+          step:{{ data.option.step }}
+        </span>
+      </div>
+    </div>
+    <div class="body-cell w-full">
+      <p
+        class="line-clamp-2"
+        v-tippy="{
+          content: data.description,
+        }"
+      >
+        {{ data.description }}
+      </p>
+    </div>
+  </div>
+</template>
+<style lang="css" scoped>
+.body-row {
+  &:not(:last-of-type) {
+    @apply border-b;
+  }
+}
+</style>

+ 230 - 0
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params.vue

@@ -0,0 +1,230 @@
+<script setup lang="ts">
+import type { CollapsibleParamSchema } from './type';
+
+import { computed, nextTick, ref, useTemplateRef } 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');
+const visibleRefs = useTemplateRef('visibleRefs');
+const collapsibleRefs = useTemplateRef('collapsibleRefs');
+
+const { b } = useNamespace('collapsible-params');
+
+const open = ref(props.defaultOpen);
+
+const visibleRows = computed(() => {
+  return props.params.slice(0, props.visibleCount);
+});
+
+const collapsibleRows = computed(() => {
+  return props.params.slice(props.visibleCount);
+});
+
+const bodyStyle = computed(() => {
+  return {
+    maxHeight:
+      typeof props.maxHeight === 'number'
+        ? `${props.maxHeight}px`
+        : props.maxHeight,
+  };
+});
+
+function init() {
+  for (const param of props.params) {
+    modelValue.value[param.key] = param.defaultValue ?? null;
+  }
+}
+
+function toggleCollapsed() {
+  open.value = !open.value;
+}
+
+async function onParamValueChange(value: any, key: string) {
+  await nextTick();
+  emits('update:value', modelValue.value, key);
+}
+
+function resetValue() {
+  if (visibleRefs.value)
+    for (const rowRef of visibleRefs.value) {
+      rowRef.reset();
+    }
+
+  if (collapsibleRefs.value)
+    for (const rowRef of collapsibleRefs.value) {
+      rowRef.reset();
+    }
+
+  init();
+}
+
+init();
+
+defineExpose({
+  toggleCollapsed,
+  resetValue,
+});
+</script>
+
+<template>
+  <CollapsibleRoot v-model:open="open" :class="b()" :unmount-on-hide="false">
+    <div class="wrapper">
+      <div class="w-full min-w-fit">
+        <div class="header">
+          <div class="header-cell">参数名称</div>
+          <div class="header-cell">配置</div>
+          <div class="header-cell">说明</div>
+        </div>
+
+        <div
+          class="body"
+          :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" v-if="!open && collapsibleRows.length > 0"></div>
+    <div
+      class="trigger-bar"
+      :class="{
+        collapsed: !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 ? '收起' : '展开' }}
+      </CollapsibleTrigger>
+    </div>
+  </CollapsibleRoot>
+</template>
+<style lang="css">
+.vben-collapsible-params {
+  @apply border rounded-[0.5rem] flex flex-col w-full overflow-hidden;
+
+  .wrapper {
+    --column1: 11.25rem;
+    --column2: 18.25rem;
+    --column3: 27.5rem;
+
+    @apply w-full relative flex flex-col overflow-x-auto;
+
+    /* min-width: calc(var(--column1) + var(--column2) + var(--column3)); */
+
+    .header,
+    .body {
+      @apply w-full flex-none flex;
+    }
+
+    .header {
+      @apply bg-accent items-center rounded-t-[0.5rem] border-b;
+    }
+
+    .body {
+      @apply flex-col overflow-x-hidden;
+    }
+
+    .body-row {
+      @apply flex items-center w-full flex-nowrap;
+    }
+
+    .header-cell,
+    .body-cell {
+      @apply py-2 px-5 leading-[1.5rem] flex items-center  flex-nowrap;
+
+      &: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);
+      }
+    }
+  }
+
+  .gutter {
+    @apply h-[1.5rem];
+  }
+
+  .trigger-bar {
+    @apply flex min-h-[2rem] border-t px-5 py-1  rounded-b-[0.5rem] z-1;
+
+    &.collapsed {
+      @apply absolute bottom-[1px] left-[1px] right-[1px] border-t-0 pt-6;
+
+      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>

+ 77 - 0
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue

@@ -0,0 +1,77 @@
+<script setup lang="ts">
+import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui';
+
+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 - 0
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts

@@ -0,0 +1,4 @@
+export { default as VbenCollapsibleParams } from './collapsible-params.vue';
+export { default as VbenCollapsible } from './collapsible.vue';
+
+export * from './type';

+ 15 - 0
packages/@core/ui-kit/shadcn-ui/src/components/collapsible/type.ts

@@ -0,0 +1,15 @@
+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;
+}

+ 1 - 0
packages/@core/ui-kit/shadcn-ui/src/components/index.ts

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

+ 2 - 1
playground/src/locales/langs/en-US/examples.json

@@ -25,7 +25,8 @@
     "upload-urls": "Urls after file upload",
     "file": "file",
     "crop-image": "Crop image",
-    "upload-image": "Click to upload image"
+    "upload-image": "Click to upload image",
+    "collapsible": "Collapsible FormItem Content"
   },
   "vxeTable": {
     "title": "Vxe Table",

+ 2 - 1
playground/src/locales/langs/zh-CN/examples.json

@@ -28,7 +28,8 @@
     "upload-urls": "文件上传后的网址",
     "file": "文件",
     "crop-image": "裁剪图片",
-    "upload-image": "点击上传图片"
+    "upload-image": "点击上传图片",
+    "collapsible": "单项表单折叠"
   },
   "vxeTable": {
     "title": "Vxe 表格",

+ 8 - 0
playground/src/router/routes/modules/examples.ts

@@ -102,6 +102,14 @@ const routes: RouteRecordRaw[] = [
               title: $t('examples.form.scrollToError'),
             },
           },
+          {
+            name: 'FormCollapsibleExample',
+            path: '/examples/form/collapsible-test',
+            component: () => import('#/views/examples/form/collapsible.vue'),
+            meta: {
+              title: $t('examples.form.collapsible'),
+            },
+          },
         ],
       },
       {

+ 210 - 0
playground/src/views/examples/form/collapsible.vue

@@ -0,0 +1,210 @@
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+import { Page } from '@vben/common-ui';
+
+import { VbenCollapsibleParams } from '@vben-core/shadcn-ui';
+
+import { Button, Card, message, RadioGroup } from 'ant-design-vue';
+
+import { useVbenForm, z } from '#/adapter/form';
+
+import DocButton from '../doc-button.vue';
+
+const layouts = [
+  { label: 'Vertical', value: 'vertical' },
+  { label: 'Horizontal', value: 'horizontal' },
+];
+const layout = ref(layouts[0].value);
+
+function getNumberValidator(key: string, limit?: [number, number]) {
+  const validator = z.number({
+    required_error: `${key} 值不能为空`,
+    invalid_type_error: `${key} 值只能为数字`,
+  });
+
+  if (limit) {
+    validator.min(limit[0], { message: `${key} 值不在区间范围内` });
+    validator.max(limit[1], { message: `${key} 值不在区间范围内` });
+  }
+
+  return validator.default(null);
+}
+
+const paramsSchema = [
+  {
+    key: 'micro_batch_size',
+    description: `批次大小,代表模型训练过程中,模型更新模型参数的数据步长,可理解为模型每看多少数据即更新一次模型参数,
+    一般建议的批次大小为16/32,表示模型每看16或32条数据即更新一次参数`,
+    option: {
+      min: 8,
+      max: 1024,
+      step: 8,
+    },
+  },
+  {
+    key: 'learning_rate',
+    description:
+      '学习率,代表每次更新数据的增量参数权重,学习率数值越大参数变化越大,对模型影响越大',
+    option: {
+      step: 1e-4,
+      type: 'exponential',
+    },
+  },
+  {
+    key: 'eval_steps',
+    description:
+      '验证步数,训练阶段针模型的验证间隔步长,用于阶段性评估模型训练准确率、训练损失',
+    option: {
+      min: 1,
+      max: 2_147_483_647,
+    },
+  },
+  {
+    key: 'num_train_epochs',
+    description:
+      '循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是1-3遍即可,可依据需求进行调整',
+    option: {
+      min: 1,
+      max: 200,
+    },
+  },
+  {
+    key: 'max_length',
+    description: `序列长度,单个训练数据样本的最大长度,超出配置长度将丢弃`,
+    option: {
+      min: 500,
+      max: 131_072,
+    },
+  },
+  {
+    key: 'warmup_ratio',
+    description: '学习率预热比例,学习率预热阶段占总训练步数的比例',
+    option: {
+      min: 0,
+      max: 1,
+      precision: 2,
+      step: 0.01,
+    },
+  },
+  {
+    key: 'save_steps',
+    description: 'Checkpoint保存间隔',
+    option: {
+      min: 1,
+      max: 2_147_483_647,
+    },
+  },
+] as CollapsibleParamSchema[];
+
+const paramsValidator = z.object({
+  micro_batch_size: getNumberValidator('micro_batch_size', [8, 1024]),
+  learning_rate: getNumberValidator('learning_rate'),
+  eval_steps: getNumberValidator('eval_steps', [1, 2_147_483_647]),
+  num_train_epochs: getNumberValidator('num_train_epochs', [1, 200]),
+  max_length: getNumberValidator('max_length', [1, 131_072]),
+  warmup_ratio: getNumberValidator('warmup_ratio', [0, 1]),
+  save_steps: getNumberValidator('save_steps', [1, 2_147_483_647]),
+});
+
+const [BaseForm, baseFormApi] = useVbenForm({
+  // 所有表单项共用,可单独在表单内覆盖
+  commonConfig: {
+    // 在label后显示一个冒号
+    colon: true,
+    // 所有表单项
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
+  // 提交函数
+  handleSubmit: onSubmit,
+  // 垂直布局,label和input在不同行,值为vertical
+  // 水平布局,label和input在同一行
+  layout: 'vertical',
+  schema: [
+    {
+      component: VbenCollapsibleParams,
+      componentProps: {
+        params: paramsSchema,
+        // maxHeight: 200, //限制最大高度,展开后可滚动
+      },
+      modelPropName: 'value',
+      fieldName: 'params',
+      label: '参数配置',
+      formItemClass: 'col-span-2 items-baseline',
+      rules: paramsValidator,
+    },
+    {
+      component: 'RichEditor',
+      fieldName: 'richEditor',
+      label: '富文本',
+      formItemClass: 'col-span-3 items-baseline',
+      collapsible: true,
+      defaultCollapsed: false, // 默认false
+    },
+  ],
+  // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
+  wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
+});
+
+function onSubmit(values: Record<string, any>) {
+  message.info({
+    content: `form values: ${JSON.stringify(values)}`,
+  });
+}
+
+function handleSetFormValue() {
+  baseFormApi.setFieldValue('params', {
+    micro_batch_size: 8,
+    learning_rate: 1e-5,
+    eval_steps: 50,
+    num_train_epochs: 3,
+    max_length: 32_768,
+    warmup_ratio: 0.05,
+    save_steps: 50,
+  });
+}
+
+function onLayoutChange(layout: string) {
+  baseFormApi.setState({
+    layout,
+  });
+}
+</script>
+
+<template>
+  <Page
+    auto-content-height
+    content-class="flex flex-col gap-4"
+    title="可折叠表单项"
+  >
+    <template #description>
+      <div class="text-muted-foreground">
+        <p>可折叠表单项、以及可折叠参数配置组件示例</p>
+      </div>
+    </template>
+    <template #extra>
+      <DocButton class="mb-2" path="/components/common-ui/vben-form" />
+    </template>
+    <Card title="基础示例">
+      <template #extra>
+        <div class="inline-flex items-center gap-4!">
+          <RadioGroup
+            :options="layouts"
+            option-type="button"
+            v-model:value="layout"
+            @update:value="onLayoutChange"
+          >
+            设置表单值
+          </RadioGroup>
+          <Button type="primary" @click="handleSetFormValue">设置表单值</Button>
+        </div>
+      </template>
+      <div class="w-full overflow-hidden">
+        <BaseForm />
+      </div>
+    </Card>
+  </Page>
+</template>

+ 3 - 0
pnpm-lock.yaml

@@ -848,6 +848,9 @@ importers:
 
   apps/web-naive:
     dependencies:
+      '@vben-core/shadcn-ui':
+        specifier: workspace:*
+        version: link:../../packages/@core/ui-kit/shadcn-ui
       '@vben/access':
         specifier: workspace:*
         version: link:../../packages/effects/access