Browse Source
feat: add collapsible 组件,form表单增加单项可折叠,支持schema配置默认关闭/开启 - shadcn-ui 增加 collapsible组件,collapsible-params组件 - form新增支持单项折叠 - collapsible-params组件在Form表单应用pull/7809/head
18 changed files with 929 additions and 68 deletions
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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,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; |
|||
} |
|||
@ -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> |
|||
Loading…
Reference in new issue