feat(module-management): 字段定义面板增加 表单/列表/查询 参与开关

跨 tab 的「字段参与了哪些界面」在字段定义一行内表达并可就地切换,
tab 结构不变。「参与是选择不是缺失」——不引入任何完整性提示。

- 语义沿用 fieldAreaDefaults 既有约定:行存在=已配置,行内开关=是否参与
  (表单/列表 b_visible、查询 b_canuse);关=置 0 不删行,保留画布布局与既有查询配置
- fieldParticipation.js(纯逻辑):判定与切换;行不存在时按默认工厂生成且强制参与
  (显式意愿覆盖大文本默认 参与=0),表单新行 b_colspan=24 追加到末尾序号
- fieldAreaDefaults 增加列表/查询默认行工厂,useModuleConfig.buildMissingDefaultConfigs
  改用工厂——「同步字段自动补全」与「手动打开开关」生成的行结构同源
- ParticipationCell(h 渲染 ui Switch)接 FmsTable customCell;表单列仅 data 模块展示
- 新 spec field-participation 9 用例:判定/两路切换/大文本强制参与/不变性
This commit is contained in:
oneao committed 2026-08-30 21:05:57 +08:00
1 parent 08646c547f
commit f019a1c1ac
8 files changed
+363 -26

No files matched your search

@@ -2,21 +2,45 @@
/**
* 字段定义面板(s_field):可编辑表格维护模块字段。
* i18n 键按 field.{moduleCode}.{fieldName} 自动同步。
* 表单/列表/查询三列为「参与开关」:在一行内表达该字段参与了哪些界面,
* 开关即改即生效(生成/隐藏默认配置行);布局细节仍在对应设计器调整。
* 语义见 fieldParticipation.js(沿用 fieldAreaDefaults 的行内开关约定)。
*/
import { ref, shallowRef, watch } from 'vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
import { createI18nKeyCell } from './I18nKeyCell'
import { createParticipationCell } from './ParticipationCell'
import {
isFormOn,
isListOn,
isQueryOn,
toggleForm,
toggleList,
toggleQuery,
} from '../fieldParticipation'
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: '' },
fields: { type: Array, default: () => [] },
/** 参与开关所需的三个配置草稿(由 index.vue 传入,切换后事件回写) */
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
/** 模块类型:表单参与列仅 data 模块展示(查询模块无表单设计) */
moduleType: { type: String, default: null },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'i18nSaved'])
const emit = defineEmits([
'update',
'i18nSaved',
'updateEditConfig',
'updateListConfig',
'updateQueryConfig',
])
const FIELD_TYPES = [
{ label: '输入框', value: 'input' },
@@ -74,7 +98,41 @@ function openI18n(row) {
const I18nKeyCell = createI18nKeyCell({ onOpen: openI18n })
const columns = shallowRef([
/* ---------- 参与开关(表单/列表/查询) ---------- */
/* 字段行 → 配置行:字段定义行的 b_id 与配置行的 b_field_id 对应;
字段本身未启用时开关禁用(停用字段运行时不显示,参与无意义) */
function onToggleForm(field) {
if (props.loading) return
emit('updateEditConfig', toggleForm(props.editConfig, field, props.moduleId))
}
function onToggleList(field) {
if (props.loading) return
emit('updateListConfig', toggleList(props.listConfig, field, props.moduleId))
}
function onToggleQuery(field) {
if (props.loading) return
emit('updateQueryConfig', toggleQuery(props.queryConfig, field, props.moduleId))
}
const FormCell = createParticipationCell({ isOn: (f) => isFormOn(props.editConfig, f), onToggle: onToggleForm, title: '参与表单' })
const ListCell = createParticipationCell({ isOn: (f) => isListOn(props.listConfig, f), onToggle: onToggleList, title: '参与列表显示' })
const QueryCell = createParticipationCell({ isOn: (f) => isQueryOn(props.queryConfig, f), onToggle: onToggleQuery, title: '作为查询条件' })
const columns = shallowRef(buildColumns())
// 列宽拖拽结果随 v-model 写回 columns,仅在模块类型变化时重建
watch(
() => props.moduleType,
() => {
columns.value = buildColumns()
},
)
function buildColumns() {
const baseColumns = [
{ dataIndex: 'b_field', title: '字段名', width: 150 },
{ dataIndex: 'b_name', title: '字段名称', width: 140, editor: { type: 'input' } },
{
@@ -97,6 +155,16 @@ const columns = shallowRef([
},
{ dataIndex: 'b_default_value', title: '默认值', width: 120, editor: { type: 'input' } },
{ dataIndex: 'b_i18n', title: '多语言键', width: 220, customCell: I18nKeyCell },
]
// 表单参与列仅数据模块展示(查询模块无表单设计)
const participationColumns = [
...(props.moduleType === 'data'
? [{ dataIndex: '__form', title: '表单', width: 64, align: 'center', customCell: FormCell }]
: []),
{ dataIndex: '__list', title: '列表', width: 64, align: 'center', customCell: ListCell },
{ dataIndex: '__query', title: '查询', width: 64, align: 'center', customCell: QueryCell },
]
const tailColumns = [
{
dataIndex: 'b_canuse',
title: '启用',
@@ -104,7 +172,9 @@ const columns = shallowRef([
align: 'center',
editor: { type: 'checkbox' },
},
])
]
return [...baseColumns, ...participationColumns, ...tailColumns]
}
</script>
<template>
@@ -0,0 +1,29 @@
/**
* 字段参与开关单元格(h 渲染函数):ui Switch 居中渲染,点击切换参与状态。
* 供字段定义面板 FmsTable 的 表单/列表/查询 三列 customCell 使用。
* 表格 cell 由 h() 动态渲染、不受 scoped 样式约束,样式放独立 scss 随本模块全局引入。
*/
import { h } from 'vue'
import { Switch } from '@/components/ui'
import './participation-cell.scss'
/**
* @param {object} opts
* @param {(row: object) => boolean} opts.isOn 该行当前是否参与
* @param {(row: object) => void} opts.onToggle 点击开关
* @param {string} opts.title 开关提示文案(如「参与列表显示」)
*/
export function createParticipationCell({ isOn, onToggle, title }) {
return ({ row }) =>
h('div', { class: 'mm-part-cell' }, [
h(Switch, {
modelValue: isOn(row),
title,
'aria-label': title,
onClick: (event) => {
event.stopPropagation()
onToggle(row)
},
}),
])
}
@@ -0,0 +1,7 @@
/* 字段参与开关单元格(ParticipationCell):随 JS 模块全局引入 */
.mm-part-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
@@ -10,7 +10,10 @@ import { loadDataApi, describeApi } from '@/services/api'
import { cloneData } from '@/utils/dataChanges'
import { createTempId } from '@/utils/tempId'
import { buildModuleItemI18nKey } from '@/utils/i18n'
import { listRowDefaults, queryRowDefaults } from '../fieldAreaDefaults'
import {
createListConfigRow,
createQueryConfigRow,
} from '../fieldAreaDefaults'
export function useModuleConfig(deps) {
const {
@@ -214,7 +217,7 @@ export function useModuleConfig(deps) {
return ids
}
// 为尚未配置的字段生成列表/查询默认行(参考旧系统 buildMissingDefaultConfigs)。
// 为尚未配置的字段生成列表/查询默认行(行结构统一走 fieldAreaDefaults 的工厂)。
// 默认属性见 fieldAreaDefaults:默认不参与的字段(大文本等)同样生成行、开关置 0,
// 保留配置空间;表单设计(editConfig)不自动生成:字段由用户在表单设计器中逐个添加,
// 自动铺满会与设计器的画布/分组/栅格编辑冲突。
@@ -230,27 +233,10 @@ export function useModuleConfig(deps) {
const fieldId = String(field.b_id)
const order = orderIndex * 10
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_i18n: field.b_i18n || null,
b_xh: order,
...listRowDefaults(field),
})
generated.listConfig.push({ ...createListConfigRow(moduleId, field, []), b_xh: order })
}
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_module_id: moduleId,
b_field_id: fieldId,
b_relation_id: null,
b_default_value: null,
b_xh: order,
...queryRowDefaults(field),
})
generated.queryConfig.push({ ...createQueryConfigRow(moduleId, field, []), b_xh: order })
}
}
return generated
@@ -1,14 +1,16 @@
/**
* 字段区域默认策略(唯一真源):
* 「字段定义 → 列表列 / 查询条件默认配置行」的规则集中于此,供
* useModuleConfig.buildMissingDefaultConfigs(同步字段、补充缺失)与查询面板
* (选定字段后的组件/操作符重置)共用。
* useModuleConfig.buildMissingDefaultConfigs(同步字段、补充缺失)、查询面板
* (选定字段后的组件/操作符重置)与字段定义面板的参与开关共用。
*
* 参与语义约定:配置行存在 = 已配置;是否参与运行时由行内开关表达
* (列表 b_visible、查询 b_canuse)。默认不参与的字段(大文本)同样生成行、
* 开关置 0——保留配置空间,勾选即恢复;删除行仅用于清掉不该存在的行(误加的查询条件)。
*/
import { createTempId } from '@/utils/tempId'
/** 大文本字段:默认不进列表、默认不作为查询条件 */
function isLargeText(field) {
return field.b_type === 'textarea' || field.b_dbtype === 'text'
@@ -57,3 +59,47 @@ export function queryRowDefaults(field) {
b_canuse: isLargeText(field) ? 0 : 1,
}
}
/* ---------- 默认配置行工厂 ----------
* 与 useModuleConfig.buildMissingDefaultConfigs、字段定义面板参与开关共用,
* 保证「同步字段自动补全」与「手动打开参与开关」生成的行结构一致。
* forceOn:显式意愿(手动开开关)时无视大文本默认不参与的策略,置 1。
*/
function nextXh(rows) {
let max = 0
for (const row of rows || []) {
const xh = Number(row.b_xh) || 0
if (xh > max) max = xh
}
return max + 10
}
/** 字段 → 列表列配置行(s_field_view),追加到末尾时使用 */
export function createListConfigRow(moduleId, field, rows, { forceOn = false } = {}) {
return {
b_id: createTempId(),
b_module_id: String(moduleId),
b_field_id: String(field.b_id),
b_group_id: null,
b_i18n: field.b_i18n || null,
b_xh: nextXh(rows),
...listRowDefaults(field),
...(forceOn ? { b_visible: 1 } : {}),
}
}
/** 字段 → 查询条件配置行(s_field_query) */
export function createQueryConfigRow(moduleId, field, rows, { forceOn = false } = {}) {
return {
b_id: createTempId(),
b_module_id: String(moduleId),
b_field_module_id: String(moduleId),
b_field_id: String(field.b_id),
b_relation_id: null,
b_default_value: null,
b_xh: nextXh(rows),
...queryRowDefaults(field),
...(forceOn ? { b_canuse: 1 } : {}),
}
}
@@ -0,0 +1,80 @@
/**
* 字段参与开关(纯逻辑,无 Vue 依赖):
* 「字段是否参与 表单/列表/查询」的判定与切换,供字段定义面板的三个开关列使用。
*
* 语义沿用 fieldAreaDefaults 的项目约定(行存在 = 已配置,行内开关 = 是否参与):
* - 表单参与:editConfig 行存在且 b_visible=1;关 = 置 b_visible=0,不删行
* (保留画布布局/栅格,与列表「删除列=隐藏」同一哲学)
* - 列表参与:listConfig 行存在且 b_visible=1(行由加载自动补全,通常恒存在)
* - 查询参与:queryConfig 行存在且 b_canuse=1(行不自动生成,开 = 需要时按默认生成)
*
* 所有 toggle 返回新数组:目标行浅拷贝后修改,其余行保持原引用;
* 行不存在且要「关」时无操作,原样返回(调用方可直接 emit)。
*/
import { createListConfigRow, createQueryConfigRow } from './fieldAreaDefaults'
import { createEditRowFactory } from './editPanelShared'
const findRow = (rows, fieldId) =>
(rows || []).find((row) => String(row.b_field_id) === String(fieldId))
/** 表单行工厂:b_colspan 24 独占一行(点击新增语义,见 FormEditPanel.insertField) */
function createFormRow(field, moduleId, rows) {
const row = createEditRowFactory({ moduleId: String(moduleId) })(field, null)
// 末尾追加:b_xh 取现有最大值 + 10(工厂默认 0 会排到表单最前)
let max = 0
for (const item of rows || []) {
const xh = Number(item.b_xh) || 0
if (xh > max) max = xh
}
row.b_xh = max + 10
return row
}
function withRowToggled(rows, row, key) {
return rows.map((item) => (item === row ? { ...item, [key]: Number(item[key]) === 1 ? 0 : 1 } : item))
}
/* ---------- 判定 ---------- */
export function isFormOn(editConfig, field) {
const row = findRow(editConfig, field.b_id)
return !!row && Number(row.b_visible) === 1
}
export function isListOn(listConfig, field) {
const row = findRow(listConfig, field.b_id)
return !!row && Number(row.b_visible) === 1
}
export function isQueryOn(queryConfig, field) {
const row = findRow(queryConfig, field.b_id)
return !!row && Number(row.b_canuse) === 1
}
/* ---------- 切换 ---------- */
/** 行不存在时追加到末尾;forceOn 的行由工厂置参与标志为 1(显式意愿) */
function appendRow(rows, row) {
return [...(rows || []), row]
}
export function toggleForm(editConfig, field, moduleId) {
const rows = editConfig || []
const row = findRow(rows, field.b_id)
if (row) return withRowToggled(rows, row, 'b_visible')
return appendRow(rows, createFormRow(field, moduleId, rows))
}
export function toggleList(listConfig, field, moduleId) {
const rows = listConfig || []
const row = findRow(rows, field.b_id)
if (row) return withRowToggled(rows, row, 'b_visible')
return appendRow(rows, createListConfigRow(moduleId, field, rows, { forceOn: true }))
}
export function toggleQuery(queryConfig, field, moduleId) {
const rows = queryConfig || []
const row = findRow(rows, field.b_id)
if (row) return withRowToggled(rows, row, 'b_canuse')
return appendRow(rows, createQueryConfigRow(moduleId, field, rows, { forceOn: true }))
}
@@ -503,8 +503,15 @@ loadModuleTree()
:module-id="String(selectedModule.b_id)"
:module-code="drafts?.module?.b_code || ''"
:fields="drafts?.fields"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:module-type="moduleType"
:loading="busy"
@update="onFieldsUpdate"
@update-edit-config="(v) => onModuleUpdate('editConfig', v)"
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
@update-query-config="(v) => onModuleUpdate('queryConfig', v)"
@i18n-saved="onModuleI18nSaved"
/>
</div>
@@ -0,0 +1,112 @@
/**
* 字段参与开关(fieldParticipation)测试:
* - 判定:行存在 + 行内开关(表单/列表 b_visible、查询 b_canuse),b_field_id 与 b_id 类型不一致按字符串比较
* - 切换:行为空/存在两路;关 = 置行内开关 0(不删行,保留布局/配置);开 = 行不存在时按默认生成且强制参与
* - 不变性:toggle 返回新数组、非目标行保持原引用
*/
import { describe, it, expect } from 'vitest'
import {
isFormOn,
isListOn,
isQueryOn,
toggleForm,
toggleList,
toggleQuery,
} from '@/views/system/module-management/fieldParticipation'
const MODULE_ID = 'm1'
function makeField(over = {}) {
return { b_id: '10', b_field: 'amount', b_name: '金额', b_type: 'money', b_canuse: 1, ...over }
}
describe('参与判定', () => {
const field = makeField()
it('行存在且开关为 1 即参与;行不存在不参与', () => {
expect(isFormOn([{ b_field_id: '10', b_visible: 1 }], field)).toBe(true)
expect(isFormOn([{ b_field_id: '10', b_visible: 0 }], field)).toBe(false)
expect(isFormOn([], field)).toBe(false)
expect(isListOn([{ b_field_id: 10, b_visible: 1 }], field)).toBe(true) // 类型不一致按字符串比较
expect(isQueryOn([{ b_field_id: '10', b_canuse: 1 }], field)).toBe(true)
expect(isQueryOn([{ b_field_id: '10', b_canuse: 0 }], field)).toBe(false)
})
it('空配置安全', () => {
expect(isFormOn(undefined, field)).toBe(false)
expect(isListOn(null, field)).toBe(false)
expect(isQueryOn([], field)).toBe(false)
})
})
describe('表单参与切换', () => {
it('行存在:切换 b_visible,不删行(保留布局)', () => {
const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1, b_colspan: 12, b_group_id: 'g1' }]
const off = toggleForm(rows, makeField(), MODULE_ID)
expect(off).toHaveLength(1)
expect(off[0].b_visible).toBe(0)
expect(off[0].b_colspan).toBe(12) // 布局属性保留
expect(off[0].b_group_id).toBe('g1')
const on = toggleForm(off, makeField(), MODULE_ID)
expect(on[0].b_visible).toBe(1)
})
it('行不存在:生成默认行追加到末尾(b_visible=1、b_colspan=24、b_xh 为末尾序号)', () => {
const rows = [{ b_id: 'e1', b_field_id: '9', b_visible: 1, b_xh: 30 }]
const next = toggleForm(rows, makeField(), MODULE_ID)
expect(next).toHaveLength(2)
const created = next[1]
expect(created.b_field_id).toBe('10')
expect(created.b_visible).toBe(1)
expect(created.b_colspan).toBe(24)
expect(created.b_module_id).toBe('m1')
expect(created.b_xh).toBe(40) // 末尾追加,不排到表单最前
expect(next[0]).toBe(rows[0]) // 非目标行保持原引用
})
})
describe('列表参与切换', () => {
it('行存在:切换 b_visible(与「删除列=隐藏」语义一致)', () => {
const rows = [{ b_id: 'v1', b_field_id: '10', b_visible: 1, b_width: 110 }]
const off = toggleList(rows, makeField(), MODULE_ID)
expect(off[0].b_visible).toBe(0)
expect(off[0].b_width).toBe(110)
expect(toggleList(off, makeField(), MODULE_ID)[0].b_visible).toBe(1)
})
it('行不存在:按默认生成且强制显示(含大文本)', () => {
const textarea = makeField({ b_type: 'textarea' })
const next = toggleList([], textarea, MODULE_ID)
expect(next).toHaveLength(1)
expect(next[0].b_visible).toBe(1) // 显式意愿覆盖大文本默认不参与
expect(next[0].b_xh).toBe(10)
})
})
describe('查询参与切换', () => {
it('行存在:切换 b_canuse', () => {
const rows = [{ b_id: 'q1', b_field_id: '10', b_canuse: 0, b_operator: 'eq' }]
const on = toggleQuery(rows, makeField(), MODULE_ID)
expect(on[0].b_canuse).toBe(1)
expect(on[0].b_operator).toBe('eq') // 既有查询配置保留
expect(toggleQuery(on, makeField(), MODULE_ID)[0].b_canuse).toBe(0)
})
it('行不存在:按默认生成(money → number/eq)且强制参与', () => {
const next = toggleQuery([], makeField(), MODULE_ID)
expect(next).toHaveLength(1)
expect(next[0].b_canuse).toBe(1)
expect(next[0].b_component).toBe('number')
expect(next[0].b_operator).toBe('eq')
expect(next[0].b_field_module_id).toBe('m1')
})
})
describe('切换的不变性', () => {
it('返回新数组,不修改原数组', () => {
const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1 }]
const next = toggleForm(rows, makeField(), MODULE_ID)
expect(next).not.toBe(rows)
expect(rows[0].b_visible).toBe(1) // 原行未被修改
})
})