Skip to content

动态表单字段扩展 ​

新增与编辑表单分别拥有贡献者。本例把必填的 SocialSecurityNumber 扩展属性放在姓氏字段后。

先配置后端 ​

Identity 用户扩展需要声明字符串属性 SocialSecurityNumber,允许在新增/编辑 UI 显示,并在新增/更新请求中接收它。前端贡献者本身不能添加持久化存储,使用示例之前先完成对象扩展。

定义字段 ​

ts
import { FormProp, PropType, type FormPropContributorCallback } from '@lsw-abpvue/components';
import { IdentityComponents, type IdentityConfigOptions } from '@lsw-abpvue/identity';
import type { IdentityUserDto } from '@lsw-abpvue/identity/proxy';
import { Validators } from '@lsw-abpvue/theme-shared';

const socialSecurityField: FormPropContributorCallback<IdentityUserDto> = props => {
  props.dropByValueAll(prop => prop.name === 'SocialSecurityNumber');
  props.addAfter(
    FormProp.create<IdentityUserDto>({
      name: 'SocialSecurityNumber',
      type: PropType.String,
      displayName: 'BookStore::SocialSecurityNumber',
      isExtra: true,
      defaultValue: '',
      validators: () => [Validators.required(), Validators.minLength(4), Validators.maxLength(64)],
    }),
    prop => prop.name === 'surname',
  );
};

export const userFormFields = {
  createFormPropContributors: { [IdentityComponents.Users]: [socialSecurityField] },
  editFormPropContributors: { [IdentityComponents.Users]: [socialSecurityField] },
} satisfies IdentityConfigOptions;

注册到模块路由 ​

将示例保存为 src/identity-options.ts,在 src/routes.ts 使用导出的选项:

ts
import { lazyRoutes } from '@lsw-abpvue/core/router';
import { userFormFields } from './identity-options';

const identityRoute = lazyRoutes('/identity', () =>
  import('@lsw-abpvue/identity').then(module => module.createIdentityRoutes(userFormFields)),
);

将 identityRoute 放入应用路由数组,并保留启动时的 provideIdentityConfig()。替换已有 identity 路由,不要为同一前缀注册两份路由。模块配置改变后重启开发服务,再打开 /identity/users。

贡献者先移除自动映射的同名属性,再插入替换字段,避免出现两个控件。删除映射字段的验证器后,应重新提供所需规则。

字段选项 ​

选项默认值/行为
type必填,决定主题控件
isExtra默认 false;true 时写入 extraProperties
defaultValue记录没有已有值时使用
validators默认无验证器,返回 AbpValidator[]
disabled/readonly条件回调,默认 false
visible/permission渲染条件
options枚举、选择、查找控件选项,支持值、Promise、Ref、getter
group同名分组中的字段一起显示
component替换类型对应的控件

即使控件只编辑字符串,也使用 FormProp<IdentityUserDto>,泛型描述回调能访问的整条记录。编辑回调可以读取已有记录,新增回调不能假定记录已有 id。

选择控件可以提供 options: () => [{ value: 'internal', label: 'Internal' }, { value: 'external', label: 'External' }]。返回选项时自行本地化 label 标签。

自定义控件契约 ​

自定义字段组件接收 modelValue、prop、record、disabled 和 readonly,通过 update:modelValue 更新控件。record 不是可写表单模型。模型类型应匹配,遵守禁用和只读状态,并保留键盘及可访问标签行为。

仅在一个页面替换字段展示时,AbpExtensibleForm 的 field-{name} 插槽接收 { prop, control }。绑定 control.value,不要直接修改 record.extraProperties。

请求正文与验证 ​

useExtensibleForm(record).toRequestBody() 把扩展控件写入 extraProperties,保留记录中没有显示的扩展属性。完整替换页面自行提交时,应使用该请求正文。服务端验证仍然是最终依据。

前端禁用/只读不能阻止伪造请求,禁止修改某属性的规则也需要在后端执行。

检查效果 ​

用有效值创建用户,重新打开编辑,确认值保留。提交空值或过短值,检查字段消息。观察请求中的 extraProperties.SocialSecurityNumber,随后 GET 应返回保存值。

另见扩展行为、表单验证与对象扩展。

条件行为与编辑值 ​

谓词的 data.record 是传入记录,不是隐式实时表单对象。创建不能假定已有 id,检查 record.isActive 也不会自动观察正在编辑的 isActive 控制。依赖编辑值的条件应通过应用字段或插槽读取实际 control,并用 context.valueOf 编写条件验证。参见自定义规则。

visible、disabled、readonly 是 UI 决策。隐藏控制仍在已构建表单中,验证器仍可能阻止保存。只在某条件下有意义的必填值,需要把条件写入验证,不能因为不显示就删除已有额外属性。

自定义字段组件 ​

组件接收 modelValue、prop、record、disabled、readonly,发出 update:modelValue。字符串控件可包装 AbpInput,转发状态及字段 id、描述属性。字段插槽直接提供 control,更新 control.value,不修改后端 record。应用专属覆盖适合插槽,可复用控件适合贡献组件。

创建与编辑 ​

即使使用同一回调,也分别注册创建与编辑。创建使用初始值,编辑优先使用返回的记录与 extraProperties。并发戳不应作为普通可见文本字段,由更新流程保留。列表未返回完整编辑字段时,应先读取详情。

修改贡献器后重新进入路由,使用解析后的列表。在打开表单前增删排序,注册表变化不能安全迁移已经打开的表单状态。

完整自定义控件 ​

后端需要定义可选字符串额外属性 EmployeeCode,并允许创建与更新。复制两个文件,在已有 createIdentityRoutes 中传入 customUserControl,增加 BookStore::EmployeeCode 资源。组件将输入转为大写,并把继承的字段属性转发给 AbpInput。

vue
<template>
  <AbpInput
    :id="prop.id"
    :model-value="value"
    :disabled="disabled"
    :readonly="readonly"
    autocomplete="off"
    @update:model-value="update"
  />
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { AbpInput } from '@lsw-abpvue/theme-shared';
import type { FormProp } from '@lsw-abpvue/components';
const props = defineProps<{
  modelValue?: unknown;
  prop: FormProp;
  record: unknown;
  disabled?: boolean | undefined;
  readonly?: boolean | undefined;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const value = computed(() => String(props.modelValue ?? ''));
function update(value: string | number | null): void {
  emit('update:modelValue', String(value ?? '').toUpperCase());
}
</script>
ts
import { FormProp, PropType, type FormPropContributorCallback } from '@lsw-abpvue/components';
import { IdentityComponents, type IdentityConfigOptions } from '@lsw-abpvue/identity';
import type { IdentityUserDto } from '@lsw-abpvue/identity/proxy';
import { Validators } from '@lsw-abpvue/theme-shared';
import CustomCodeInput from './CustomCodeInput.vue';

const codeField: FormPropContributorCallback<IdentityUserDto> = props => {
  props.dropByValueAll(prop => prop.name === 'EmployeeCode');
  props.addTail(
    FormProp.create<IdentityUserDto>({
      name: 'EmployeeCode',
      type: PropType.String,
      isExtra: true,
      displayName: 'BookStore::EmployeeCode',
      component: CustomCodeInput,
      validators: () => [Validators.maxLength(16), Validators.pattern(/[A-Z0-9-]*/)],
    }),
  );
};
export const customUserControl = {
  createFormPropContributors: { [IdentityComponents.Users]: [codeField] },
  editFormPropContributors: { [IdentityComponents.Users]: [codeField] },
} satisfies IdentityConfigOptions;

非官方社区项目,采用 MIT 许可证,与 Volosoft 无关。