This commit is contained in:
xingyu4j
2026-07-20 17:39:20 +08:00
34 changed files with 2188 additions and 331 deletions

View File

@@ -360,6 +360,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

View File

@@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

View File

@@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -137,14 +137,23 @@ const defaultPreferences: Preferences = {
},
widget: {
fullscreen: true,
fullscreenButtonPosition: 'header',
globalSearch: true,
globalSearchButtonPosition: 'header',
languageToggle: true,
languageToggleButtonPosition: 'header',
lockScreen: true,
lockScreenButtonPosition: 'header',
logoutButtonPosition: 'header',
notification: true,
notificationButtonPosition: 'header',
refresh: true,
refreshButtonPosition: 'header',
sidebarToggle: true,
themeToggle: true,
themeToggleButtonPosition: 'header',
timezone: true,
timezoneButtonPosition: 'header',
},
};

View File

@@ -365,22 +365,40 @@ interface TransitionPreferences {
interface WidgetPreferences {
/** 是否启用全屏部件 */
fullscreen: boolean;
/** 全屏按钮位置 */
fullscreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用全局搜索部件 */
globalSearch: boolean;
/** 全局搜索按钮位置 */
globalSearchButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用语言切换部件 */
languageToggle: boolean;
/** 语言切换按钮位置 */
languageToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否开启锁屏功能 */
lockScreen: boolean;
/** 锁屏按钮位置 */
lockScreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 退出登录按钮位置 */
logoutButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示通知部件 */
notification: boolean;
/** 通知按钮位置 */
notificationButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 显示刷新按钮 */
refresh: boolean;
/** 刷新按钮位置 */
refreshButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示侧边栏显示/隐藏部件 */
sidebarToggle: boolean;
/** 是否显示主题切换部件 */
themeToggle: boolean;
/** 主题切换按钮位置 */
themeToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示时区部件 */
timezone: boolean;
/** 时区按钮位置 */
timezoneButtonPosition: 'header' | 'none' | 'user-dropdown';
}
interface Preferences {

View File

@@ -101,6 +101,47 @@ describe('formApi', () => {
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat: (
value: any,
setValue: any,
_values: any,
ctx: any,
) => {
setValue('normalizedName', value?.trim());
setValue('$root.firstRow', ctx?.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
} as any,
],
});
const formActions: any = {
meta: {},
values: {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
},
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
firstRow: 1,
});
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@@ -229,6 +270,35 @@ describe('updateSchema', () => {
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should update child schema by parent path', () => {
instance.state = {
schema: [
{
children: [
{ component: 'text', fieldName: 'name', label: 'Name' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
fieldName: 'contacts',
type: 'array',
} as any,
],
};
instance.updateSchema([
{
fieldName: 'contacts.name',
label: 'Full Name',
},
]);
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
'Full Name',
);
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
'Phone',
);
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { FormSchema } from '../types';
import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue';
@@ -9,11 +9,12 @@ import {
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { cn, set } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
@@ -23,6 +24,8 @@ const props = withDefaults(
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/**
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
*/
@@ -30,6 +33,8 @@ const props = withDefaults(
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
@@ -51,6 +56,8 @@ const props = withDefaults(
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
@@ -67,12 +74,33 @@ const { fields, push, remove } = useFieldArray<Record<string, any>>(
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
const gridStyle = computed(() => {
const columns = [
...(props.showIndex ? ['3rem'] : []),
...props.schema.map(() => 'minmax(0, 1fr)'),
'4rem',
];
return {
gridTemplateColumns: columns.join(' '),
};
});
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
const row: Record<string, any> = {};
props.schema.forEach((col) => {
const value =
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
? col.defaultValue
: 'type' in col && col.type === 'array'
? []
: null;
set(row, col.fieldName, value);
});
return row;
}
function addRow() {
@@ -89,83 +117,101 @@ function removeRow(index: number) {
remove(index);
}
/**
* 把列定义转换为子单元格 FormField 所需的 props。
* - fieldName 替换为嵌套路径 `name[index].fieldName`,让校验与取值落在数组元素上
* - hideLabel表头已展示列名单元格不重复显示
*/
function cellProps(col: FormSchema, index: number) {
return {
...col,
commonComponentProps: {},
disabled: props.disabled,
fieldName: `${arrayPath.value}[${index}].${col.fieldName}`,
formFieldProps: {},
hideLabel: true,
};
function rowSchemas(index: number) {
return props.schema.map((col) =>
createArrayChildSchema(col as never, {
arrayField: arrayPath.value,
commonConfig: props.commonConfig,
disabled: props.disabled,
globalCommonConfig: props.globalCommonConfig,
index,
}),
);
}
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<table class="w-full border-collapse">
<thead>
<tr class="border-border border-b">
<th
v-if="showIndex"
class="text-muted-foreground w-12 px-2 py-2 text-left text-sm font-normal"
>
#
</th>
<th
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</th>
<th
class="text-muted-foreground w-16 px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b align-top"
<div class="border-border/70 overflow-hidden rounded-md border">
<div
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<td v-if="showIndex" class="text-muted-foreground px-2 py-3 text-sm">
{{ index + 1 }}
</td>
<td v-for="col in schema" :key="col.fieldName" class="px-2 py-2">
<FormField v-bind="cellProps(col, index)" />
</td>
<td class="px-2 py-3">
<VbenIconButton
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</td>
</tr>
</tbody>
</table>
#
</div>
<div
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</div>
<div
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</div>
</div>
<div
v-if="fields.length === 0"
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
>
{{ emptyText }}
<div
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
>
<span class="sm:hidden">#</span>
{{ index + 1 }}
</div>
<template
v-for="(childSchema, childIndex) in rowSchemas(index)"
:key="childSchema.fieldName"
>
<div class="min-w-0 py-2 sm:px-2">
<div
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
>
<VbenRenderContent :content="schema?.[childIndex]?.label" />
</div>
<FormField
v-bind="childSchema"
:class="childSchema.formItemClass"
/>
</div>
</template>
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
<VbenIconButton
type="button"
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</div>
</div>
<div
v-if="fields.length === 0"
class="text-muted-foreground py-6 text-center text-sm"
>
{{ emptyText }}
</div>
</div>
<VbenButton
variant="outline"
size="sm"
type="button"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"

View File

@@ -30,6 +30,10 @@ import {
} from '@vben-core/shared/utils';
import { resolveFieldNamePath } from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
function getDefaultState(): VbenFormProps {
return {
@@ -393,25 +397,10 @@ export class FormApi {
);
return;
}
const currentSchema = [...(this.state?.schema ?? [])];
const updatedMap: Record<string, any> = {};
updated.forEach((item) => {
if (item.fieldName) {
updatedMap[item.fieldName] = item;
}
});
currentSchema.forEach((schema, index) => {
const updatedData = updatedMap[schema.fieldName];
if (updatedData) {
currentSchema[index] = mergeWithArrayOverride(
updatedData,
schema,
) as FormSchema;
}
});
const currentSchema = this.updateSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
@@ -456,6 +445,79 @@ export class FormApi {
return validateResult;
}
private applyValueFormatBySchemas(
schemas: FormSchema[],
values: Record<string, any>,
parentPath?: string,
parentContext?: {
arrayField?: string;
row?: Record<string, any>;
rowIndex?: number;
rowPath?: string;
},
) {
schemas.forEach((schema) => {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? this.resolveValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values,
row,
};
const children = getFormArraySchemaChildren(schema);
if (children.length > 0) {
const arrayValue = this.resolveValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
this.applyValueFormatBySchemas(
children as FormSchema[],
values,
rowPath,
{
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
},
);
});
}
}
if (schema.valueFormat) {
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(
values,
this.resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values,
schemaContext,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
}
});
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -611,30 +673,7 @@ export class FormApi {
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);
}
});
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
return values;
};
@@ -654,6 +693,27 @@ export class FormApi {
});
};
private resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -666,6 +726,51 @@ export class FormApi {
return get(values, fieldName);
}
private resolveValueFormatFieldName(fieldName: string, parentPath?: string) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as FormSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as FormSchema;
}
return schema;
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -680,6 +785,48 @@ export class FormApi {
set(values, fieldName, value);
}
private updateSchemaList(
currentSchema: FormSchema[],
updated: Partial<FormSchema>[],
): FormSchema[] {
return currentSchema.map((schema): FormSchema => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
}
const children = getFormArraySchemaChildren(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated
.map((item) => {
const fieldName = item.fieldName
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName
? ({
...item,
fieldName,
} as Partial<FormSchema>)
: undefined;
})
.filter(Boolean) as Partial<FormSchema>[];
if (childUpdates.length === 0) {
return schema;
}
return this.setSchemaChildren(
schema,
this.updateSchemaList(children as FormSchema[], childUpdates),
);
});
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];

View File

@@ -78,6 +78,9 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
const collapseOpen = ref(!defaultCollapsed);
function getFormApi(): FormActions {
@@ -338,7 +341,7 @@ onUnmounted(() => {
<FormItem
v-show="isShow"
:class="{
'form-valid-error': isInValid,
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
@@ -406,7 +409,7 @@ onUnmounted(() => {
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,
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"

View File

@@ -2,27 +2,19 @@
import type { GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type {
FormCommonConfig,
FormRenderProps,
FormSchema,
FormShape,
} from '../types';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed } from 'vue';
import { Form } from '@vben-core/shadcn-ui';
import {
cn,
isFunction,
isString,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
interface Props extends FormRenderProps {}
@@ -95,78 +87,23 @@ const formCollapsed = computed(() => {
return props.collapsed && isCalculated.value;
});
const computedSchema = computed(
(): (Omit<FormSchema, 'formFieldProps'> & {
commonComponentProps: Record<string, any>;
formFieldProps: Record<string, any>;
})[] => {
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
// 处理函数形式的formItemClass
let resolvedSchemaFormItemClass = schema.formItemClass;
if (isFunction(schema.formItemClass)) {
try {
resolvedSchemaFormItemClass = schema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
colon,
disabled,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideLabel,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...schema,
commonComponentProps: componentProps,
componentProps: schema.componentProps,
controlClass: cn(controlClass, schema.controlClass),
formFieldProps: {
...formFieldProps,
...schema.formFieldProps,
},
formItemClass: cn(
'shrink-0',
{ hidden },
formItemClass,
resolvedSchemaFormItemClass,
),
labelClass: cn(labelClass, schema.labelClass),
};
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
});
},
);
});
});
</script>
<template>

View File

@@ -0,0 +1,341 @@
import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormFieldProps,
FormItemDependencies,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
} from '../types';
import {
get,
isFunction,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
commonComponentProps: MaybeComponentProps;
formFieldProps: Record<string, any>;
formItemClass: string;
};
interface CreateFormFieldSchemaOptions {
commonConfig?: FormCommonConfig;
disabled?: boolean;
forceHideLabel?: boolean;
globalCommonConfig?: FormCommonConfig;
hidden?: boolean;
}
interface CreateArrayChildSchemaOptions extends CreateFormFieldSchemaOptions {
arrayField: string;
index: number;
}
function createSchemaContext(
baseContext: FormSchemaContext,
values?: Partial<Record<string, any>>,
): FormSchemaContext {
const rootValues = values as Record<string, any> | undefined;
return {
...baseContext,
rootValues,
row:
baseContext.rowPath && rootValues
? get(rootValues, baseContext.rowPath)
: undefined,
};
}
function scopeRowFieldName(rowPath: string, fieldName: string) {
if (!fieldName) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${rowPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === rowPath || fieldName.startsWith(`${rowPath}.`)) {
return fieldName;
}
return `${rowPath}.${fieldName}`;
}
function wrapComponentProps(
componentProps: AnyFormSchema['componentProps'],
baseContext: FormSchemaContext,
) {
if (!isFunction(componentProps)) {
return componentProps;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
componentProps(values, actions, createSchemaContext(baseContext, values));
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapRenderComponentContent(
render: AnyFormSchema['renderComponentContent'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
if (!isFunction(handler)) {
return handler;
}
return ((
values: Partial<Record<string, any>>,
actions: FormActions,
controller: any,
) =>
handler(
values,
actions,
controller,
createSchemaContext(baseContext, values),
)) as T;
}
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
) {
if (!dependencies) {
return dependencies;
}
const rowPath = baseContext.rowPath;
if (!rowPath) {
return dependencies;
}
return {
...dependencies,
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
if: wrapDependencyFn(dependencies.if, baseContext),
required: wrapDependencyFn(dependencies.required, baseContext),
rules: wrapDependencyFn(dependencies.rules, baseContext),
show: wrapDependencyFn(dependencies.show, baseContext),
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
triggerFields:
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [],
};
}
function createArrayComponentProps(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const componentProps = schema.componentProps;
const arrayProps = 'arrayProps' in schema ? schema.arrayProps : undefined;
const children = getFormArraySchemaChildren(schema);
const commonConfig = options.commonConfig;
const globalCommonConfig = options.globalCommonConfig;
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
...arrayProps,
...componentProps(values, actions),
commonConfig,
globalCommonConfig,
...schemaProps,
});
}
return {
...arrayProps,
...componentProps,
commonConfig,
globalCommonConfig,
...schemaProps,
};
}
function createArrayFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
Reflect.deleteProperty(restSchema, 'arrayProps');
Reflect.deleteProperty(restSchema, 'children');
Reflect.deleteProperty(restSchema, 'type');
return {
...restSchema,
component: 'VbenFormFieldArray',
componentProps: createArrayComponentProps(schema, options),
};
}
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children;
}
const componentProps = schema.componentProps;
if (
!isFunction(componentProps) &&
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema;
}
return [];
}
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
return (
('type' in schema && schema.type === 'array') ||
schema.component === 'VbenFormFieldArray' ||
getFormArraySchemaChildren(schema).length > 0
);
}
export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
return scopeRowFieldName(rowPath, fieldName);
}
export function createFormFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions = {},
): NormalizedFormFieldSchema {
const commonConfig = mergeWithArrayOverride(
options.commonConfig ?? {},
options.globalCommonConfig ?? {},
);
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = commonConfig;
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
try {
resolvedSchemaFormItemClass = normalizedSchema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
colon,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps: componentProps as MaybeComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
.join(' '),
formFieldProps: {
...formFieldProps,
...normalizedSchema.formFieldProps,
},
formItemClass: [
'shrink-0',
options.hidden ? 'hidden' : '',
formItemClass,
resolvedSchemaFormItemClass,
]
.filter(Boolean)
.join(' '),
labelClass: [labelClass, normalizedSchema.labelClass]
.filter(Boolean)
.join(' '),
disabled: options.disabled ?? normalizedSchema.disabled ?? disabled,
hideLabel:
options.forceHideLabel ?? normalizedSchema.hideLabel ?? hideLabel,
} as NormalizedFormFieldSchema;
}
export function createArrayChildSchema(
schema: AnyFormSchema,
options: CreateArrayChildSchemaOptions,
): NormalizedFormFieldSchema {
const rowPath = `${options.arrayField}[${options.index}]`;
const fieldName = resolveArrayChildFieldName(rowPath, schema.fieldName);
const baseContext: FormSchemaContext = {
arrayField: options.arrayField,
fieldName,
originalFieldName: schema.fieldName,
rowIndex: options.index,
rowPath,
};
return createFormFieldSchema(
{
...schema,
componentProps: wrapComponentProps(schema.componentProps, baseContext),
dependencies: scopeDependencies(schema.dependencies, baseContext),
fieldName,
help: wrapCustomParamsRender(schema.help, baseContext),
renderComponentContent: wrapRenderComponentContent(
schema.renderComponentContent,
baseContext,
),
},
{
commonConfig: options.commonConfig,
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: options.globalCommonConfig,
},
);
}

View File

@@ -4,6 +4,7 @@ export type {
BaseFormComponentType,
ExtendedFormApi,
FormLayout,
FormSchemaContext,
VbenFormFieldArrayProps,
VbenFormProps,
FormSchema as VbenFormSchema,

View File

@@ -66,6 +66,23 @@ export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export type FormActions = FormContext<GenericObject>;
export interface FormSchemaContext {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
fieldName?: string;
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: Record<string, any>;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
rowIndex?: number;
/** 当前行路径,例如 contacts[0] */
rowPath?: string;
}
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
@@ -73,6 +90,7 @@ export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => Component | string)
| string;
@@ -87,18 +105,21 @@ type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => T;
type FormItemDependenciesConditionWithRules = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
@@ -145,6 +166,7 @@ type ComponentProps =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => MaybeComponentProps)
| MaybeComponentProps;
@@ -233,12 +255,14 @@ export interface FormCommonConfig {
type RenderComponentContentType = (
value: Partial<Record<string, any>>,
api: FormActions,
ctx?: FormSchemaContext,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => P & Record<string, any>)
| (P & Record<string, any>);
@@ -253,6 +277,7 @@ export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
ctx?: FormSchemaContext,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
@@ -303,10 +328,32 @@ type FormSchemaFallback<T extends BaseFormComponentType> = {
componentProps?: ComponentProps;
} & FormSchemaBody;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
> =
| FormArraySchema<T, P>
| FormSchemaDiscriminated<T, P>
| FormSchemaFallback<T>;
/**
* 数组编辑器VbenFormFieldArray的组件参数
@@ -319,17 +366,23 @@ export interface VbenFormFieldArrayProps<
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema: FormSchema<T, P>[];
schema?: FormSchema<T, P>[];
/** 是否显示序号列 */
showIndex?: boolean;
}

View File

@@ -13,7 +13,7 @@ interface Props {
*/
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/**
* @zh_CN logo高度 只在 logoMode=full时
* @zh_CN logo高度 只在 logoMode=full时
*/
fullLogoHeight?: number | string;
/**

View File

@@ -0,0 +1,240 @@
import type { RouteRecordRaw } from '@vben/types';
import { describe, expect, it } from 'vitest';
import { generateAccessible } from '../accessible';
// generateAccessible 会操作传入的 router 实例。这里用最小 stub 覆盖它实际调用的方法:
// - getRoutes(): 返回 [] -> 不存在根路由 '/', 走 router.addRoute 分支
// - addRoute/removeRoute: 空实现
// 我们只断言返回的 accessibleRoutes 上自动生成的 redirect。
function createRouterStub() {
return {
addRoute: () => {},
getRoutes: () => [],
removeRoute: () => {},
} as any;
}
async function generate(routes: RouteRecordRaw[]) {
const { accessibleRoutes } = await generateAccessible('frontend', {
router: createRouterStub(),
routes,
});
return accessibleRoutes;
}
function findByName(
routes: RouteRecordRaw[],
name: string,
): RouteRecordRaw | undefined {
for (const route of routes) {
if (route.name === name) {
return route;
}
if (route.children) {
const found = findByName(route.children as RouteRecordRaw[], name);
if (found) {
return found;
}
}
}
return undefined;
}
describe('generateAccessible - redirect normalization', () => {
it('不为动态参数(:id)首子路由的父级生成 redirect', async () => {
const routes = [
{
name: 'DyeSets',
path: 'dye-sets',
children: [
{
name: 'DyeSetDetail',
path: ':id',
meta: { hideInMenu: true, title: 'detail' },
},
],
meta: { title: 'dye-sets' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'DyeSets')?.redirect).toBeUndefined();
});
it('父级为对象 redirect({name}) 且含 :id 子路由时不抛异常且不生成 redirect', async () => {
const routes = [
{
name: 'Production',
path: '/production',
redirect: { name: 'ProductionTasks' },
children: [
{
name: 'ProductionTasks',
path: 'production-tasks',
children: [
{
name: 'ProductionTaskDetail',
path: ':id',
meta: { hideInMenu: true, title: 'detail' },
},
{
name: 'ProductionTaskMatch',
path: ':id/match',
meta: { hideInMenu: true, title: 'match' },
},
],
meta: { title: 'tasks' },
},
],
meta: { title: 'production' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
// 顶级对象 redirect 保持不变
expect(findByName(result, 'Production')?.redirect).toEqual({
name: 'ProductionTasks',
});
// :id 首子路由的父级不生成 redirect
expect(findByName(result, 'ProductionTasks')?.redirect).toBeUndefined();
});
it('父级为对象 redirect 时,普通相对首子路由回退用 parent.path 拼接', async () => {
const routes = [
{
name: 'Setting',
path: '/setting',
redirect: { name: 'SettingService' },
children: [
{
name: 'SettingGroup',
path: 'group',
children: [
{
name: 'SettingService',
path: 'service',
meta: { title: 'service' },
},
],
meta: { title: 'group' },
},
],
meta: { title: 'setting' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'SettingGroup')?.redirect).toBe(
'/setting/group/service',
);
});
it('深层嵌套(上游风格)相对路径逐级生成正确的累计绝对 redirect', async () => {
const routes = [
{
name: 'Demos',
path: '/demos',
children: [
{
name: 'NestedDemos',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: 'menu1',
meta: { title: 'menu1' },
},
{
name: 'Menu2Demo',
path: 'menu2',
children: [
{
name: 'Menu21Demo',
path: 'menu2-1',
meta: { title: 'menu2-1' },
},
],
meta: { title: 'menu2' },
},
],
meta: { title: 'nested' },
},
],
meta: { title: 'demos' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
// Demos 重定向到第一级子路由,子路由继续级联到叶子
expect(findByName(result, 'Demos')?.redirect).toBe('/demos/nested');
expect(findByName(result, 'NestedDemos')?.redirect).toBe(
'/demos/nested/menu1',
);
expect(findByName(result, 'Menu2Demo')?.redirect).toBe(
'/demos/nested/menu2/menu2-1',
);
});
it('首子路由为绝对路径(/foo)时不生成 redirect', async () => {
const routes = [
{
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
meta: { title: 'analytics' },
},
],
meta: { title: 'dashboard' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'Dashboard')?.redirect).toBeUndefined();
});
it('首子路由为空 path 时不生成 redirect', async () => {
const routes = [
{
name: 'HideChildrenParent',
path: 'hide-menu-children',
children: [
{
name: 'HideChildren',
path: '',
meta: { title: 'hide' },
},
],
meta: { title: 'parent' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'HideChildrenParent')?.redirect).toBeUndefined();
});
it('已存在的 redirect 保持不变', async () => {
const routes = [
{
name: 'Custom',
path: '/custom',
redirect: '/custom/keep',
children: [
{
name: 'CustomChild',
path: 'child',
meta: { title: 'child' },
},
],
meta: { title: 'custom' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep');
});
});

View File

@@ -148,11 +148,26 @@ async function generateRoutes(
return route;
}
if (parent && parent.redirect) {
const parentSplit = (parent.redirect as string).split('/');
// fork 定制:如果第一个子路由是动态路由(如 :id说明当前路由本身是一个
// “列表+详情”页面(渲染自身组件),不应自动重定向到未填充的动态参数,
// 否则地址栏会出现字面量 ":id" 或匹配失败导致 404。
// 详见对上游重构 commit f00a8812 的修复。
if (firstChild.path.startsWith(':')) {
return route;
}
// 拼接子路由的重定向绝对路径。
// - 当 parent.redirect 为字符串时,它已经是累计好的绝对路径,直接替换最后一段
// 即可正确支持任意层级的深层嵌套(如 /demos/nested/menu2/menu2-1
// - fork 定制:后端菜单可能传入对象形式的 redirect如 { name }),无法 split
// 此时回退到使用 parent.path 拼接(这类 parent 为顶级路由path 为绝对路径)。
if (parent && parent.redirect && isString(parent.redirect)) {
const parentSplit = parent.redirect.split('/');
parentSplit.splice(-1, 2, route.path, firstChild.path);
const redirectPath = parentSplit.join('/');
route.redirect = redirectPath;
} else if (parent && parent.redirect) {
route.redirect = `${parent.path}/${route.path}/${firstChild.path}`;
} else {
route.redirect = `${route.path}/${firstChild.path}`;
}

View File

@@ -44,7 +44,6 @@ const showComponent = (route: RouteLocationNormalizedLoadedGeneric) => {
v-if="getEnabledTransition"
:name="getTransitionName(route)"
appear
mode="out-in"
>
<KeepAlive
v-if="keepAlive"

View File

@@ -2,25 +2,38 @@
import { computed, useSlots } from 'vue';
import { useRefresh } from '@vben/hooks';
import { RotateCw } from '@vben/icons';
import { LockKeyhole, LogOut, RotateCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { useVbenModal } from '@vben-core/popup-ui';
import { VbenFullScreen, VbenIconButton } from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
import {
GlobalSearch,
LanguageToggle,
LockScreenModal,
PreferencesButton,
ThemeToggle,
TimezoneButton,
} from '../../widgets';
interface Props {
/**
* 头像
*/
avatar?: string;
/**
* Logo 主题
*/
theme?: string;
/**
* 用户文本
*/
text?: string;
}
defineOptions({
@@ -28,18 +41,79 @@ defineOptions({
});
withDefaults(defineProps<Props>(), {
avatar: '',
theme: 'light',
text: '',
});
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const emit = defineEmits<{
clearPreferencesAndLogout: [];
logout: [];
openLockScreen: [];
}>();
const REFERENCE_VALUE = 100;
const accessStore = useAccessStore();
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalSearchShortcutKey,
preferencesButtonPosition,
} = usePreferences();
const slots = useSlots();
const { refresh } = useRefresh();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
});
const [LogoutModal, logoutModalApi] = useVbenModal({
onConfirm() {
handleSubmitLogout();
},
});
function handleOpenLock() {
lockModalApi.open();
}
function handleSubmitLock(lockScreenPassword: string) {
lockModalApi.close();
accessStore.lockScreen(lockScreenPassword);
}
function handleLogout() {
logoutModalApi.open();
}
function handleSubmitLogout() {
emit('logout');
logoutModalApi.close();
}
// 快捷键
if (preferences.shortcutKeys.enable) {
const keys = useMagicKeys();
const lockKey = keys['Alt+KeyL'];
const logoutKey = keys['Alt+KeyQ'];
if (lockKey) {
whenever(lockKey, () => {
if (globalLockScreenShortcutKey?.value) {
handleOpenLock();
}
});
}
if (logoutKey) {
whenever(logoutKey, () => {
if (globalLogoutShortcutKey?.value) {
handleLogout();
}
});
}
}
/**
* 插槽列表类型
*/
@@ -48,7 +122,10 @@ type SlotItem = { index: number; name: string };
const rightSlots = computed(() => {
const list: Array<SlotItem> = [];
// 全局搜索
if (preferences.widget.globalSearch) {
if (
preferences.widget.globalSearch &&
preferences.widget.globalSearchButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE,
name: 'global-search',
@@ -60,40 +137,73 @@ const rightSlots = computed(() => {
index: REFERENCE_VALUE + 10,
name: 'preferences',
});
// 将偏好设置中的子功能分组到同一个按钮位置控制逻辑下
if (preferences.widget.themeToggle) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
}
// 主题、语言、时区等子功能由各自的 widget 开关独立控制,
// 不应跟随偏好设置按钮的显示与否(如 enablePreferences 为 false 或按钮位于其他位置时)
if (
preferences.widget.themeToggle &&
preferences.widget.themeToggleButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (
preferences.widget.timezone &&
preferences.widget.timezoneButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
// 全屏
if (preferences.widget.fullscreen) {
if (
preferences.widget.fullscreen &&
preferences.widget.fullscreenButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 50,
name: 'fullscreen',
});
}
// 消息通知
if (preferences.widget.notification) {
if (
preferences.widget.notification &&
preferences.widget.notificationButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 60,
name: 'notification',
});
}
// 锁定屏幕(仅在 header 位置显示)
if (
preferences.widget.lockScreen &&
preferences.widget.lockScreenButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 70,
name: 'lock-screen-btn',
});
}
// 退出登录(仅在 header 位置显示)
if (preferences.widget.logoutButtonPosition === 'header') {
list.push({
index: REFERENCE_VALUE + 80,
name: 'logout-btn',
});
}
Object.keys(slots).forEach((key) => {
// 适配插槽名称例如第一个插槽名header-right-1
@@ -114,7 +224,10 @@ const rightSlots = computed(() => {
const leftSlots = computed(() => {
const list: Array<SlotItem> = [];
// 刷新
if (preferences.widget.refresh) {
if (
preferences.widget.refresh &&
preferences.widget.refreshButtonPosition === 'header'
) {
list.push({
index: 0,
name: 'refresh',
@@ -150,6 +263,26 @@ function clearPreferencesAndLogout() {
</script>
<template>
<LockModal
v-if="preferences.widget.lockScreen"
:avatar="avatar"
:text="text"
@submit="handleSubmitLock"
/>
<LogoutModal
:cancel-text="$t('common.cancel')"
:confirm-text="$t('common.confirm')"
:fullscreen-button="false"
:title="$t('common.prompt')"
centered
content-class="px-8 min-h-10"
footer-class="border-none mb-3 mr-3"
header-class="border-none"
>
{{ $t('ui.widgets.logoutTip') }}
</LogoutModal>
<template
v-for="slot in leftSlots.filter((item) => item.index < REFERENCE_VALUE)"
:key="slot.name"
@@ -206,6 +339,24 @@ function clearPreferencesAndLogout() {
<template v-else-if="slot.name === 'timezone'">
<TimezoneButton class="mt-0.5 mr-1" />
</template>
<template v-else-if="slot.name === 'lock-screen-btn'">
<VbenIconButton
class="mr-1"
:tooltip="$t('ui.widgets.lockScreen.title')"
@click="handleOpenLock"
>
<LockKeyhole class="size-4" />
</VbenIconButton>
</template>
<template v-else-if="slot.name === 'logout-btn'">
<VbenIconButton
class="mr-1"
:tooltip="$t('common.logout')"
@click="handleLogout"
>
<LogOut class="size-4" />
</VbenIconButton>
</template>
</slot>
</template>
</div>

View File

@@ -36,7 +36,21 @@ import { LayoutTabbar } from './tabbar';
defineOptions({ name: 'BasicLayout' });
const emit = defineEmits<{ clearPreferencesAndLogout: []; clickLogo: [] }>();
withDefaults(defineProps<Props>(), {
avatar: '',
text: '',
});
const emit = defineEmits<{
clearPreferencesAndLogout: [];
clickLogo: [];
logout: [];
}>();
interface Props {
avatar?: string;
text?: string;
}
const {
isDark,
@@ -176,6 +190,10 @@ function clearPreferencesAndLogout() {
emit('clearPreferencesAndLogout');
}
function handleLogout() {
emit('logout');
}
function clickLogo() {
emit('clickLogo');
}
@@ -317,8 +335,11 @@ const headerSlots = computed(() => {
<!-- 头部区域 -->
<template #header>
<LayoutHeader
:avatar="avatar"
:theme="theme"
:text="text"
@clear-preferences-and-logout="clearPreferencesAndLogout"
@logout="handleLogout"
>
<template
v-if="!showHeaderNav && preferences.breadcrumb.enable"

View File

@@ -6,24 +6,41 @@ import { computed } from 'vue';
import { $t } from '@vben/locales';
import SelectItem from '../select-item.vue';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceInterfaceControl',
});
const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
const widgetLanguageToggle = defineModel<boolean>('widgetLanguageToggle');
const widgetNotification = defineModel<boolean>('widgetNotification');
const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const widgetGlobalSearchButtonPosition = defineModel<string>(
'widgetGlobalSearchButtonPosition',
);
const widgetFullscreenButtonPosition = defineModel<string>(
'widgetFullscreenButtonPosition',
);
const widgetLanguageToggleButtonPosition = defineModel<string>(
'widgetLanguageToggleButtonPosition',
);
const widgetNotificationButtonPosition = defineModel<string>(
'widgetNotificationButtonPosition',
);
const widgetThemeToggleButtonPosition = defineModel<string>(
'widgetThemeToggleButtonPosition',
);
const widgetLockScreenButtonPosition = defineModel<string>(
'widgetLockScreenButtonPosition',
);
const widgetLogoutButtonPosition = defineModel<string>(
'widgetLogoutButtonPosition',
);
const appPreferencesButtonPosition = defineModel<string>(
'appPreferencesButtonPosition',
);
const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const widgetRefreshButtonPosition = defineModel<string>(
'widgetRefreshButtonPosition',
);
const widgetTimezoneButtonPosition = defineModel<string>(
'widgetTimezoneButtonPosition',
);
const positionItems = computed((): SelectOption[] => [
{
@@ -43,36 +60,75 @@ const positionItems = computed((): SelectOption[] => [
value: 'user-dropdown',
},
]);
const buttonPositionItems = computed((): SelectOption[] => [
{
label: $t('preferences.widget.header'),
value: 'header',
},
{
label: $t('preferences.widget.userDropdown'),
value: 'user-dropdown',
},
{
label: $t('common.notShow'),
value: 'none',
},
]);
</script>
<template>
<SwitchItem v-model="widgetGlobalSearch">
{{ $t('preferences.widget.globalSearch') }}
</SwitchItem>
<SwitchItem v-model="widgetThemeToggle">
{{ $t('preferences.widget.themeToggle') }}
</SwitchItem>
<SwitchItem v-model="widgetLanguageToggle">
{{ $t('preferences.widget.languageToggle') }}
</SwitchItem>
<SwitchItem v-model="widgetFullscreen">
{{ $t('preferences.widget.fullscreen') }}
</SwitchItem>
<SwitchItem v-model="widgetNotification">
{{ $t('preferences.widget.notification') }}
</SwitchItem>
<SwitchItem v-model="widgetLockScreen">
{{ $t('preferences.widget.lockScreen') }}
</SwitchItem>
<SwitchItem v-model="widgetSidebarToggle">
{{ $t('preferences.widget.sidebarToggle') }}
</SwitchItem>
<SwitchItem v-model="widgetRefresh">
{{ $t('preferences.widget.refresh') }}
</SwitchItem>
<SwitchItem v-model="widgetTimezone">
{{ $t('preferences.widget.timezone') }}
</SwitchItem>
<SelectItem
v-model="widgetGlobalSearchButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.globalSearchPosition') }}
</SelectItem>
<SelectItem
v-model="widgetThemeToggleButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.themeTogglePosition') }}
</SelectItem>
<SelectItem
v-model="widgetLanguageToggleButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.languageTogglePosition') }}
</SelectItem>
<SelectItem
v-model="widgetFullscreenButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.fullscreenPosition') }}
</SelectItem>
<SelectItem
v-model="widgetNotificationButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.notificationPosition') }}
</SelectItem>
<SelectItem
v-model="widgetLockScreenButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.lockScreenPosition') }}
</SelectItem>
<SelectItem v-model="widgetLogoutButtonPosition" :items="buttonPositionItems">
{{ $t('preferences.widget.logoutButtonPosition') }}
</SelectItem>
<SelectItem
v-model="widgetRefreshButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.refreshPosition') }}
</SelectItem>
<SelectItem
v-model="widgetTimezoneButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.timezonePosition') }}
</SelectItem>
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
{{ $t('preferences.position.title') }}
</SelectItem>

View File

@@ -174,15 +174,33 @@ const shortcutKeysGlobalLockScreen = defineModel<boolean>(
'shortcutKeysGlobalLockScreen',
);
const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
const widgetLanguageToggle = defineModel<boolean>('widgetLanguageToggle');
const widgetNotification = defineModel<boolean>('widgetNotification');
const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const widgetGlobalSearchButtonPosition = defineModel<string>(
'widgetGlobalSearchButtonPosition',
);
const widgetFullscreenButtonPosition = defineModel<string>(
'widgetFullscreenButtonPosition',
);
const widgetLanguageToggleButtonPosition = defineModel<string>(
'widgetLanguageToggleButtonPosition',
);
const widgetNotificationButtonPosition = defineModel<string>(
'widgetNotificationButtonPosition',
);
const widgetThemeToggleButtonPosition = defineModel<string>(
'widgetThemeToggleButtonPosition',
);
const widgetLockScreenButtonPosition = defineModel<string>(
'widgetLockScreenButtonPosition',
);
const widgetLogoutButtonPosition = defineModel<string>(
'widgetLogoutButtonPosition',
);
const widgetRefreshButtonPosition = defineModel<string>(
'widgetRefreshButtonPosition',
);
const widgetTimezoneButtonPosition = defineModel<string>(
'widgetTimezoneButtonPosition',
);
const {
customPreferences,
@@ -483,15 +501,33 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:app-preferences-button-position="
appPreferencesButtonPosition
"
v-model:widget-fullscreen="widgetFullscreen"
v-model:widget-global-search="widgetGlobalSearch"
v-model:widget-language-toggle="widgetLanguageToggle"
v-model:widget-lock-screen="widgetLockScreen"
v-model:widget-notification="widgetNotification"
v-model:widget-refresh="widgetRefresh"
v-model:widget-sidebar-toggle="widgetSidebarToggle"
v-model:widget-theme-toggle="widgetThemeToggle"
v-model:widget-timezone="widgetTimezone"
v-model:widget-fullscreen-button-position="
widgetFullscreenButtonPosition
"
v-model:widget-global-search-button-position="
widgetGlobalSearchButtonPosition
"
v-model:widget-language-toggle-button-position="
widgetLanguageToggleButtonPosition
"
v-model:widget-lock-screen-button-position="
widgetLockScreenButtonPosition
"
v-model:widget-logout-button-position="
widgetLogoutButtonPosition
"
v-model:widget-notification-button-position="
widgetNotificationButtonPosition
"
v-model:widget-refresh-button-position="
widgetRefreshButtonPosition
"
v-model:widget-theme-toggle-button-position="
widgetThemeToggleButtonPosition
"
v-model:widget-timezone-button-position="
widgetTimezoneButtonPosition
"
/>
</Block>
<Block :title="$t('preferences.footer.title')">

View File

@@ -6,7 +6,18 @@ import type { AnyFunction } from '@vben/types';
import { computed, useTemplateRef, watch } from 'vue';
import { useHoverToggle } from '@vben/hooks';
import { LockKeyhole, LogOut, Settings } from '@vben/icons';
import {
Bell,
Fullscreen,
Languages,
LockKeyhole,
LogOut,
RotateCw,
Search,
Settings,
Sun,
SunMoon,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
@@ -40,10 +51,6 @@ interface Props {
* @zh_CN 描述
*/
description?: string;
/**
* 是否启用快捷键
*/
enableShortcutKey?: boolean;
/**
* 菜单数组
*/
@@ -74,9 +81,7 @@ defineOptions({
const props = withDefaults(defineProps<Props>(), {
avatar: '',
description: '',
enableShortcutKey: true,
menus: () => [],
showShortcutKey: true,
tagText: '',
text: '',
trigger: 'click',
@@ -122,18 +127,86 @@ watch(
},
);
const showLockInDropdown = computed(
() =>
preferences.widget.lockScreen &&
preferences.widget.lockScreenButtonPosition === 'user-dropdown',
);
const showLogoutInDropdown = computed(
() => preferences.widget.logoutButtonPosition === 'user-dropdown',
);
const showGlobalSearchInDropdown = computed(
() =>
preferences.widget.globalSearch &&
preferences.widget.globalSearchButtonPosition === 'user-dropdown',
);
const showThemeToggleInDropdown = computed(
() =>
preferences.widget.themeToggle &&
preferences.widget.themeToggleButtonPosition === 'user-dropdown',
);
const showLanguageToggleInDropdown = computed(
() =>
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'user-dropdown',
);
const showTimezoneInDropdown = computed(
() =>
preferences.widget.timezone &&
preferences.widget.timezoneButtonPosition === 'user-dropdown',
);
const showFullscreenInDropdown = computed(
() =>
preferences.widget.fullscreen &&
preferences.widget.fullscreenButtonPosition === 'user-dropdown',
);
const showNotificationInDropdown = computed(
() =>
preferences.widget.notification &&
preferences.widget.notificationButtonPosition === 'user-dropdown',
);
const showRefreshInDropdown = computed(
() =>
preferences.widget.refresh &&
preferences.widget.refreshButtonPosition === 'user-dropdown',
);
const hasAnyInDropdown = computed(
() =>
showLockInDropdown.value ||
showLogoutInDropdown.value ||
showGlobalSearchInDropdown.value ||
showThemeToggleInDropdown.value ||
showLanguageToggleInDropdown.value ||
showTimezoneInDropdown.value ||
showFullscreenInDropdown.value ||
showNotificationInDropdown.value ||
showRefreshInDropdown.value,
);
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
const enableLogoutShortcutKey = computed(() => {
return props.enableShortcutKey && globalLogoutShortcutKey.value;
return showLogoutInDropdown.value && globalLogoutShortcutKey.value;
});
const enableLockScreenShortcutKey = computed(() => {
return props.enableShortcutKey && globalLockScreenShortcutKey.value;
return showLockInDropdown.value && globalLockScreenShortcutKey.value;
});
const enableShortcutKey = computed(() => {
return props.enableShortcutKey && preferences.shortcutKeys.enable;
return (
(showLockInDropdown.value || showLogoutInDropdown.value) &&
preferences.shortcutKeys.enable
);
});
function handleOpenLock() {
@@ -146,7 +219,6 @@ function handleSubmitLock(lockScreenPassword: string) {
}
function handleLogout() {
// emit
logoutModalApi.open();
openPopover.value = false;
}
@@ -186,13 +258,14 @@ if (enableShortcutKey.value) {
<template>
<LockModal
v-if="preferences.widget.lockScreen"
v-if="showLockInDropdown"
:avatar="avatar"
:text="text"
@submit="handleSubmitLock"
/>
<LogoutModal
v-if="showLogoutInDropdown"
:cancel-text="$t('common.cancel')"
:confirm-text="$t('common.confirm')"
:fullscreen-button="false"
@@ -261,7 +334,97 @@ if (enableShortcutKey.value) {
<VbenIcon :icon="menu.icon" class="mr-2 size-4" />
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<template v-if="showLockInDropdown || showLogoutInDropdown">
<DropdownMenuSeparator v-if="showLockInDropdown" />
<DropdownMenuItem
v-if="showLockInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenLock"
>
<LockKeyhole class="mr-2 size-4" />
{{ $t('ui.widgets.lockScreen.title') }}
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
{{ altView }} L
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="showLogoutInDropdown" />
<DropdownMenuItem
v-if="showLogoutInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleLogout"
>
<LogOut class="mr-2 size-4" />
{{ $t('common.logout') }}
<DropdownMenuShortcut v-if="enableLogoutShortcutKey">
{{ altView }} Q
</DropdownMenuShortcut>
</DropdownMenuItem>
</template>
<template
v-if="
showGlobalSearchInDropdown ||
showThemeToggleInDropdown ||
showLanguageToggleInDropdown ||
showTimezoneInDropdown ||
showFullscreenInDropdown ||
showNotificationInDropdown ||
showRefreshInDropdown
"
>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="showGlobalSearchInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Search class="mr-2 size-4" />
{{ $t('preferences.widget.globalSearch') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Sun class="mr-2 size-4" />
{{ $t('preferences.widget.themeToggle') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showLanguageToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Languages class="mr-2 size-4" />
{{ $t('preferences.widget.languageToggle') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<SunMoon class="mr-2 size-4" />
{{ $t('preferences.widget.timezone') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showFullscreenInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Fullscreen class="mr-2 size-4" />
{{ $t('preferences.widget.fullscreen') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showNotificationInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Bell class="mr-2 size-4" />
{{ $t('preferences.widget.notification') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showRefreshInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<RotateCw class="mr-2 size-4" />
{{ $t('preferences.widget.refresh') }}
</DropdownMenuItem>
</template>
<DropdownMenuSeparator
v-if="hasAnyInDropdown || preferencesButtonPosition.userDropdown"
/>
<DropdownMenuItem
v-if="preferencesButtonPosition.userDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@@ -270,28 +433,6 @@ if (enableShortcutKey.value) {
<Settings class="mr-2 size-4" />
{{ $t('preferences.title') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="preferences.widget.lockScreen"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenLock"
>
<LockKeyhole class="mr-2 size-4" />
{{ $t('ui.widgets.lockScreen.title') }}
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
{{ altView }} L
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="preferences.widget.lockScreen" />
<DropdownMenuItem
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleLogout"
>
<LogOut class="mr-2 size-4" />
{{ $t('common.logout') }}
<DropdownMenuShortcut v-if="enableLogoutShortcutKey">
{{ altView }} Q
</DropdownMenuShortcut>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -21,5 +21,6 @@
"yes": "Yes",
"no": "No",
"showSearchPanel": "Show search panel",
"hideSearchPanel": "Hide search panel"
"hideSearchPanel": "Hide search panel",
"notShow": "Don't Show"
}

View File

@@ -193,14 +193,25 @@
"widget": {
"title": "Widget",
"globalSearch": "Enable Global Search",
"globalSearchPosition": "Global Search Position",
"fullscreen": "Enable Fullscreen",
"fullscreenPosition": "Fullscreen Position",
"themeToggle": "Enable Theme Toggle",
"themeTogglePosition": "Theme Toggle Position",
"languageToggle": "Enable Language Toggle",
"languageTogglePosition": "Language Toggle Position",
"notification": "Enable Notification",
"notificationPosition": "Notification Position",
"sidebarToggle": "Enable Sidebar Toggle",
"lockScreen": "Enable Lock Screen",
"lockScreenPosition": "Lock Screen Button Position",
"logoutButtonPosition": "Logout Button Position",
"header": "Header",
"userDropdown": "User Dropdown",
"refresh": "Enable Refresh",
"timezone": "Enable Timezone"
"refreshPosition": "Refresh Position",
"timezone": "Enable Timezone",
"timezonePosition": "Timezone Position"
},
"antd": {
"tabLabel": "Antd Extension",

View File

@@ -21,5 +21,6 @@
"yes": "是",
"no": "否",
"showSearchPanel": "显示搜索面板",
"hideSearchPanel": "隐藏搜索面板"
"hideSearchPanel": "隐藏搜索面板",
"notShow": "不显示"
}

View File

@@ -193,14 +193,25 @@
"widget": {
"title": "小部件",
"globalSearch": "启用全局搜索",
"globalSearchPosition": "全局搜索位置",
"fullscreen": "启用全屏",
"fullscreenPosition": "全屏按钮位置",
"themeToggle": "启用主题切换",
"themeTogglePosition": "主题切换位置",
"languageToggle": "启用语言切换",
"languageTogglePosition": "语言切换位置",
"notification": "启用通知",
"notificationPosition": "通知位置",
"sidebarToggle": "启用侧边栏切换",
"lockScreen": "启用锁屏",
"lockScreenPosition": "锁屏按钮位置",
"logoutButtonPosition": "退出按钮位置",
"header": "顶栏",
"userDropdown": "用户下拉窗",
"refresh": "启用刷新",
"timezone": "启用时区"
"refreshPosition": "刷新按钮位置",
"timezone": "启用时区",
"timezonePosition": "时区按钮位置"
},
"antd": {
"tabLabel": "Antd 拓展",

View File

@@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

View File

@@ -110,6 +110,15 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.collapsible'),
},
},
{
name: 'FormArrayDemo',
path: '/form-array-demo',
component: () => import('#/views/demos/form-array/index.vue'),
meta: {
icon: 'lucide:list-plus',
title: '表单数组 Demo',
},
},
],
},
{

View File

@@ -0,0 +1,325 @@
# Form Array 数组字段用法
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`
示例页面:
- `/vue-vben-admin/playground/src/views/demos/form-array/index.vue`
核心实现:
- `/packages/@core/ui-kit/form-ui/src/form-render/schema.ts`
- `/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue`
- `/packages/@core/ui-kit/form-ui/src/form-api.ts`
## 快速开始
```ts
const schema: VbenFormSchema[] = [
{
type: 'array',
fieldName: 'contacts',
label: '联系人',
formItemClass: 'col-span-1 md:col-span-2',
defaultValue: [
{
enabled: true,
name: '张三',
phone: '10086',
role: 'owner',
},
],
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
arrayProps: {
addButtonText: '添加联系人',
min: 1,
max: 5,
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
},
children: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
],
},
},
],
},
];
```
## 字段职责
### `type: 'array'`
声明这是一个数组字段。渲染前会被内部转换为 `VbenFormFieldArray`,所以业务 schema 不需要写具体组件名。
### `fieldName`
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
```ts
contacts[0].name
```
### `children`
数组每一行的子字段定义。每个 child 都是完整的 `FormSchema`,可以继续使用:
- `component`
- `componentProps`
- `rules`
- `dependencies`
- `defaultValue`
- `help`
- `suffix`
- `renderComponentContent`
- `valueFormat`
- `formFieldProps`
- `disabled`
- `hide`
- `labelClass`
- `controlClass`
### `arrayProps`
传给数组编辑器的配置:
| 字段 | 说明 |
| --- | --- |
| `addButtonText` | 新增按钮文案 |
| `actionText` | 操作列表头文案 |
| `emptyText` | 空数据文案 |
| `min` | 最少行数,达到后禁用删除 |
| `max` | 最多行数,达到后禁用新增 |
| `showIndex` | 是否显示序号 |
| `createRow` | 新增行时生成默认数据 |
## 校验建议
数组父级 `rules` 建议只写数组级规则,例如至少一行:
```ts
rules: z.array(z.any()).min(1, '请至少添加一个联系人')
```
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`
```ts
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
}
```
不要在父级数组规则里重复写 `z.object({ name: ... })`,否则某一行子字段失败时,父级数组也会失败,容易出现重复错误提示。
## dependencies 用法
children 里的 `dependencies.triggerFields` 默认是“当前行相对路径”。例如:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
dependencies: {
triggerFields: ['role'],
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
},
}
```
在第 1 行中,`triggerFields: ['role']` 会被转换成:
```ts
contacts[0].role
```
回调多了一个可选 `ctx` 参数:
| 字段 | 说明 |
| --- | --- |
| `ctx.row` | 当前行数据 |
| `ctx.rowIndex` | 当前行索引 |
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
| `ctx.arrayField` | 数组字段名,例如 `contacts` |
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
| `ctx.rootValues` | 表单完整值 |
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
```ts
dependencies: {
triggerFields: ['$root.planName'],
componentProps: (values) => ({
disabled: !values.planName,
}),
}
```
如果想显式写当前行字段,也可以使用 `$row.` 前缀:
```ts
triggerFields: ['$row.role']
```
## valueFormat 用法
children 里的 `valueFormat` 也会按行执行:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
}
```
`contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
```ts
contacts[0].phone
```
如果要写根字段,用 `$root.`
```ts
setValue('$root.firstContactPhone', value)
```
如果要显式写当前行字段,用 `$row.`
```ts
setValue('$row.phone', value)
```
## updateSchema 用法
可以用父级路径更新 child schema
```ts
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
如果传入带索引路径,也会更新对应 child 定义:
```ts
formApi.updateSchema([
{
fieldName: 'contacts[0].phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
注意:`updateSchema` 更新的是 schema 定义,不是单行实例。因此 `contacts[0].phone` 这种写法目前会解析到 child `phone`,实际影响所有行的该列。
## 新增行默认值
优先使用 `arrayProps.createRow`
```ts
arrayProps: {
createRow: () => ({
name: '',
role: 'member',
phone: '',
enabled: true,
}),
}
```
没有 `createRow` 时,会根据 children 的 `defaultValue` 生成行数据;没有 defaultValue 的字段会给 `null`
## 内部流程
数组字段从 schema 到渲染大致是这条链路:
```mermaid
flowchart TD
A["业务 schema: type='array' + children"] --> B["form.vue computedSchema"]
B --> C["createFormFieldSchema"]
C --> D["识别数组 schema"]
D --> E["转换为 component='VbenFormFieldArray'"]
E --> F["children 放入 componentProps.schema"]
F --> G["form-field.vue 渲染外层 FormField"]
G --> H["form-field-array.vue useFieldArray 管理行"]
H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render/valueFormat ctx"]
J --> M["继续复用 FormField 渲染 child"]
```
几个关键点:
- 外层 `type: 'array'` 只是语义声明。
- 具体展示仍复用内部 `VbenFormFieldArray`
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
- `valueFormat``updateSchema``FormApi` 里递归处理 children。
## 小屏幕展示
`form-field-array` 在大屏下按表格式 grid 展示,在小屏下每行转为纵向堆叠,并显示每个 child 的 label。业务侧通常不需要额外处理移动端布局。
## 兼容旧写法
旧写法仍可用:
```ts
{
component: 'VbenFormFieldArray',
fieldName: 'contacts',
componentProps: {
schema: [...],
},
}
```
新代码推荐:
```ts
{
type: 'array',
fieldName: 'contacts',
children: [...],
}
```

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({});
const schema: VbenFormSchema[] = [
{
component: 'Input',
componentProps: {
placeholder: '请输入方案名称',
},
defaultValue: '值班联络人配置',
fieldName: 'planName',
label: '方案名称',
rules: z.string().min(1, '请输入方案名称'),
},
{
component: 'Textarea',
dependencies: {
componentProps: (values) => {
const planName = values.planName as string | undefined;
return {
disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
rows: 2,
};
},
required: (values) => {
return String(values.planName ?? '').includes('值班');
},
rules: (values) => {
return String(values.planName ?? '').includes('值班')
? z.string().min(2, '请输入至少 2 个字')
: z.string().optional();
},
triggerFields: ['planName'],
},
fieldName: 'description',
formItemClass: 'col-span-1 md:col-span-2',
label: '方案说明',
},
{
arrayProps: {
addButtonText: '添加联系人',
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
max: 5,
min: 1,
},
children: [
{
component: 'Input',
componentProps: (_values, _form, ctx) => ({
placeholder: `${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
valueFormat: (value) => value?.trim(),
},
{
component: 'Select',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
{ label: '观察员', value: 'viewer' },
],
},
defaultValue: 'member',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
},
{
component: 'Input',
dependencies: {
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
triggerFields: ['role'],
},
fieldName: 'phone',
label: '电话',
rules: z.string().optional(),
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
},
{
component: 'Switch',
componentProps: {
checkedChildren: '启用',
unCheckedChildren: '停用',
},
defaultValue: true,
fieldName: 'enabled',
label: '状态',
},
],
defaultValue: [
{
enabled: true,
name: '张三',
phone: ' 10086 ',
role: 'owner',
},
],
fieldName: 'contacts',
formItemClass: 'col-span-1 md:col-span-2',
label: '联系人',
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
type: 'array',
},
];
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 90,
},
handleSubmit: (values) => {
submitValues.value = values;
message.success('已通过校验');
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-1 gap-x-4 md:grid-cols-2',
});
async function handleSubmit() {
await formApi.validateAndSubmitForm();
}
async function handleGetValues() {
submitValues.value = await formApi.getValues();
}
function handlePatchChildRule() {
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
message.success('已动态更新子字段规则');
}
</script>
<template>
<Page title="Form Array Demo">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card title="数组字段">
<Form />
<Space class="mt-4 flex flex-wrap">
<Button type="primary" @click="handleSubmit">提交</Button>
<Button @click="handleGetValues">获取值</Button>
<Button @click="handlePatchChildRule">更新电话规则</Button>
</Space>
</Card>
<Card title="输出">
<pre
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
</Card>
</div>
</Page>
</template>