Merge branch 'main' of https://github.com/xingyu4j/vue-vben-admin into deps
This commit is contained in:
@@ -360,6 +360,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
341
packages/@core/ui-kit/form-ui/src/form-render/schema.ts
Normal file
341
packages/@core/ui-kit/form-ui/src/form-render/schema.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
FormLayout,
|
||||
FormSchemaContext,
|
||||
VbenFormFieldArrayProps,
|
||||
VbenFormProps,
|
||||
FormSchema as VbenFormSchema,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
240
packages/effects/access/src/__tests__/accessible.test.ts
Normal file
240
packages/effects/access/src/__tests__/accessible.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ const showComponent = (route: RouteLocationNormalizedLoadedGeneric) => {
|
||||
v-if="getEnabledTransition"
|
||||
:name="getTransitionName(route)"
|
||||
appear
|
||||
mode="out-in"
|
||||
>
|
||||
<KeepAlive
|
||||
v-if="keepAlive"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"showSearchPanel": "Show search panel",
|
||||
"hideSearchPanel": "Hide search panel"
|
||||
"hideSearchPanel": "Hide search panel",
|
||||
"notShow": "Don't Show"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"showSearchPanel": "显示搜索面板",
|
||||
"hideSearchPanel": "隐藏搜索面板"
|
||||
"hideSearchPanel": "隐藏搜索面板",
|
||||
"notShow": "不显示"
|
||||
}
|
||||
|
||||
@@ -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 拓展",
|
||||
|
||||
@@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
325
playground/src/views/demos/form-array/README.md
Normal file
325
playground/src/views/demos/form-array/README.md
Normal 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: [...],
|
||||
}
|
||||
```
|
||||
187
playground/src/views/demos/form-array/index.vue
Normal file
187
playground/src/views/demos/form-array/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user