For agentic workers: 按任务顺序执行,完成每个任务后做一次局部编译或页面验证。不要按旧行号盲改,先用文中关键字定位当前代码。
Goal: 将低代码对象设计器里重复出现的“编辑表单布局、弹窗方式、弹窗宽度”等配置收敛到表单设计器单一入口,并补齐字段组件类型切换与最大长度的高可见配置。
Architecture: 表单设计器的 schema.layout 作为编辑表单布局与弹窗配置的主数据源;CRUD 选项和列表设计器只展示摘要与列表专属开关。列表运行态通过 BusinessListDesigner.buildDesignerRuntimeCrudProps 把 schema.layout 同步到 AiCrudPage props,保留历史 editZone/tableZone 配置兜底,避免旧应用配置丢失。
Tech Stack: Vue 3 <script setup> + Naive UI + 现有低代码 formDesignerSchema/viewSchema 协议。
当前低代码对象设计器中,以下配置在多个入口重复维护,用户容易不知道应该改哪里,且保存后可能出现表单设计和列表预览不一致:
| 配置项 | 表单属性 → 表单项配置 | CRUD 选项 → 编辑弹窗 | 列表设计 → 表单与弹窗 |
|---|---|---|---|
| 编辑打开方式 | 已有 | 重复可编辑 | 重复可编辑 |
| 弹窗宽度 | 缺失 | 重复可编辑 | 重复可编辑 |
| 详情弹窗宽度 | 不单独暴露,默认跟随弹窗宽度 | 重复可编辑 | 重复可编辑 |
| 抽屉方向 | 缺失 | 缺失 | 重复可编辑 |
| 编辑表单列数/间距 | 已有 | 重复可编辑 | 重复可编辑 |
| 标签位置/对齐/宽度 | 已有 | 部分重复 | 重复可编辑 |
此外,字段组件还有两个体验问题:
input/textarea 的最大长度藏在“字段约束”组合区里,不够突出。input 切换为 textarea,select 切换为 radio/dictSelect。schema.layout 补齐弹窗宽度、抽屉方向配置;详情弹窗宽度默认跟随弹窗宽度,仅保留历史字段兼容。editShowFeedback/hideModalFooter/hideDefaultDetailContent 这类列表行为开关。BusinessListDesigner 运行态 props 优先读取 schema.layout,历史 editZone/tableZone 配置作为兜底。editZone/tableZone 字段的兼容读取。schema.layout、crudOptions、selectedBlock.props、runtimeCrudProps。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 区新增。文件:
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>
文件:
forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue定位:
<section class="panel-item">编辑弹窗要求:
editLabelWidtheditXGapeditYGapformOpenMode/modalTypemodalWidthdetailModalWidthschema.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>
文件:
forge-admin-ui/src/components/lowcode-builder/page/ListPageGridDesigner.vue定位:
<n-collapse-item ... name="edit" title="表单与弹窗">resolveAiCrudEditFlagsupdateAiCrudEditFlags要求:
editGridColseditLabelWidtheditLabelPlacementeditLabelAligneditSizeeditXGapeditYGapformOpenMode/modalTypedrawerPlacementmodalWidthdetailModalWidthselectedBlock.props 读取。这里的数据由 BusinessListDesigner.buildDesignerRuntimeCrudProps 从 schema.layout 同步。editShowFeedback、hideModalFooter、hideDefaultDetailContent。参考 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>
schema.layout 优先文件:
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 || {}
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'
}
文件:
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()要求:
componentKeyfieldBinding.fieldTypefieldBinding.dataTypefieldBinding.componentTypefieldAssetUpdateddefaultValue、placeholder、disabled、clearable、required、maxlength、showCount、dictType。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,
},
})
}
}
文件:
forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vue定位:
<n-collapse-item v-if="isField" title="字段组件" name="field">supportsFieldMaxLength 配置块。要求:
supportsFieldMaxLength、selectedFieldMaxLength、updateFieldMaxLength。参考 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>
文件:
forge-admin-ui/src/views/app-center/components/designer/forge-form-designer/ForgePropertyPanel.vueforge-admin-ui/src/components/lowcode-builder/page/ListPageGridDesigner.vue要求:
参考样式:
.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 通过。input 字段,可切换为 textarea/number/inputNumber/money,画布即时更新。select 字段,可切换为 radio/checkbox/dictSelect,字段绑定和字典配置不丢失。fileUpload 字段,可切换为 imageUpload,字段资产同步为对应组件类型。input/textarea 字段,“最大长度”在字段组件基础区域直接可见;清空最大长度后字段资产中的 length/basicProps.maxlength 同步清理。editZone.props 或 tableZone.props 保存了弹窗配置。本次不能删除旧字段,只调整读取优先级,确保旧应用没有 schema.layout 时仍按旧配置渲染。GridBlockRenderer.vue 仍会从 block props 和 runtimeCrudProps 合并运行态属性。只要 BusinessListDesigner 正确把 schema.layout 输出到 runtimeCrudProps,列表预览与运行态可保持兼容。