表单列表配置去重优化_1e744c2a.md 21 KB

表单与列表设计器配置去重优化实施计划

For agentic workers: 按任务顺序执行,完成每个任务后做一次局部编译或页面验证。不要按旧行号盲改,先用文中关键字定位当前代码。

Goal: 将低代码对象设计器里重复出现的“编辑表单布局、弹窗方式、弹窗宽度”等配置收敛到表单设计器单一入口,并补齐字段组件类型切换与最大长度的高可见配置。

Architecture: 表单设计器的 schema.layout 作为编辑表单布局与弹窗配置的主数据源;CRUD 选项和列表设计器只展示摘要与列表专属开关。列表运行态通过 BusinessListDesigner.buildDesignerRuntimeCrudPropsschema.layout 同步到 AiCrudPage props,保留历史 editZone/tableZone 配置兜底,避免旧应用配置丢失。

Tech Stack: Vue 3 <script setup> + Naive UI + 现有低代码 formDesignerSchema/viewSchema 协议。


背景

当前低代码对象设计器中,以下配置在多个入口重复维护,用户容易不知道应该改哪里,且保存后可能出现表单设计和列表预览不一致:

配置项 表单属性 → 表单项配置 CRUD 选项 → 编辑弹窗 列表设计 → 表单与弹窗
编辑打开方式 已有 重复可编辑 重复可编辑
弹窗宽度 缺失 重复可编辑 重复可编辑
详情弹窗宽度 不单独暴露,默认跟随弹窗宽度 重复可编辑 重复可编辑
抽屉方向 缺失 缺失 重复可编辑
编辑表单列数/间距 已有 重复可编辑 重复可编辑
标签位置/对齐/宽度 已有 部分重复 重复可编辑

此外,字段组件还有两个体验问题:

  • input/textarea 的最大长度藏在“字段约束”组合区里,不够突出。
  • 选中字段后不能直接切换同类组件类型,例如 input 切换为 textareaselect 切换为 radio/dictSelect

范围

本次要做

  • 表单属性 schema.layout 补齐弹窗宽度、抽屉方向配置;详情弹窗宽度默认跟随弹窗宽度,仅保留历史字段兼容。
  • CRUD 选项 Drawer 的“编辑弹窗”改为只读摘要,仅保留“每页条数”可编辑。
  • 列表设计器的“表单与弹窗”改为只读摘要,仅保留 editShowFeedback/hideModalFooter/hideDefaultDetailContent 这类列表行为开关。
  • BusinessListDesigner 运行态 props 优先读取 schema.layout,历史 editZone/tableZone 配置作为兜底。
  • 字段属性面板新增同类组件类型切换,并同步字段资产元数据。
  • 最大长度提升为独立配置项,放在字段组件基础配置中更靠前的位置。

本次不做

  • 不改后端接口、数据库表结构和 Flyway 脚本。
  • 不迁移历史数据,只做读取优先级兼容。
  • 不移除运行态对旧 editZone/tableZone 字段的兼容读取。
  • 不新增新的页面设计协议;继续复用 schema.layoutcrudOptionsselectedBlock.propsruntimeCrudProps

关键约束

  • schema.layout 是主配置源;新增配置必须写入 schema.layout,不要再写入 crudOptions 或列表 block props。
  • BusinessListDesigner.vue 当前 buildDesignerRuntimeCrudProps(schema, fields, customActions) 使用的就是当前列表 schema,不存在 props.formSchema 参数;不要按旧方案新增 props.formSchema
  • ForgePropertyPanel.vue 中字段默认映射常量当前叫 componentFieldDefaults,不是 FIELD_TYPE_DEFAULTS
  • drawerPlacementOptions 当前在 ListPageGridDesigner.vue 已存在,但 ForgePropertyPanel.vue 中未发现;如不存在需在 ForgePropertyPanel.vue script 区新增。
  • 组件类型切换只允许同组切换,不支持跨语义类型切换,例如不能把文件上传切成日期。
  • 切换组件类型必须保留字段编码、字段名称、校验、默认值等通用配置,同时清理不兼容的组件私有 props。

Task 1:补齐表单属性中的弹窗配置

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue

定位:

  • <n-collapse-item title="表单项配置" name="layout">
  • const formOpenModeOptions = [...]
  • function updateFormOpenModeLayout(value)

要求:

  • 在“编辑打开方式”下方新增两个配置行:
    • 弹窗宽度:写入 schema.layout.modalWidth,默认 800px,并同步写入 schema.layout.detailModalWidth 作为运行态兼容值。
    • 抽屉方向:写入 schema.layout.drawerPlacement,默认 right
  • ForgePropertyPanel.vue 中没有 drawerPlacementOptions,新增:
const drawerPlacementOptions = [
  { label: '右侧', value: 'right' },
  { label: '左侧', value: 'left' },
]

参考 UI:

<div class="compact-config-row">
  <label>弹窗宽度</label>
  <n-input
    :value="schema.layout?.modalWidth || '800px'"
    placeholder="800px / 60vw"
    size="small"
    @update:value="updateFormModalWidth"
  />
</div>
<div class="compact-config-row">
  <label>抽屉方向</label>
  <n-select
    :value="schema.layout?.drawerPlacement || 'right'"
    :options="drawerPlacementOptions"
    size="small"
    @update:value="updateFormLayout({ drawerPlacement: $event || 'right' })"
  />
</div>

Task 2:CRUD 选项 Drawer 移除重复编辑项

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue

定位:

  • CRUD 选项面板中的 <section class="panel-item">
  • 标题文本:编辑弹窗

要求:

  • 移除以下可编辑项:
    • editLabelWidth
    • editXGap
    • editYGap
    • formOpenMode/modalType
    • modalWidth
    • detailModalWidth
  • 替换为只读摘要,摘要统一从 schema.layout 读取。
  • 保留“每页条数”,因为它是列表分页配置,不属于表单布局。

参考 UI:

<section class="panel-item">
  <div class="panel-item-title">
    编辑弹窗
    <small class="panel-item-hint">配置已移至「表单属性 → 表单项配置」</small>
  </div>
  <div class="crud-readonly-summary">
    <span>打开方式:{{ schema.layout?.formOpenMode || schema.layout?.modalType || 'modal' }}</span>
    <span>弹窗宽度:{{ schema.layout?.modalWidth || '800px' }}</span>
    <span>表单列数:{{ normalizedFormGridColumns }}</span>
  </div>
  <n-form-item label="每页条数">
    <n-input-number
      :value="crudOptions.pageSize || 10"
      :min="1"
      :max="200"
      @update:value="updateCrudOption('pageSize', $event || 10)"
    />
  </n-form-item>
</section>

Task 3:列表设计器“表单与弹窗”改为只读摘要

文件:

  • Modify: forge-admin-ui/src/components/lowcode-builder/page/ListPageGridDesigner.vue

定位:

  • <n-collapse-item ... name="edit" title="表单与弹窗">
  • resolveAiCrudEditFlags
  • updateAiCrudEditFlags

要求:

  • 移除以下可编辑项:
    • editGridCols
    • editLabelWidth
    • editLabelPlacement
    • editLabelAlign
    • editSize
    • editXGap
    • editYGap
    • formOpenMode/modalType
    • drawerPlacement
    • modalWidth
    • detailModalWidth
  • 保留只读摘要,摘要从 selectedBlock.props 读取。这里的数据由 BusinessListDesigner.buildDesignerRuntimeCrudPropsschema.layout 同步。
  • 保留“详情与页脚”复选框:editShowFeedbackhideModalFooterhideDefaultDetailContent

参考 UI:

<n-collapse-item
  v-if="selectedBlock.blockType === 'AiCrudPage' && propertySectionVisible(['表单与弹窗', '新增', '编辑', '表单', '弹窗', '抽屉', '详情', '页脚', '打开方式', '标签位置', '标签对齐', '标签宽度', '表单列数'])"
  name="edit"
  title="表单与弹窗"
>
  <div class="property-search-anchor" data-property-search="表单与弹窗 新增 编辑 表单 弹窗 抽屉 详情 页脚 打开方式 标签位置 标签对齐 标签宽度 表单列数" />
  <div class="form-modal-help">
    弹窗方式和表单布局已统一在「表单设计」的「表单属性 → 表单项配置」中管理。
  </div>
  <div class="form-modal-readonly-summary">
    <div class="readonly-summary-row">
      <span>打开方式</span>
      <strong>{{ selectedBlock.props?.formOpenMode || selectedBlock.props?.modalType || 'modal' }}</strong>
    </div>
    <div class="readonly-summary-row">
      <span>弹窗宽度</span>
      <strong>{{ selectedBlock.props?.modalWidth || '800px' }}</strong>
    </div>
    <div class="readonly-summary-row">
      <span>编辑列数</span>
      <strong>{{ selectedBlock.props?.editGridCols || 1 }}</strong>
    </div>
  </div>
  <n-form-item label="详情与页脚">
    <div class="metrics-editor">
      <n-checkbox-group
        :value="resolveAiCrudEditFlags(selectedBlock)"
        @update:value="updateAiCrudEditFlags"
      >
        <n-space>
          <n-checkbox value="editShowFeedback" label="校验反馈" />
          <n-checkbox value="hideModalFooter" label="隐藏页脚" />
          <n-checkbox value="hideDefaultDetailContent" label="隐藏默认详情" />
        </n-space>
      </n-checkbox-group>
    </div>
  </n-form-item>
</n-collapse-item>

Task 4:同步数据流改为 schema.layout 优先

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/BusinessListDesigner.vue

定位:

  • const designerRuntimeCrudProps = computed(() => buildDesignerRuntimeCrudProps(localSchema.value, designFields.value, listCustomActions.value))
  • function buildDesignerRuntimeCrudProps(schema = {}, fields = [], customActions = [])
  • function resolveDesignerFormOpenMode(tableProps = {}, editProps = {})
  • function resolveDesignerModalType(formOpenMode = 'modal', tableProps = {}, editProps = {})

要求:

  • buildDesignerRuntimeCrudProps 中新增:
const formLayout = schema.layout || {}
  • 运行态 props 读取优先级统一为:
schema.layout -> editZone.props -> tableZone.props -> 默认值
  • 调整 resolvedFormOpenMode / resolvedModalType 计算,确保 schema.layout.formOpenMode 优先。

参考实现要点:

const formLayout = schema.layout || {}
const resolvedFormOpenMode = resolveDesignerFormOpenMode(formLayout, tableProps, editProps)
const resolvedModalType = resolveDesignerModalType(resolvedFormOpenMode, formLayout, tableProps, editProps)
editGridCols: formLayout.gridColumns || formLayout.gridCols || editProps.editGridCols || tableProps.editGridCols || 1,
editLabelWidth: formLayout.labelWidth || editProps.editLabelWidth || editProps.labelWidth || tableProps.editLabelWidth || 'auto',
editLabelPlacement: formLayout.labelPlacement || editProps.editLabelPlacement || editProps.labelPlacement || tableProps.editLabelPlacement || 'left',
editLabelAlign: formLayout.labelAlign || editProps.editLabelAlign || editProps.labelAlign || tableProps.editLabelAlign || 'right',
editSize: formLayout.size || editProps.editSize || editProps.size || tableProps.editSize || 'medium',
editXGap: formLayout.columnGap ?? editProps.editXGap ?? editProps.columnGap ?? tableProps.editXGap ?? 16,
editYGap: formLayout.rowGap ?? editProps.editYGap ?? editProps.rowGap ?? tableProps.editYGap ?? 8,
modalWidth: formLayout.modalWidth || editProps.modalWidth || tableProps.modalWidth || '800px',
detailModalWidth: formLayout.detailModalWidth || formLayout.modalWidth || editProps.detailModalWidth || editProps.modalWidth || tableProps.detailModalWidth || tableProps.modalWidth || '800px',
formOpenMode: resolvedFormOpenMode,
modalType: resolvedModalType,
drawerPlacement: formLayout.drawerPlacement || editProps.drawerPlacement || tableProps.drawerPlacement || 'right',

函数签名建议:

function resolveDesignerFormOpenMode(formLayout = {}, tableProps = {}, editProps = {}) {
  const value = formLayout.formOpenMode || formLayout.modalType || editProps.formOpenMode || tableProps.formOpenMode || editProps.modalType || tableProps.modalType || 'modal'
  return value === 'tabWorkspace' ? 'tabWorkspace' : (['modal', 'drawer', 'flat'].includes(value) ? value : 'modal')
}

function resolveDesignerModalType(formOpenMode = 'modal', formLayout = {}, tableProps = {}, editProps = {}) {
  if (['modal', 'drawer'].includes(formOpenMode))
    return formOpenMode
  const value = formLayout.modalType || editProps.modalType || tableProps.modalType || 'modal'
  return ['modal', 'drawer'].includes(value) ? value : 'modal'
}

Task 5:字段属性新增组件类型切换

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue

定位:

  • 选中组件属性面板:<n-collapse-item title="标识" name="identity">
  • const componentFieldDefaults = {...}
  • function updateComponent(patch)
  • function createFieldAssetFromSelectedComponent()

要求:

  • 在“显示名称”下方新增“组件类型”选择器。
  • 仅字段组件允许切换,并且只允许同组组件互换。
  • 切换时必须同步:
    • componentKey
    • fieldBinding.fieldType
    • fieldBinding.dataType
    • fieldBinding.componentType
    • 字段资产更新事件 fieldAssetUpdated
  • 切换时应保留通用 props:defaultValueplaceholderdisabledclearablerequiredmaxlengthshowCountdictType
  • 当切换到非文本类组件时,清理文本专属的 maxlength/showCount,除非目标组件仍是 input/textarea

新增 UI:

<n-form-item v-if="canSwitchComponentType" label="组件类型">
  <n-select
    :value="selectedComponent.componentKey"
    :options="switchableComponentOptions"
    filterable
    @update:value="handleSwitchComponentType"
  />
</n-form-item>

新增逻辑:

const switchableComponentGroups = [
  ['input', 'textarea', 'number', 'inputNumber', 'money'],
  ['select', 'radio', 'checkbox', 'dictSelect'],
  ['date', 'datetime'],
  ['fileUpload', 'imageUpload'],
  ['objectReference', 'recordSelector'],
]

const componentTypeLabelMap = {
  input: '单行输入',
  textarea: '多行文本',
  number: '数字输入',
  inputNumber: '数字输入',
  money: '金额',
  select: '下拉选择',
  radio: '单选框',
  checkbox: '多选框',
  dictSelect: '字典选择',
  date: '日期',
  datetime: '日期时间',
  fileUpload: '文件上传',
  imageUpload: '图片上传',
  objectReference: '引用对象',
  recordSelector: '记录选择器',
}

const canSwitchComponentType = computed(() => {
  if (!isField.value || !selectedComponent.value)
    return false
  const key = selectedComponent.value.componentKey
  return switchableComponentGroups.some(group => group.includes(key))
})

const switchableComponentOptions = computed(() => {
  const key = selectedComponent.value?.componentKey
  const group = switchableComponentGroups.find(item => item.includes(key)) || []
  return group.map(value => ({
    label: componentTypeLabelMap[value] || value,
    value,
  }))
})

function pickSwitchableCommonProps(sourceProps = {}, newKey = '') {
  const commonKeys = ['defaultValue', 'placeholder', 'disabled', 'clearable', 'required', 'dictType']
  const nextProps = {}
  commonKeys.forEach((key) => {
    if (sourceProps[key] !== undefined)
      nextProps[key] = sourceProps[key]
  })
  if (['input', 'textarea'].includes(newKey)) {
    if (sourceProps.maxlength !== undefined)
      nextProps.maxlength = sourceProps.maxlength
    if (sourceProps.showCount !== undefined)
      nextProps.showCount = sourceProps.showCount
  }
  return nextProps
}

function handleSwitchComponentType(newKey) {
  const component = selectedComponent.value
  if (!component || !newKey || newKey === component.componentKey)
    return
  const group = switchableComponentGroups.find(item => item.includes(component.componentKey))
  if (!group?.includes(newKey))
    return
  const defaults = componentFieldDefaults[newKey] || componentFieldDefaults.input
  const nextFieldBinding = {
    ...(component.fieldBinding || {}),
    fieldType: defaults.fieldType,
    dataType: defaults.dataType,
    componentType: defaults.componentType || newKey,
  }
  updateComponent({
    componentKey: newKey,
    props: pickSwitchableCommonProps(component.props || {}, newKey),
    fieldBinding: nextFieldBinding,
  })
  const asset = selectedFieldAsset.value
  if (asset && selectedFieldCode.value) {
    emit('fieldAssetUpdated', {
      ...asset,
      fieldCode: selectedFieldCode.value,
      fieldType: defaults.fieldType,
      dataType: defaults.dataType,
      length: defaults.length,
      precision: defaults.precision,
      componentType: defaults.componentType || newKey,
      queryType: defaults.queryType,
      fieldBinding: nextFieldBinding,
      basicProps: {
        ...(asset.basicProps || {}),
        ...pickSwitchableCommonProps(component.props || {}, newKey),
        fieldBinding: nextFieldBinding,
      },
    })
  }
}

Task 6:字段最大长度提升可见性

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue

定位:

  • <n-collapse-item v-if="isField" title="字段组件" name="field">
  • 当前“字段约束”里的 supportsFieldMaxLength 配置块。

要求:

  • 在“默认值”下方新增独立的“最大长度”表单项。
  • 从“字段约束”中移除原来的最大长度行,避免重复。
  • 仍然复用现有 supportsFieldMaxLengthselectedFieldMaxLengthupdateFieldMaxLength

参考 UI:

<n-form-item v-if="supportsFieldMaxLength" label="最大长度">
  <div class="option-editor-row two-columns">
    <n-input-number
      :value="selectedFieldMaxLength"
      :min="1"
      :max="2048"
      :show-button="false"
      clearable
      placeholder="最大长度"
      @update:value="updateFieldMaxLength"
    />
    <n-switch
      :value="selectedComponent.props?.showCount === true"
      size="small"
      @update:value="updateComponent({ props: { showCount: $event } })"
    >
      <template #checked>计数</template>
      <template #unchecked>计数</template>
    </n-switch>
  </div>
</n-form-item>

Task 7:样式补充

文件:

  • Modify: forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue
  • Modify: forge-admin-ui/src/components/lowcode-builder/page/ListPageGridDesigner.vue

要求:

  • 新增只读摘要样式,保持后台属性面板紧凑,不使用大面积卡片。
  • 如果同名 class 已存在,复用并补齐缺失属性,不重复定义。

参考样式:

.crud-readonly-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 16px;
  padding: 8px 12px;
  background: var(--n-color-hover, #f7f8fa);
  border-radius: 6px;
  font-size: 12px;
  color: var(--n-text-color-3);
  margin-bottom: 12px;
}

.panel-item-hint {
  font-weight: normal;
  font-size: 11px;
  color: var(--n-text-color-3);
  margin-left: 8px;
}

.form-modal-readonly-summary {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 12px;
  background: var(--n-color-hover, #f7f8fa);
  border-radius: 6px;
  margin-bottom: 12px;
}

.readonly-summary-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  font-size: 12px;
}

.readonly-summary-row span {
  color: var(--n-text-color-3);
}

.readonly-summary-row strong {
  color: var(--n-text-color-1);
  font-weight: 500;
  text-align: right;
}

验证清单

  • 执行前读取 code-copilot/rules/automated-testing-standard.md,验证结果追加到当前变更的 execution-log.md(若本需求进入 code-copilot 变更流程)。
  • source ~/.nvm/nvm.sh && nvm use v20.19.0 && cd forge-admin-ui && pnpm build 通过。
  • 打开表单设计器,进入“表单属性 → 表单项配置”,可配置编辑打开方式、弹窗宽度、抽屉方向、表单列数、标签位置/对齐/宽度。
  • 打开 CRUD 选项 Drawer,“编辑弹窗”只显示只读摘要和“每页条数”。
  • 打开列表设计器,选中 AiCrudPage,“表单与弹窗”只显示只读摘要和“详情与页脚”复选框。
  • 修改表单属性中的弹窗宽度、抽屉方向后,列表设计器摘要和运行时预览保持一致;详情弹窗宽度跟随弹窗宽度。
  • 选中 input 字段,可切换为 textarea/number/inputNumber/money,画布即时更新。
  • 选中 select 字段,可切换为 radio/checkbox/dictSelect,字段绑定和字典配置不丢失。
  • 选中 fileUpload 字段,可切换为 imageUpload,字段资产同步为对应组件类型。
  • 选中 input/textarea 字段,“最大长度”在字段组件基础区域直接可见;清空最大长度后字段资产中的 length/basicProps.maxlength 同步清理。

风险与兼容

  • 旧应用可能已经在 editZone.propstableZone.props 保存了弹窗配置。本次不能删除旧字段,只调整读取优先级,确保旧应用没有 schema.layout 时仍按旧配置渲染。
  • GridBlockRenderer.vue 仍会从 block props 和 runtimeCrudProps 合并运行态属性。只要 BusinessListDesigner 正确把 schema.layout 输出到 runtimeCrudProps,列表预览与运行态可保持兼容。
  • 组件类型切换会影响字段数据类型。对已经发布并建表的业务对象,后续实际执行时需要结合发布校验提示用户确认字段类型变更风险;本次需求只做设计态元数据同步,不做数据库迁移。