+
删除
@@ -297,10 +383,15 @@ function switchView(mode) {
上方表格仅作预览,拖列头右缘可调列宽
下方分组树:拖拽排序 / 嵌套 / 移动列
右键节点可新增子分组 / 重命名 / 删除
- Ctrl+C 复制选中列属性
- Ctrl+X 剪切(复制并删除当前列)
- Ctrl+V 粘贴属性到选中列
- Delete 删除选中列 / 分组带
+
+ Ctrl+C 复制选中列属性
+ Ctrl+X 剪切(复制并删除当前列)
+ Ctrl+V 粘贴属性到选中列
+ Delete 删除选中列 / 分组带
+
+
+ 删错的列从左侧字段库随时加回
+
@@ -311,7 +402,7 @@ function switchView(mode) {
:visual-columns="visualColumns"
:band-rows="bandRows"
:header-columns="headerColumns"
- :ungrouped-count="groupColumnsMap.ungrouped.length"
+ :ungrouped-count="ungroupedCount"
:selected-node-key="selectedNodeKey"
:loading="loading"
:width-of="widthOf"
@@ -320,6 +411,7 @@ function switchView(mode) {
:group-id-of-row="groupIdOfRow"
:commit-col-drag="commitColDrag"
:on-commit="emitUpdate"
+ :empty-text="isSubtable ? undefined : '暂无列配置'"
/>
@@ -329,14 +421,15 @@ function switchView(mode) {
{{ groupCount }} 个分组
- (selectedNodeKey = k)"
@col-drop="onColDrop"
@band-drop="onBandDrop"
@@ -350,7 +443,7 @@ function switchView(mode) {
暂无分组,点击工具栏「添加分组」
-
+
(inspectorCollapsed = v)"
@width-commit="onWidthCommit"
@toggle="toggleInspector"
@band-title-change="onBandTitleChange"
@toggle-band-enable="toggleBandEnable"
- @remove-selected="removeSelected"
+ @remove-selected="onRemoveSelected"
@add-sub-group="addSubGroup(selectedBand)"
@rename="structureTreeRef?.startRename(selectedBand?.gid)"
@delete-band="deleteBand(selectedBand)"
@@ -378,13 +473,13 @@ function switchView(mode) {
(activeKey = String(row.b_id))"
+ @row-click="onDataRowClick"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
@@ -452,7 +547,10 @@ function switchView(mode) {
line-height: 1;
padding-bottom: 1px;
cursor: help;
- transition: border-color 0.15s, color 0.15s, background 0.15s;
+ transition:
+ border-color 0.15s,
+ color 0.15s,
+ background 0.15s;
&:hover {
border-color: color-mix(in srgb, var(--fms-primary) 45%, transparent);
background: color-mix(in srgb, var(--fms-primary) 5%, var(--fms-card));
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/EditDataView.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/EditDataView.vue
similarity index 100%
rename from code/fms/fms-vue/src/views/system/module-management/components/edit-panels/EditDataView.vue
rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/EditDataView.vue
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue
similarity index 99%
rename from code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue
rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue
index 83bee0fa..cec2ec4b 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue
@@ -12,7 +12,7 @@
import { computed, ref } from 'vue'
import { Input } from '@/components/ui'
import draggable from 'vuedraggable'
-import { filterLibraryFields } from '../../editPanelShared'
+import { filterLibraryFields } from './useStructureEditing'
const props = defineProps({
/** 模块全部字段 */
diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js
similarity index 61%
rename from code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js
rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js
index 6b870603..266b5a47 100644
--- a/code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js
+++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js
@@ -1,10 +1,17 @@
/**
- * 结构编辑内核(分组树 + 行归属 + 平铺重编 + 选中态 + 列属性剪贴板)。
- * 由 TableEditPanel(子表设计,s_field_edit 行)与 ModuleListConfigPanel(列表配置,
- * s_field_view 行)共用:宿主只注入数据源(getter)、行工厂(差异字段)、
- * 提交回调(转 emit)与重命名请求(转发结构树 startRename)。
+ * 设计器内核(design-editor 唯一共享 js,纯编辑器逻辑、不与业务打交道)。
+ * 由 TableDesignEditor(子表/列表表格设计)与 FormEditPanel(表单画布设计)共用,按区块划分:
*
- * 语义约定:
+ * 1. 分组树推导 buildGroupTree / groupIdOfRow / useGroupTree
+ * 2. 字段查询 useFieldLookup(字段名/字段对象 Map、控件类型标签)
+ * 3. 多级表头布局 useBandLayout(visualColumns / bandRows / headerColumns)
+ * 4. 行编辑纯函数 renumberRows / 剪贴板 / toggleFlag / filterLibraryFields / createEditRowFactory
+ * 5. 结构编辑内核 useStructureEditing(分组树 + 行归属 + 平铺重编 + 选中态 + 列属性剪贴板)
+ *
+ * 业务默认策略(listRowDefaults / queryRowDefaults / createListConfigRow / createQueryConfigRow)
+ * 已拆至 ../fieldDefaults.js(业务层);编辑器内核不依赖它——差异行工厂由宿主注入。
+ *
+ * 语义约定(useStructureEditing):
* - 分组 b_xh 为「兄弟内编号」((i+1)*10),不同层可重复;
* - 行 b_xh 为「未分组在前 + 分组树先序」的全局平铺序,与运行时列序一致;
* - 删除分组:直接子分组提升为被删分组的父级,组内行归未分组;级联清理其它配置
@@ -13,8 +20,348 @@
import { computed, nextTick, ref, watch } from 'vue'
import { Message } from '@/components/ui'
import { createTempId } from '@/utils/tempId'
-import { useGroupTree, buildGroupTree } from './useGroupTree'
-import { applyClipboardProps, buildClipboardProps, renumberRows } from '../editPanelShared'
+
+/* ============================================================
+ * 1. 分组树推导(s_field_group 分组扁平数组 → 树 + 多级表头 band 元信息)
+ * ============================================================ */
+
+// toSorted 返回新数组,必须回写原数组,否则 buildGroupTree 的排序不生效
+function sortGroupNodesByXh(nodes) {
+ const sorted = nodes.toSorted((a, b) => (a.group.b_xh || 0) - (b.group.b_xh || 0))
+ nodes.splice(0, nodes.length, ...sorted)
+ return nodes
+}
+
+export function buildGroupTree(groups) {
+ const map = new Map()
+ const roots = []
+ for (const g of groups || []) {
+ map.set(String(g.b_id), { group: g, children: [] })
+ }
+ for (const g of groups || []) {
+ const node = map.get(String(g.b_id))
+ const parentId = g.b_parent_id == null ? null : String(g.b_parent_id)
+ if (parentId != null && map.has(parentId)) map.get(parentId).children.push(node)
+ else roots.push(node)
+ }
+ sortGroupNodesByXh(roots)
+ const walk = (nodes) => {
+ for (const node of nodes) {
+ sortGroupNodesByXh(node.children)
+ walk(node.children)
+ }
+ }
+ walk(roots)
+ return roots
+}
+
+export function groupIdOfRow(row) {
+ return row?.b_group_id == null ? null : String(row.b_group_id)
+}
+
+/**
+ * @param {import('vue').Ref|import('vue').ComputedRef} groupsRef 分组扁平数组
+ * @param {import('vue').Ref|import('vue').ComputedRef} rowsRef 编辑行数组
+ */
+export function useGroupTree(groupsRef, rowsRef) {
+ const groupTree = computed(() => buildGroupTree(groupsRef.value))
+
+ const groupNodeMap = computed(() => {
+ const map = new Map()
+ const walk = (nodes) => {
+ for (const node of nodes) {
+ map.set(String(node.group.b_id), node)
+ walk(node.children)
+ }
+ }
+ walk(groupTree.value)
+ return map
+ })
+
+ // groupId → 直属列(不递归收集);未分组列单独收集
+ const groupColumnsMap = computed(() => {
+ const map = new Map()
+ const ungrouped = []
+ for (const row of rowsRef.value || []) {
+ const gid = groupIdOfRow(row)
+ if (gid != null && groupNodeMap.value.has(gid)) {
+ if (!map.has(gid)) map.set(gid, [])
+ map.get(gid).push(row)
+ } else {
+ ungrouped.push(row)
+ }
+ }
+ return { map, ungrouped }
+ })
+
+ // 分组带元信息:层级(根=0)、直属列数、子树列数(span,空分组至少 1)、最大层级
+ const bandMeta = computed(() => {
+ const columns = groupColumnsMap.value
+ const metaMap = new Map()
+ let maxLevel = 0
+ const build = (nodes, level) =>
+ nodes.map((node) => {
+ const gid = String(node.group.b_id)
+ const meta = {
+ gid,
+ group: node.group,
+ level,
+ ownCount: (columns.map.get(gid) || []).length,
+ children: [],
+ span: 0,
+ }
+ metaMap.set(gid, meta)
+ meta.children = build(node.children, level + 1)
+ meta.span = Math.max(1, meta.ownCount + meta.children.reduce((s, c) => s + c.span, 0))
+ if (level > maxLevel) maxLevel = level
+ return meta
+ })
+ const roots = build(groupTree.value, 0)
+ return { roots, metaMap, maxLevel }
+ })
+
+ // gid 是否位于 ancestorGid 的子树内(防拖入自身子树成环)
+ function isDescendantGroup(ancestorGid, gid) {
+ const meta = bandMeta.value.metaMap.get(String(gid))
+ if (!meta) return false
+ let cur = meta.group.b_parent_id == null ? null : String(meta.group.b_parent_id)
+ while (cur != null) {
+ if (cur === String(ancestorGid)) return true
+ const m = bandMeta.value.metaMap.get(cur)
+ if (!m) break
+ cur = m.group.b_parent_id == null ? null : String(m.group.b_parent_id)
+ }
+ return false
+ }
+
+ return { groupColumnsMap, bandMeta, isDescendantGroup, groupIdOfRow }
+}
+
+/* ============================================================
+ * 2. 字段查询与标签共享逻辑
+ * ============================================================ */
+
+const FIELD_TYPE_LABELS = {
+ input: '输入框',
+ textarea: '文本域',
+ number: '数字',
+ money: '金额',
+ date: '日期',
+ datetime: '日期时间',
+ checkbox: '复选框',
+ select: '下拉选择',
+}
+
+/**
+ * @param {import('vue').Ref|import('vue').ComputedRef|(() => 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 }
+}
+
+/* ============================================================
+ * 3. 多级表头渲染推导(纯 computed,无状态)
+ * ============================================================ */
+
+/**
+ * @param {{ groupColumnsMap: import('vue').ComputedRef<{map: Map, ungrouped: Array}>,
+ * bandMeta: import('vue').ComputedRef<{roots: Array, maxLevel: number}> }} tree
+ */
+export function useBandLayout(tree) {
+ const { groupColumnsMap, bandMeta } = tree
+
+ // 视觉列序:未分组列在前,随后分组树先序(每分组:直属列 → 空叶子分组补 slot 占位)
+ const visualColumns = computed(() => {
+ const columns = groupColumnsMap.value
+ const list = []
+ for (const row of columns.ungrouped) {
+ list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: null })
+ }
+ const walk = (nodes) => {
+ for (const node of nodes) {
+ const own = columns.map.get(node.gid) || []
+ for (const row of own) {
+ list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: node.gid })
+ }
+ if (!own.length && !node.children.length) {
+ list.push({ kind: 'slot', key: `slot-${node.gid}`, gid: node.gid, group: node.group })
+ }
+ walk(node.children)
+ }
+ }
+ walk(bandMeta.value.roots)
+ return list
+ })
+
+ // 多级表头 band 行:每层一个 tr 的单元格序列;未分组列头在第 0 层,贯穿全部表头行高
+ const bandRows = computed(() => {
+ const { roots, maxLevel } = bandMeta.value
+ if (!roots.length) return []
+ const ungrouped = groupColumnsMap.value.ungrouped
+ const totalHeaderRows = maxLevel + 2
+ const byLevel = new Map()
+ const collect = (nodes) => {
+ for (const node of nodes) {
+ if (!byLevel.has(node.level)) byLevel.set(node.level, [])
+ byLevel.get(node.level).push(node)
+ collect(node.children)
+ }
+ }
+ collect(roots)
+ const rows = []
+ for (let level = 0; level <= maxLevel; level++) {
+ const nodes = byLevel.get(level) || []
+ const cells = []
+ if (level === 0 && ungrouped.length) {
+ for (const row of ungrouped) {
+ cells.push({ kind: 'ungrouped-col', row, span: 1, rowspan: totalHeaderRows })
+ }
+ }
+ cells.push(
+ ...nodes.map((node) => ({
+ kind: 'band',
+ gid: node.gid,
+ group: node.group,
+ level: node.level,
+ span: node.span,
+ rowspan: node.children.length ? 1 : maxLevel - node.level + 1,
+ })),
+ )
+ rows.push(cells)
+ }
+ return rows
+ })
+
+ const ungroupedCount = computed(() => groupColumnsMap.value.ungrouped.length)
+ const ungroupedInBand = computed(
+ () => bandRows.value.length > 0 && groupColumnsMap.value.ungrouped.length > 0,
+ )
+
+ // 列头行:未分组列头已渲染在 band 行时,这里只渲染分组内列
+ const headerColumns = computed(() => {
+ if (!ungroupedInBand.value) return visualColumns.value
+ return visualColumns.value.filter((c) => c.gid != null)
+ })
+
+ return { visualColumns, bandRows, headerColumns, ungroupedInBand, ungroupedCount }
+}
+
+/* ============================================================
+ * 4. 行编辑纯函数(无 Vue 依赖)
+ * ============================================================ */
+
+/** 按索引重排 b_xh:从 10 开始,步长 10 */
+export function renumberRows(rows) {
+ return rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
+}
+
+/**
+ * 复制列属性:提取指定 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) : {}),
+ })
+}
+
+/* ============================================================
+ * 5. 结构编辑内核
+ * 由 TableDesignEditor(子表设计 / 列表设计共用)消费:宿主只注入数据源(getter)、
+ * 行工厂(差异字段)、提交回调(转 emit)与重命名请求(转发结构树 startRename)。
+ * ============================================================ */
const DEFAULT_CLIP_KEYS = [
'b_required',
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue
similarity index 85%
rename from code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue
rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue
index 3d91f9dd..08314e86 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue
@@ -1,37 +1,58 @@
@@ -85,7 +106,7 @@ const TEXTAREA_HEIGHT_OPTIONS = [
:model-value="selectedRow.b_placeholder || ''"
:disabled="loading"
placeholder="输入框内的浅色提示文字"
- @change="(v) => emit('placeholder-change', v)"
+ @change="onPlaceholderChange"
/>
布局
@@ -103,7 +124,7 @@ const TEXTAREA_HEIGHT_OPTIONS = [
:model-value="spanOf(selectedRow)"
:disabled="loading"
aria-label="栅格宽度"
- @change="(v) => emit('colspan-change', v)"
+ @change="onColspanInput"
/>