refactor(module-management): 阶段二——抽取字段查询与共享纯函数

- 新建 composables/useFieldLookup.js:FIELD_TYPE_LABELS 常量 +
  useFieldLookup(fieldsRef) 返回 fieldNameMap/fieldMap/fieldOf/fieldName/controlTypeLabel
- 新建 editPanelShared.js(纯函数,无 Vue 依赖):renumberRows/buildVisibilityMap/
  buildClipboardProps/applyClipboardProps/toggleFlag/filterLibraryFields/createEditRowFactory
- ModuleTableEditPanel/ModuleFormEditPanel/ModuleStructureTree 三处同体字段查询
  统一改用 useFieldLookup,删除各自 FIELD_TYPE_LABELS/fieldNameMap/fieldMap/fieldOf/
  fieldName/controlTypeLabel 复制体
- 两面板 syncToList/syncToQuery 共享 buildVisibilityMap(emit 与提示文案留在各自面板)
- 两面板 copyRowProps/pastePropsToSelected 共享 buildClipboardProps/applyClipboardProps:
  键集合 diff 后参数化(子表 b_width / 主表 b_colspan+spanOf 归一),行为不变
- createEditRow 语义相同、细节不同(子表写 b_width、主表写 b_colspan)→
  抽参数化工厂 createEditRowFactory({ moduleId, getExtra }),两面板各自传参
- 主表 groupIdOf 与 useGroupTree.groupIdOfRow 函数体逐字相同 → 改导入(别名保持调用点不变)
- 字段库 libraryFields 的过滤排序逻辑抽为 filterLibraryFields(.sort→.toSorted,
  消除 oxlint 变异告警);toggleInspector/toggleRowFlag 核心抽为 toggleFlag

行为不变;测试 184 通过(2 个既有失败与本重构无关);lint 告警 60→59(净减 1)
This commit is contained in:
oneao committed 2026-08-28 15:22:04 +08:00
1 parent 158eb3c029
commit 27752351b9
5 files changed
+208 -240

No files matched your search

@@ -36,6 +36,17 @@ import {
} from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { groupIdOfRow as groupIdOf } from '../composables/useGroupTree'
import { useFieldLookup } from '../composables/useFieldLookup'
import {
applyClipboardProps,
buildClipboardProps,
buildVisibilityMap,
createEditRowFactory,
filterLibraryFields,
renumberRows,
toggleFlag,
} from '../editPanelShared'
const props = defineProps({
moduleId: { type: String, required: true },
@@ -186,14 +197,6 @@ function emitRows(rows) {
emitUpdate()
}
function renumberRows(rows) {
return rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
}
function groupIdOf(row) {
return row?.b_group_id == null ? null : String(row.b_group_id)
}
/* 逻辑行推导:与 CSS grid 自动换行一致——从分组段首部顺序向前打包,
累计宽度超过 24 即换行。返回目标字段所在视觉行的索引,及行内其它字段宽度和 */
function collectLineIndexes(rows, insertedIndex) {
@@ -245,21 +248,9 @@ function rebalanceInsert(rows, insertedIndex, { fill = true } = {}) {
}
/* ================= 公共查询表 ================= */
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const fieldMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field)
}
return map
})
const { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel } = useFieldLookup(
() => props.fields,
)
const groupMap = computed(() => {
const map = new Map()
@@ -281,12 +272,7 @@ const groupOptions = computed(() => {
/* ================= 数据视图:同步到列表 / 查询 ================= */
function syncToList() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
@@ -306,12 +292,7 @@ function syncToList() {
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
@@ -389,17 +370,6 @@ function formDataColumns() {
const dataColumns = computed(() => formDataColumns())
/* ================= 设计视图:字段/分组推导 ================= */
const FIELD_TYPE_LABELS = {
input: '输入框',
textarea: '文本域',
number: '数字',
money: '金额',
date: '日期',
datetime: '日期时间',
checkbox: '复选框',
select: '下拉选择',
}
// 24 栅格制下的常用宽度选项(栅格宽度 b_colspan)
const COLSPAN_OPTIONS = [
{ label: '整行 24/24', value: '24' },
@@ -421,22 +391,6 @@ function spanOf(row) {
return v >= 1 && v <= 24 ? v : 24
}
function fieldOf(row) {
if (row?.b_field_id == null) return null
return fieldMap.value.get(String(row.b_field_id)) || null
}
function fieldName(row) {
const fid = row?.b_field_id
if (fid == null) return ''
return fieldNameMap.value.get(String(fid)) || String(fid)
}
function controlTypeLabel(row) {
const field = fieldOf(row)
return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知'
}
function isTextarea(row) {
return fieldOf(row)?.b_type === 'textarea'
}
@@ -470,19 +424,7 @@ function heightOf(row) {
const libSearch = ref('')
// 字段库:展示模块的全部字段(含未加入表单的字段),按名称 a-z 排序(无类型分组小标题);
// 搜索时按名称/编码过滤,命中才显示
const libraryFields = computed(() => {
const kw = libSearch.value.trim().toLowerCase()
return [...(props.fields || [])]
.filter(
(f) =>
!kw ||
String(f.b_name || '').toLowerCase().includes(kw) ||
String(f.b_field || '').toLowerCase().includes(kw),
)
.sort((a, b) =>
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
)
})
const libraryFields = computed(() => filterLibraryFields(props.fields, libSearch.value))
function isFieldAdded(field) {
return localEdit.value.some((row) => String(row.b_field_id) === String(field.b_id))
@@ -732,24 +674,14 @@ function selectRow(row) {
}
// 字段库 → 画布:已加入则定位选中,未加入则按目标位置插入。
function createEditRow(field, groupId) {
// 表单行默认:b_colspan 取该分组设置的默认宽度(默认 24 独占一行),无 b_width
const createEditRow = createEditRowFactory({
moduleId: props.moduleId,
getExtra: (field, groupId) => {
const sectionKey = groupId == null ? 'ungrouped' : `g-${String(groupId)}`
return {
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: String(field.b_id),
b_group_id: groupId || null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
// 新字段宽度取该分组设置的默认宽度(默认 24 独占一行)
b_colspan: fieldColsOf(sectionKey),
b_height: field.b_type === 'textarea' ? 120 : null,
b_placeholder: null,
b_xh: 0,
}
}
return { b_colspan: fieldColsOf(sectionKey) }
},
})
function insertField(
field,
@@ -1219,7 +1151,7 @@ function moveSelectedRow(delta) {
function toggleRowFlag(row, key) {
if (!row || props.loading) return
row[key] = Number(row[key]) === 1 ? 0 : 1
toggleFlag(row, key)
emitUpdate()
}
@@ -1230,28 +1162,21 @@ function onLabelChip(row, key) {
/* 复制/粘贴:仅复制布局与状态属性(不含标识、排序号、分组),用于把某字段的配置套用到其它字段 */
const clipboardProps = ref(null)
// 表单复制属性键:布局与状态属性(栅格宽度 b_colspan 需按 spanOf 归一)
const CLIP_KEYS = ['b_colspan', 'b_required', 'b_readonly', 'b_disabled', 'b_visible', 'b_placeholder', 'b_height']
function copyRowProps() {
const row = selectedRow.value
if (!row || props.loading) return
clipboardProps.value = {
name: fieldName(row),
props: {
b_colspan: spanOf(row),
b_required: Number(row.b_required) === 1 ? 1 : 0,
b_readonly: Number(row.b_readonly) === 1 ? 1 : 0,
b_disabled: Number(row.b_disabled) === 1 ? 1 : 0,
b_visible: Number(row.b_visible) === 1 ? 1 : 0,
b_placeholder: row.b_placeholder || null,
b_height: row.b_height == null ? null : Number(row.b_height) || null,
},
}
clipboardProps.value = buildClipboardProps(row, fieldName, CLIP_KEYS, {
b_colspan: (r) => spanOf(r),
})
}
function pastePropsToSelected() {
const row = selectedRow.value
if (!row || props.loading || !clipboardProps.value) return
Object.assign(row, clipboardProps.value.props)
applyClipboardProps(row, clipboardProps.value.props)
emitUpdate()
}
@@ -1354,7 +1279,7 @@ function libClone(field) {
function toggleInspector(key) {
const row = selectedRow.value
if (!row || props.loading) return
row[key] = Number(row[key]) === 1 ? 0 : 1
toggleFlag(row, key)
emitUpdate()
}
@@ -27,6 +27,16 @@ import ModuleStructureTree from './structure-tree/ModuleStructureTree.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { useGroupTree, buildGroupTree } from '../composables/useGroupTree'
import { useFieldLookup } from '../composables/useFieldLookup'
import {
applyClipboardProps,
buildClipboardProps,
buildVisibilityMap,
createEditRowFactory,
filterLibraryFields,
renumberRows,
toggleFlag,
} from '../editPanelShared'
const props = defineProps({
moduleId: { type: String, required: true },
@@ -83,41 +93,19 @@ function emitUpdate() {
/* ================= 数据视图:行排序 ================= */
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localEdit.value = sorted
localEdit.value = renumberRows(rows)
emitUpdate()
}
function renumberRows(rows) {
return rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
}
/* ================= 公共查询表 ================= */
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const fieldMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field)
}
return map
})
const { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel } = useFieldLookup(
() => props.fields,
)
/* ================= 数据视图:同步到列表 / 查询 ================= */
function syncToList() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
@@ -137,12 +125,7 @@ function syncToList() {
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
@@ -205,16 +188,6 @@ function tableDataColumns() {
const dataColumns = computed(() => tableDataColumns())
/* ================= 设计视图:字段/分组推导 ================= */
const FIELD_TYPE_LABELS = {
input: '输入框',
textarea: '文本域',
number: '数字',
money: '金额',
date: '日期',
datetime: '日期时间',
checkbox: '复选框',
select: '下拉选择',
}
/* 子表常用列宽(px) */
const COLUMN_WIDTH_OPTIONS = [
@@ -225,22 +198,6 @@ const COLUMN_WIDTH_OPTIONS = [
{ label: '大 240', value: '240' },
]
function fieldOf(row) {
if (row?.b_field_id == null) return null
return fieldMap.value.get(String(row.b_field_id)) || null
}
function fieldName(row) {
const fid = row?.b_field_id
if (fid == null) return ''
return fieldNameMap.value.get(String(fid)) || String(fid)
}
function controlTypeLabel(row) {
const field = fieldOf(row)
return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知'
}
/* ---- 表格(子表)模式:列宽 ---- */
function widthOf(row) {
const v = Number(row?.b_width)
@@ -298,19 +255,7 @@ const libSearch = ref('')
const draggingFieldId = ref(null)
// 字段库:展示模块的全部字段(含未加入表格的字段),按名称 a-z 排序;
// 搜索时按名称/编码过滤,命中才显示
const libraryFields = computed(() => {
const kw = libSearch.value.trim().toLowerCase()
return [...(props.fields || [])]
.filter(
(f) =>
!kw ||
String(f.b_name || '').toLowerCase().includes(kw) ||
String(f.b_field || '').toLowerCase().includes(kw),
)
.toSorted((a, b) =>
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
)
})
const libraryFields = computed(() => filterLibraryFields(props.fields, libSearch.value))
function isFieldAdded(field) {
return localEdit.value.some((row) => String(row.b_field_id) === String(field.b_id))
@@ -781,23 +726,11 @@ function toggleBandEnable() {
}
/* ================= 列:增删改 ================= */
function createEditRow(field, groupId) {
return {
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: String(field.b_id),
b_group_id: groupId || null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_height: field.b_type === 'textarea' ? 120 : null,
b_placeholder: null,
b_width: defaultWidthOf(field),
b_xh: 0,
}
}
// 子表行默认:b_colspan=24(表格不使用),额外补 b_width(按字段类型取默认列宽)
const createEditRow = createEditRowFactory({
moduleId: props.moduleId,
getExtra: (field) => ({ b_width: defaultWidthOf(field) }),
})
// 插入到某分组带的直属列末尾(空分组按树前序位置)
function appendIntoGroup(row, groupId, rows) {
@@ -938,28 +871,19 @@ function scrollToRowCell(fieldId) {
/* ================= 复制/粘贴(列属性) ================= */
const clipboardProps = ref(null)
// 子表复制属性键:布局与状态属性(不含标识、排序号、分组)
const CLIP_KEYS = ['b_required', 'b_readonly', 'b_disabled', 'b_visible', 'b_placeholder', 'b_height', 'b_width']
function copyRowProps() {
const row = selectedRow.value
if (!row || props.loading) return
clipboardProps.value = {
name: fieldName(row),
props: {
b_required: Number(row.b_required) === 1 ? 1 : 0,
b_readonly: Number(row.b_readonly) === 1 ? 1 : 0,
b_disabled: Number(row.b_disabled) === 1 ? 1 : 0,
b_visible: Number(row.b_visible) === 1 ? 1 : 0,
b_placeholder: row.b_placeholder || null,
b_height: row.b_height == null ? null : Number(row.b_height) || null,
b_width: row.b_width == null ? null : Number(row.b_width) || null,
},
}
clipboardProps.value = buildClipboardProps(row, fieldName, CLIP_KEYS)
}
function pastePropsToSelected() {
const row = selectedRow.value
if (!row || props.loading || !clipboardProps.value) return
Object.assign(row, clipboardProps.value.props)
applyClipboardProps(row, clipboardProps.value.props)
emitUpdate()
}
@@ -1241,7 +1165,7 @@ function onColResizePointerEnd() {
function toggleInspector(key) {
const row = selectedRow.value
if (!row || props.loading) return
row[key] = Number(row[key]) === 1 ? 0 : 1
toggleFlag(row, key)
emitUpdate()
}
@@ -27,6 +27,7 @@ import {
import draggable from 'vuedraggable'
import StructureTreeNode from './StructureTreeNode.vue'
import { useGroupTree } from '../../composables/useGroupTree'
import { useFieldLookup } from '../../composables/useFieldLookup'
const props = defineProps({
groups: { type: Array, default: () => [] },
@@ -53,36 +54,7 @@ const emit = defineEmits([
])
/* ================= 字段/名称推导 ================= */
const FIELD_TYPE_LABELS = {
input: '输入框',
textarea: '文本域',
number: '数字',
money: '金额',
date: '日期',
datetime: '日期时间',
checkbox: '复选框',
select: '下拉选择',
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) map.set(String(field.b_id), field.b_name || field.b_field)
return map
})
const fieldMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) map.set(String(field.b_id), field)
return map
})
function fieldName(row) {
if (row?.b_field_id == null) return ''
return fieldNameMap.value.get(String(row.b_field_id)) || String(row.b_field_id)
}
function controlTypeLabel(row) {
const field = row?.b_field_id != null ? fieldMap.value.get(String(row.b_field_id)) : null
return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知'
}
const { fieldName, controlTypeLabel } = useFieldLookup(() => props.fields)
/* ================= 分组树构建 =================
* 树构建与元信息推导见 composables/useGroupTree(面板与结构树共用)。 */
@@ -0,0 +1,59 @@
/**
* 字段查询与标签共享逻辑。
* 供 ModuleTableEditPanel / ModuleFormEditPanel / ModuleStructureTree 共用:
* 字段名 Map、字段对象 Map、字段查询函数、控件类型标签。
*/
import { computed } from 'vue'
export const FIELD_TYPE_LABELS = {
input: '输入框',
textarea: '文本域',
number: '数字',
money: '金额',
date: '日期',
datetime: '日期时间',
checkbox: '复选框',
select: '下拉选择',
}
/**
* @param {import('vue').Ref<Array>|import('vue').ComputedRef<Array>|(() => Array)} fieldsRef
* 字段数组 ref/computed,或返回字段数组的 getter 函数
*/
export function useFieldLookup(fieldsRef) {
const fields = typeof fieldsRef === 'function' ? computed(fieldsRef) : fieldsRef
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of fields.value || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const fieldMap = computed(() => {
const map = new Map()
for (const field of fields.value || []) {
map.set(String(field.b_id), field)
}
return map
})
function fieldOf(row) {
if (row?.b_field_id == null) return null
return fieldMap.value.get(String(row.b_field_id)) || null
}
function fieldName(row) {
const fid = row?.b_field_id
if (fid == null) return ''
return fieldNameMap.value.get(String(fid)) || String(fid)
}
function controlTypeLabel(row) {
const field = fieldOf(row)
return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知'
}
return { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel }
}
@@ -0,0 +1,88 @@
/**
* 两个编辑面板共享的纯函数(无 Vue 依赖)。
* 栅格重排、列属性复制/粘贴、可见性 Map、字段库搜索、编辑行构建等。
*/
import { createTempId } from '@/utils/tempId'
/** 按索引重排 b_xh:从 10 开始,步长 10 */
export function renumberRows(rows) {
return rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
}
/** 构建字段可见性 Map:b_field_id → b_visible(1/0) */
export function buildVisibilityMap(rows) {
const map = new Map()
for (const row of rows) {
if (row.b_field_id != null) {
map.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
return map
}
/**
* 复制列属性:提取指定 keys 的标准化值,返回 { name, props }。
* 特殊键处理:b_placeholder → 字符串或 null;b_height/b_width → 数值或 null;
* 其余按键的 0/1 标志归一。normalize 可覆盖个别键的自定义取值(如 b_colspan 需 spanOf)。
*/
export function buildClipboardProps(row, fieldNameOf, keys, normalize = {}) {
const props = {}
for (const k of keys) {
if (normalize[k]) props[k] = normalize[k](row)
else if (k === 'b_placeholder') props[k] = row.b_placeholder || null
else if (k === 'b_height' || k === 'b_width') props[k] = row[k] == null ? null : Number(row[k]) || null
else props[k] = Number(row[k]) === 1 ? 1 : 0
}
return { name: fieldNameOf(row), props }
}
/** 将剪切板属性对象应用到行的目标属性 */
export function applyClipboardProps(row, props) {
Object.assign(row, props)
}
/** 切换字段布尔标志(纯 mutation) */
export function toggleFlag(row, key) {
row[key] = Number(row[key]) === 1 ? 0 : 1
}
/** 按关键字过滤字段(名称/编码),按名称排序 */
export function filterLibraryFields(fields, keyword) {
const kw = String(keyword || '').trim().toLowerCase()
return [...(fields || [])]
.filter(
(f) =>
!kw ||
String(f.b_name || '').toLowerCase().includes(kw) ||
String(f.b_field || '').toLowerCase().includes(kw),
)
.toSorted((a, b) =>
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
)
}
/**
* 创建编辑行工厂函数。
* 共享:b_id / b_module_id / b_field_id / b_group_id / b_visible / b_required / b_readonly / b_disabled
* / b_colspan(默认24) / b_height(textarea 120) / b_placeholder / b_xh
* 差异:由 getExtra(field, groupId) 补充(如 b_width / b_colspan 覆盖)。
* @param {{ moduleId: string, getExtra?: (field: object, groupId: string|null) => object }} opts
* @returns {(field: object, groupId: string|null) => object}
*/
export function createEditRowFactory({ moduleId, getExtra }) {
return (field, groupId) => ({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: String(field.b_id),
b_group_id: groupId || null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_height: field.b_type === 'textarea' ? 120 : null,
b_placeholder: null,
b_xh: 0,
...(getExtra ? getExtra(field, groupId) : {}),
})
}