diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index 3461277f..86ebbcbe 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -192,6 +192,8 @@ create table s_field_group ( b_parent_id bigint null, -- 父分组(分组树) b_title nvarchar(100) not null, -- 分组标题 b_i18n varchar(150) null, -- 标题多语言 key + b_layout_direction varchar(10) null, -- 分组内字段排列方向(vertical/horizontal) + b_colspan int not null default 48, -- 分组在外层 48 宫格中的占格数(默认 48 独占一行) b_xh int not null default 0, -- 分组排序 b_canuse int not null default 1, -- 是否启用(停用整组隐藏) constraint pk_s_field_group primary key (b_id), diff --git a/code/fms/fms-api/config/migrations/042_add_group_colspan_and_drop_edit_check.sql b/code/fms/fms-api/config/migrations/042_add_group_colspan_and_drop_edit_check.sql new file mode 100644 index 00000000..39bd8083 --- /dev/null +++ b/code/fms/fms-api/config/migrations/042_add_group_colspan_and_drop_edit_check.sql @@ -0,0 +1,34 @@ +/* + 表单设计器分组宽度持久化: + 1. s_field_group.b_colspan —— 分组在外层 48 宫格中的占格数(默认 48 独占一行)。 + 与 s_field_edit.b_colspan 同义(栅格占格数),仅进制不同:分组 48 宫格、字段 24 宫格。 + 不加 CHECK 约束(与前端钳制逻辑一致,边界由应用层保证)。 + 2. 删除 s_field_edit 的 CK_s_field_edit_colspan CHECK 约束(b_colspan BETWEEN 1 AND 24), + 放开栅格宽度的数据库层限制,校验统一交由前端与应用层。 + 可重复执行:列与约束均通过存在性判断避免重复。 +*/ +SET NOCOUNT ON; + +-- 1. s_field_group.b_colspan +IF NOT EXISTS ( + SELECT 1 + FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.s_field_group') + AND name = N'b_colspan' +) +BEGIN + ALTER TABLE dbo.s_field_group + ADD b_colspan int NOT NULL CONSTRAINT DF_s_field_group_colspan DEFAULT (48); +END; + +-- 2. 删除 s_field_edit 的 CK_s_field_edit_colspan CHECK 约束 +IF EXISTS ( + SELECT 1 + FROM sys.check_constraints + WHERE parent_object_id = OBJECT_ID(N'dbo.s_field_edit') + AND name = N'CK_s_field_edit_colspan' +) +BEGIN + ALTER TABLE dbo.s_field_edit + DROP CONSTRAINT CK_s_field_edit_colspan; +END; diff --git a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql index 79f9d8b1..f3974a1c 100644 --- a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql +++ b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql @@ -133,6 +133,7 @@ BEGIN b_title nvarchar(100) NOT NULL, b_i18n varchar(150) NULL, b_layout_direction varchar(10) NULL CONSTRAINT DF_s_field_group_layout_direction DEFAULT ('vertical'), + b_colspan int NOT NULL CONSTRAINT DF_s_field_group_colspan DEFAULT (48), b_xh int NOT NULL CONSTRAINT DF_s_field_group_xh DEFAULT (0), b_canuse int NOT NULL CONSTRAINT DF_s_field_group_canuse DEFAULT (1), CONSTRAINT PK_s_field_group PRIMARY KEY (b_id), @@ -178,7 +179,6 @@ BEGIN b_xh int NOT NULL CONSTRAINT DF_s_field_edit_xh DEFAULT (0), CONSTRAINT PK_s_field_edit PRIMARY KEY (b_id), CONSTRAINT UQ_s_field_edit_field UNIQUE (b_module_id, b_field_id), - CONSTRAINT CK_s_field_edit_colspan CHECK (b_colspan BETWEEN 1 AND 24), CONSTRAINT CK_s_field_edit_height CHECK (b_height IS NULL OR (b_height BETWEEN 64 AND 480)) ); CREATE INDEX IX_s_field_edit_order ON dbo.s_field_edit (b_xh, b_id); diff --git a/code/fms/fms-vue/src/components/FpsMonitor.vue b/code/fms/fms-vue/src/components/FpsMonitor.vue index 4c4e8f5f..b266472a 100644 --- a/code/fms/fms-vue/src/components/FpsMonitor.vue +++ b/code/fms/fms-vue/src/components/FpsMonitor.vue @@ -100,6 +100,23 @@ const rootEl = ref(null) const pos = ref(null) let dragStart = null // { pointerX, pointerY, originX, originY, width, height } +/* ---------- 双击隐藏(也可由父级通过 ref 调 show/hide 控制显隐) ---------- */ +const visible = ref(true) + +function onDblClick() { + visible.value = false +} + +function show() { + visible.value = true +} + +function hide() { + visible.value = false +} + +defineExpose({ show, hide }) + function onPointerDown(e) { // 重置按钮的点击不触发拖拽 if (e.target.closest('.fps-monitor__reset')) return @@ -148,6 +165,7 @@ onBeforeUnmount(() => { @@ -2765,52 +2720,4 @@ textarea.mm-fdesign__ctrl { .mm-fdesign__section-settings-input { width: 72px; } - -/* ---- 表单预览(主表):运行时形态,用 Form/FormItem 渲染 ---- - * 外层 48 宫格与设计画布一致,分组按各自 cols 占格流式排布; - * 分组内层 24 宫格由 FormItem 的 span 跨列实现。 - * 间距层次:分组间 > item 间,无边框时靠间距区分分组边界 */ -.mm-fdesign__preview { - display: grid; - grid-template-columns: repeat(48, minmax(0, 1fr)); - gap: 16px 12px; - align-items: start; -} - -.mm-fdesign__preview-section { - min-width: 0; - padding: 0; -} - -.mm-fdesign__preview-title { - margin-bottom: 8px; - font-size: 13px; - font-weight: 600; - color: var(--fms-text); -} - -.mm-fdesign__preview-grid { - display: grid; - grid-template-columns: repeat(24, minmax(0, 1fr)); - gap: 8px 10px; - align-items: start; -} - -/* Form 上下文:预览只关心布局与控件,不渲染提交行为相关 UI */ -.mm-fdesign__preview-form { - width: 100%; -} - -/* 预览内 FormItem 纵向间距归零,由 grid gap 统一控制 */ -.mm-fdesign__preview-grid :deep(.fms-form-item) { - margin-bottom: 0; -} - -.mm-fdesign__preview-empty { - grid-column: 1 / -1; - padding: 40px 0; - text-align: center; - color: var(--fms-text-secondary); - font-size: 13px; -} diff --git a/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js index 266b5a47..9144eee5 100644 --- a/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js +++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js @@ -523,6 +523,7 @@ export function useStructureEditing({ b_title: '', b_xh: nextGroupXh(), b_canuse: 1, + b_colspan: 48, } emitGroupsRenumbered([...(getGroups() || []), newGroup]) focusNewBandTitle(newGroup.b_id) @@ -537,6 +538,7 @@ export function useStructureEditing({ b_title: '', b_xh: nextGroupXh(), b_canuse: 1, + b_colspan: 48, } emitGroupsRenumbered([...(getGroups() || []), newGroup]) focusNewBandTitle(newGroup.b_id) diff --git a/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormPreview.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormPreview.vue new file mode 100644 index 00000000..ec740d39 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormPreview.vue @@ -0,0 +1,157 @@ + + + + + + + + + + + {{ fieldOf(row)?.b_default_value || '选项' }} + + + + + + + + {{ emptyText }} + + + + \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/index.vue b/code/fms/fms-vue/src/views/system/module-management/index.vue index 4999be8f..2e5da641 100644 --- a/code/fms/fms-vue/src/views/system/module-management/index.vue +++ b/code/fms/fms-vue/src/views/system/module-management/index.vue @@ -4,9 +4,11 @@ * 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。 * 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。 * 模块树 / 草稿加载与脏检查 / 保存编排逻辑直接内联在下方;确认弹窗状态机仅本页使用。 + * 多语言面板(i18n)数据由 ModuleI18nPanel 自持 —— 本文件只加载原始扁平行(rawI18nRows)传入, + * 保存时调用面板的 buildSaveRequest / applyInsertedIds。 * 设计器共用逻辑在 components/useStructureEditing.js,本文件不包含。 */ -import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import { useHotkeys } from '@/composables/useHotkeys' import { Plus, @@ -37,13 +39,11 @@ import { Message, } from '@/components/ui' import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api' -import { buildTree, sortTree } from '@/utils/tree' +import { buildTree, sortTree, findTreeNode, collectAllKeys, expandAncestors, filterTreeByText, insertTreeNode, removeTreeNode } from '@/utils/tree' import { buildModuleI18nKey, buildModuleItemI18nKey, syncModuleItemI18nKeys, - consolidatePivotRows, - getI18nNamespace, } from '@/utils/i18n' import { createTempId } from '@/utils/tempId' import { @@ -81,12 +81,15 @@ const drafts = ref(null) const activeSection = ref('basic') const basicPanelRef = ref(null) const autoCodePanelRef = ref(null) +const i18nPanelRef = ref(null) +/** i18n 原始扁平行([{b_id,b_key,b_locale,b_value}]),加载后传给 ModuleI18nPanel 自持处理 */ +const rawI18nRows = ref([]) // 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享 const skipModuleLoad = { value: false } /* ================= 派生状态(index.vue 组装) ================= */ const selectedModule = computed(() => - moduleSelectedId.value ? findModuleNode(moduleTreeData.value, moduleSelectedId.value) : null, + moduleSelectedId.value ? findTreeNode(moduleTreeData.value, moduleSelectedId.value) : null, ) const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value) @@ -98,214 +101,16 @@ const moduleTypeLabel = computed(() => { const parentModuleName = computed(() => { if (!selectedModule.value?.b_parent_id) return '' - return findModuleNode(moduleTreeData.value, selectedModule.value.b_parent_id)?.b_name || '' + return findTreeNode(moduleTreeData.value, selectedModule.value.b_parent_id)?.b_name || '' }) const moduleType = computed(() => selectedModule.value?.b_module_type || null) const isDataModule = computed(() => moduleType.value === 'data') const isQueryModule = computed(() => moduleType.value === 'query') -/* ================= 编辑方式 / 导航 / i18n 展示(面板编排) ================= */ +/* ================= 编辑方式 / 导航(面板编排) ================= */ const editMode = computed(() => drafts.value?.module?.b_edit_mode || 'form') -const finalI18nItems = computed(() => { - const module = drafts.value?.module - if (!module) return [] - const items = [] - const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_module_type, module.b_code) - if (moduleKey) { - items.push({ - key: moduleKey, - managed: true, - defaultValue: module.b_name || '', - id: `module:${moduleKey}`, - }) - } - for (const field of drafts.value?.fields || []) { - const key = buildModuleItemI18nKey('field', module.b_code, field.b_field) - if (key) items.push({ key, managed: true, defaultValue: field.b_name || '', id: `field:${key}` }) - } - for (const power of drafts.value?.powers || []) { - const key = buildModuleItemI18nKey('power', module.b_code, power.b_code) - if (key) items.push({ key, managed: true, defaultValue: power.b_name || '', id: `power:${key}` }) - } - // 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管) - for (const group of drafts.value?.groups || []) { - const key = String(group.b_i18n || '').trim() - if (key) items.push({ key, managed: true, defaultValue: group.b_title || '', id: `group:${group.b_id}` }) - } - return items -}) - -// 前端默认语言列表(默认语言排第一);与面板共用同一份,保证保存 diff 与展示一致 -const I18N_LANGUAGES = [ - { code: 'zh-CN', name: '简体中文', default: true }, - { code: 'en-US', name: 'English', default: false }, -] - -/* ================= i18n 数据维护(多语言面板数据由 index.vue 持有) ================= */ - -// 扁平记录(b_key/b_locale/b_value)→ 透视行(每行一个 key,每种语言一列) -function buildI18nPivot(flat) { - const byKey = new Map() - for (const r of flat) { - if (!byKey.has(r.b_key)) byKey.set(r.b_key, []) - byKey.get(r.b_key).push(r) - } - const rows = [] - for (const [key, recs] of byKey) { - const row = { _id: createTempId(), b_key: key, _localeIds: {} } - for (const r of recs) { - row[r.b_locale] = r.b_value - row._localeIds[r.b_locale] = r.b_id - } - rows.push(row) - } - return rows -} - -function isI18nManaged(row) { - return Boolean(row && row._managedId) -} - -function isEmptyI18nManaged(row, defaultLocale) { - if (row._localeIds && Object.keys(row._localeIds).length) return false - for (const lang of I18N_LANGUAGES) { - const value = row[lang.code] - if (value == null || value === '') continue - if (lang.code === defaultLocale && String(value) === String(row._defaultValue ?? '')) continue - return false - } - return true -} - -// 保证模块/字段/权限受管键始终存在:默认语言为空时自动跟随当前名称,人工翻译不被覆盖 -function reconcileI18nRows() { - if (!drafts.value) return - const items = finalI18nItems.value - const rows = [...drafts.value.i18n] - const rowByManagedId = new Map() - const unclaimedByKey = new Map() - for (const row of rows) { - if (row._managedId) rowByManagedId.set(String(row._managedId), row) - else unclaimedByKey.set(String(row.b_key), row) - } - const currentManagedIds = new Set() - const defaultLocale = I18N_LANGUAGES[0]?.code || '' - - for (const item of items) { - const managedId = String(item.id || item.key) - currentManagedIds.add(managedId) - let row = rowByManagedId.get(managedId) - if (!row) row = unclaimedByKey.get(String(item.key)) - if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey)) - if (!row) { - row = { _id: createTempId(), b_key: item.key, _localeIds: {}, _syncInjected: true } - rows.push(row) - } - const previousDefault = row._defaultValue - const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue) - if ( - defaultLocale && - (row[defaultLocale] == null || - row[defaultLocale] === '' || - row[defaultLocale] === previousDefault) - ) { - row[defaultLocale] = nextDefault - } - row.b_key = String(item.key) - row._managedId = managedId - row._defaultValue = nextDefault - row._sortType = String(item.key).split('.', 1)[0] - } - - // 键已失效的受管行:自动创建且无内容的直接丢弃,有翻译或来自数据库的解除保护保留 - const keptRows = [] - for (const row of rows) { - if (row._managedId && !currentManagedIds.has(String(row._managedId))) { - if (row._syncInjected && isEmptyI18nManaged(row, defaultLocale)) continue - delete row._managedId - delete row._defaultValue - } - keptRows.push(row) - } - drafts.value.i18n = consolidatePivotRows(keptRows) -} - -// 保存:按最终草稿态生成 s_i18n 请求(与加载时的扁平基线 diff) -function buildI18nSaveRequest() { - reconcileI18nRows() - const rows = drafts.value?.i18n || [] - const originals = new Map( - (originals.value?.i18n || []).map((r) => [`${r.b_key}|${r.b_locale}`, r]), - ) - const inserts = [] - const updates = [] - const deletes = [] - // 模块命名空间:这些键属于当前模块元数据,落库写入 b_module_id 便于级联删除;其余保持 NULL - const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group'] - function moduleIdOf(key) { - return MODULE_NAMESPACES.includes(getI18nNamespace(key)) ? String(selectedModule.value?.b_id) : null - } - const seen = new Set() - for (const row of rows) { - const key = String(row.b_key || '') - if (!key) continue - for (const lang of I18N_LANGUAGES) { - const locale = lang.code - const cellKey = `${key}|${locale}` - seen.add(cellKey) - const value = row[locale] == null ? '' : String(row[locale]) - const original = originals.get(cellKey) - const localeId = row._localeIds?.[locale] - if (localeId) { - if (!original || String(original.b_value ?? '') !== value) { - updates.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value, b_module_id: moduleIdOf(key), b_canuse: 1 }) - } - } else if (value !== '') { - inserts.push({ - b_id: createTempId(), _rowId: row._id, _locale: locale, - b_key: key, b_locale: locale, b_value: value, b_module_id: moduleIdOf(key), b_canuse: 1, - }) - } - } - } - for (const r of originals.value?.i18n || []) { - const cellKey = `${r.b_key}|${r.b_locale}` - if (!seen.has(cellKey)) deletes.push({ b_id: r.b_id }) - } - if (!inserts.length && !updates.length && !deletes.length) return null - return { table: 's_i18n', key_field: 'b_id', inserts, updates, deletes } -} - -// 为新增记录回写真实主键到透视行 _localeIds,避免二次保存重复插入 -function applyI18nInsertedIds(inserts) { - for (const ins of inserts || []) { - if (!ins._rowId || !ins._locale || ins.b_id == null) continue - const row = (drafts.value?.i18n || []).find((r) => String(r._id) === String(ins._rowId)) - if (row) row._localeIds[ins._locale] = String(ins.b_id) - } -} - -// 同步字段时删除失效受管键(_managedId 为 `field:${i18nKey}` 等,见 finalI18nItems) -function removeI18nByManagedIds(ids) { - if (!drafts.value) return - const set = new Set(ids.map(String)) - drafts.value.i18n = (drafts.value.i18n || []).filter((r) => !set.has(String(r._managedId))) -} - -// 字段/权限草稿变化(改名、编码输入)时防抖补齐受管行;加载中不处理(load 完成后自行 reconcile) -let i18nReconcileTimer = null -watch( - finalI18nItems, - () => { - if (configLoading.value || !drafts.value) return - clearTimeout(i18nReconcileTimer) - i18nReconcileTimer = setTimeout(() => reconcileI18nRows(), 300) - }, -) -onBeforeUnmount(() => clearTimeout(i18nReconcileTimer)) - /* 右侧分组配置导航:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。 每个导航项唯一激活(activeSection)。 */ const configGroups = computed(() => { @@ -461,13 +266,13 @@ async function saveModule() { } } - // i18n 数据独立保存于 s_i18n:先补齐受管键,再按基线 diff 生成请求 - const i18nReq = buildI18nSaveRequest() + // i18n 数据独立保存于 s_i18n:由面板按基线 diff 生成请求 + const i18nReq = i18nPanelRef.value?.buildSaveRequest?.() if (i18nReq) { - // 为新增的多语言记录分配真实主键,并回写草稿,避免二次保存重复插入 + // 为新增的多语言记录分配真实主键,并回写面板透视行,避免二次保存重复插入 if (i18nReq.inserts?.length) { await allocateTemporaryIds([i18nReq.inserts]) - applyI18nInsertedIds(i18nReq.inserts) + i18nPanelRef.value?.applyInsertedIds?.(i18nReq.inserts) } saveReqs.push(i18nReq) } @@ -480,7 +285,7 @@ async function saveModule() { unsavedModuleIds.delete(moduleId) // 更新树节点显示(名称/编码可能变化) - const node = findModuleNode(moduleTreeData.value, moduleId) + const node = findTreeNode(moduleTreeData.value, moduleId) if (node) Object.assign(node, cloneData(moduleData)) // 保存后重新加载:以数据库落库结果为准刷新草稿与各面板, // 临时 id → 真实 id 也由本次重载自然回灌,无需本地换引用。 @@ -526,50 +331,8 @@ async function onConfirmOk() { /* ================= 模块树 ================= */ -/* ---- 树纯函数 ---- */ - -/** 递归在树中查找指定 id 的节点 */ -function findModuleNode(nodes, id) { - for (const node of nodes) { - if (String(node.b_id) === String(id)) return node - if (node.children) { - const found = findModuleNode(node.children, id) - if (found) return found - } - } - return null -} - -/** 递归收集所有节点 key(搜索时展开全部用) */ -function collectAllKeys(nodes) { - const keys = [] - const walk = (list) => { - for (const node of list) { - keys.push(String(node.b_id)) - if (node.children) walk(node.children) - } - } - walk(nodes) - return keys -} - -/** 从根到目标节点的祖先路径(新建节点时展开,保证可见) */ -function expandAncestors(nodes, id) { - const keys = [] - const walk = (list, ancestors) => { - for (const node of list) { - const key = String(node.b_id) - if (key === id) { - keys.push(...ancestors) - return true - } - if (node.children && walk(node.children, [...ancestors, key])) return true - } - return false - } - walk(nodes, []) - return keys -} +/* 树通用纯函数(查找/收集 key/祖先路径/过滤/插入/移除)已下沉到 @/utils/tree, + 本文件只保留模块特有的树加工(分类节点标记 + 图标)。 */ /** 分类节点标记为非叶子 + 附加图标 */ const moduleTypeIconMap = { @@ -588,52 +351,6 @@ function markCategoryNonLeaf(nodes) { return nodes } -/** 按名称/编码过滤树(命中节点保留子树) */ -function filterTree(nodes, keyword) { - const kw = String(keyword || '').trim().toLowerCase() - if (!kw) return nodes - const result = [] - for (const node of nodes) { - const hit = - String(node.b_name || '').toLowerCase().includes(kw) || - String(node.b_code || '').toLowerCase().includes(kw) - const children = node.children ? filterTree(node.children, kw) : undefined - if (hit || children?.length) { - result.push({ ...node, children }) - } - } - return result -} - -/** 插入节点到树(顶级或指定父级下,就地排序) */ -function insertModuleNode(nodes, node) { - if (node.b_parent_id == null) { - nodes.push(node) - sortTree(nodes) - return - } - for (const n of nodes) { - if (String(n.b_id) === String(node.b_parent_id)) { - n.children = n.children || [] - n.children.push(node) - sortTree(n.children) - return - } - if (n.children) insertModuleNode(n.children, node) - } -} - -/** 从树中移除指定 id 的节点 */ -function removeModuleNode(nodes, id) { - for (let i = 0; i < nodes.length; i++) { - if (String(nodes[i].b_id) === String(id)) { - nodes.splice(i, 1) - return - } - if (nodes[i].children) removeModuleNode(nodes[i].children, id) - } -} - /* ---- 树加载 / 新增 / 删除 ---- */ // 搜索时展开全部(保证命中节点可见),清空时收起 @@ -641,7 +358,7 @@ watch(moduleKeyword, (kw) => { moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : [] }) -const filteredModuleTree = computed(() => filterTree(moduleTreeData.value, moduleKeyword.value)) +const filteredModuleTree = computed(() => filterTreeByText(moduleTreeData.value, moduleKeyword.value)) // 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除) function hasSavedModuleDescendant(node) { @@ -720,14 +437,14 @@ async function addModule(parentId, moduleType) { isLeaf: moduleType === 'category' ? false : undefined, } // 插入树并选中(跳过 watch 的自动加载,直接用空草稿) - insertModuleNode(moduleTreeData.value, newModule) + insertTreeNode(moduleTreeData.value, newModule, { sort: sortTree }) // 展开祖先路径,保证新建节点在收起的树中可见 const ancestors = expandAncestors(moduleTreeData.value, newId) if (ancestors.length) { moduleExpandedKeys.value = [...new Set([...moduleExpandedKeys.value, ...ancestors])] } // 单独设置树节点图标(不进入 drafts,避免污染保存数据) - const treeNode = findModuleNode(moduleTreeData.value, newId) + const treeNode = findTreeNode(moduleTreeData.value, newId) if (treeNode) treeNode.icon = moduleTypeIconMap[moduleType] || null unsavedModuleIds.add(newId) skipModuleLoad.value = true @@ -744,18 +461,19 @@ async function addModule(parentId, moduleType) { autoCode: [], powers: [], }) + rawI18nRows.value = [] originals.value = cloneData(drafts.value) loadedModuleId.value = newId } async function deleteModule(id) { - const module = id != null ? findModuleNode(moduleTreeData.value, id) : selectedModule.value + const module = id != null ? findTreeNode(moduleTreeData.value, id) : selectedModule.value if (!module || busy.value) return const moduleId = String(module.b_id) if (unsavedModuleIds.has(moduleId)) { // 未保存草稿:本地递归移除子树并清理标记(草稿下不应有已保存子孙) collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k)) - removeModuleNode(moduleTreeData.value, moduleId) + removeTreeNode(moduleTreeData.value, moduleId) moduleSelectedId.value = null return } @@ -772,7 +490,10 @@ async function deleteModule(id) { try { const id2 = String(module.b_id) // 子表按 b_module_id 条件删(后端 deletes 行内所有列组成 AND 条件), - // 无需先查出各行 id;主表仍按主键删 + // 无需先查出各行 id;主表仍按主键删。 + // s_i18n 也按 b_module_id 关联删除(受管键均回填 b_module_id,见 ModuleI18nPanel.buildSaveRequest); + // 若不清理,删除后残留的孤儿键会被同编码新模块的多语言面板加载, + // 进而产生指向已不存在记录的 UPDATE,导致后端「修改记录数异常: 0」。 const subTables = [ 's_field', 's_field_group', @@ -781,39 +502,18 @@ async function deleteModule(id) { 's_field_query', 's_autocode', 's_power', + 's_i18n', ] const reqs = subTables.map((table) => ({ table, key_field: 'b_id', deletes: [{ b_module_id: id2 }], })) - // 多语言(s_i18n.b_module_id 由迁移脚本 041 提供;历史/未回填数据仍按 key 前缀匹配)。 - // 优先按 b_module_id 精确关联删除(新数据),并以 key 前缀匹配兜底(存量数据): - // 若不清理,删除后残留的孤儿键会被同编码新模块的多语言面板加载, - // 进而产生指向已不存在记录的 UPDATE,导致后端「修改记录数异常: 0」。 - const moduleCode = String(module.b_code || '').trim() - if (moduleCode) { - const prefix = module.b_module_type === 'category' ? 'menu' : 'module' - const i18nRes = await loadDataApi( - 's_i18n', - `b_module_id = ${id2} OR b_key = '${prefix}.${moduleCode}' ` + - `OR b_key LIKE '${prefix}.${moduleCode}.%' ` + - `OR b_key LIKE 'field.${moduleCode}.%' OR b_key LIKE 'power.${moduleCode}.%'`, - ) - const i18nRows = i18nRes.data || [] - if (i18nRows.length) { - reqs.push({ - table: 's_i18n', - key_field: 'b_id', - deletes: i18nRows.map((r) => ({ b_id: r.b_id })), - }) - } - } reqs.push({ table: 's_module', key_field: 'b_id', deletes: [{ b_id: id2 }] }) await saveObjectApi(reqs) // 本地移除整棵子树(未保存子孙随父一并清理),并清除未保存标记 collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k)) - removeModuleNode(moduleTreeData.value, id2) + removeTreeNode(moduleTreeData.value, id2) moduleSelectedId.value = null Message.success('模块已删除') } catch (e) { @@ -826,10 +526,10 @@ async function deleteModule(id) { /* ================= 草稿加载 ================= */ async function loadModuleConfig(moduleId) { const id = String(moduleId) - const type = findModuleNode(moduleTreeData.value, id)?.b_module_type + const type = findTreeNode(moduleTreeData.value, id)?.b_module_type configLoading.value = true try { - let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes + let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes, i18nRes if (type === 'data') { const responses = await Promise.all([ loadDataApi('s_module', `b_id = ${id}`), @@ -840,8 +540,9 @@ async function loadModuleConfig(moduleId) { loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'), loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'), + loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'), ]) - ;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes] = responses + ;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes, i18nRes] = responses } else if (type === 'query') { const responses = await Promise.all([ loadDataApi('s_module', `b_id = ${id}`), @@ -850,8 +551,9 @@ async function loadModuleConfig(moduleId) { loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'), + loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'), ]) - ;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, powersRes] = responses + ;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, powersRes, i18nRes] = responses } else { moduleRes = await loadDataApi('s_module', `b_id = ${id}`) } @@ -868,23 +570,13 @@ async function loadModuleConfig(moduleId) { } originals.value = original drafts.value = cloneData(original) - // i18n 独立加载(s_i18n 不随模块配置草稿加载,按受管键查询) - if (type === 'data' || type === 'query') { - const keys = [...new Set(finalI18nItems.value.flatMap((it) => [it.key, it.sourceKey]).filter(Boolean).map(String))] - const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'") - const where = keys.length ? 'b_key IN (' + quotedKeys.join(', ') + ')' : null - const res = where ? await loadDataApi('s_i18n', where, 'b_key ASC, b_locale ASC') : { data: [] } - if (String(moduleSelectedId.value) !== id) return - const flat = (res.data || []).map((row) => ({ - b_id: row.b_id, b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value, - })) - originals.value.i18n = flat - drafts.value.i18n = buildI18nPivot(flat) - reconcileI18nRows() - } else { - originals.value.i18n = [] - drafts.value.i18n = [] - } + // i18n 随模块配置一起并行加载(s_i18n 按 b_module_id 关联), + // 只取原始扁平行传给 ModuleI18nPanel,由面板自持 pivot/reconcile/保存 diff + rawI18nRows.value = i18nRes + ? (i18nRes.data || []).map((row) => ({ + b_id: row.b_id, b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value, + })) + : [] // 列表配置不再自动补全:行存在即展示,删除即真删,由用户通过字段库手动管理(与查询配置一致)。 loadedModuleId.value = id activeSection.value = 'basic' @@ -909,6 +601,7 @@ watch(moduleSelectedId, (id) => { configLoading.value = false loadedModuleId.value = null drafts.value = null + rawI18nRows.value = [] return } // 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading) @@ -1047,7 +740,7 @@ function buildSyncPlan(module, columns) { } // 视图中已不存在的字段:应用时移除并级联清理配置行与受管 i18n const removedIds = new Set() - // 受管多语言的 _managedId 是 `field:${i18nKey}`(见 finalI18nItems),不是字段 b_id + // 受管多语言的 _managedId 是 `field:${i18nKey}`(由 ModuleI18nPanel 维护),不是字段 b_id const removedI18nIds = new Set() for (const existing of drafts.value.fields || []) { if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) { @@ -1069,7 +762,7 @@ function applySyncPlan(plan) { (row) => !plan.removedIds.has(String(row.b_field_id)), ) } - removeI18nByManagedIds(Array.from(plan.removedI18nIds)) + i18nPanelRef.value?.removeByManagedIds?.(Array.from(plan.removedI18nIds)) } drafts.value.fields = plan.mergedFields Message.success(`已成功同步 ${plan.mergedFields.length} 个字段`) @@ -1344,11 +1037,14 @@ loadModuleTree() class="mm-config-pane" > onModuleUpdate('i18n', v)" /> diff --git a/code/test/vue-vben-admin b/code/test/vue-vben-admin new file mode 160000 index 00000000..2d708760 --- /dev/null +++ b/code/test/vue-vben-admin @@ -0,0 +1 @@ +Subproject commit 2d7087602b732e6c23c7696ee8430eb1e8221b56