20260914172820
This commit is contained in:
1 parent
c31329d387
commit
549c8bd2eb
38 files changed
+3430
-502
No files matched your search
@@ -5,7 +5,8 @@
|
||||
* 菜单类型:directory(目录)、page(页面)、external(外链)。
|
||||
*
|
||||
* 本文件是编排层,只保留三类职责:
|
||||
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序,以及树节点启用停用的落库;
|
||||
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序(含 b_depth / b_path 维护),
|
||||
* 以及树节点启用停用的落库;
|
||||
* 2. 菜单配置(主表 / 多语言)的加载与保存编排
|
||||
* (校验 → diff → 分配真实主键 → 落库 → 重载);
|
||||
* 3. 跨面板联动:菜单编码派生多语言键、未保存切换确认与统一确认弹窗。
|
||||
@@ -33,7 +34,16 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||||
import MenuTreePanel from './MenuTreePanel.vue'
|
||||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||||
import { findNodePosition, markDirectoryNonLeaf, menuRowOf, renumberSiblings } from './utils'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyMenuPosition,
|
||||
buildMenuI18nKey,
|
||||
findNodePosition,
|
||||
markDirectoryNonLeaf,
|
||||
menuRowOf,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from './utils'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
|
||||
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
|
||||
@@ -220,16 +230,28 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const dropPos = findNodePosition(menuTreeData.value, dropId)
|
||||
if (!dragPos || !dropPos) return
|
||||
|
||||
const dragNode = dragPos.list[dragPos.index]
|
||||
const dropNode = dropPos.list[dropPos.index]
|
||||
// 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
|
||||
if (findTreeNode([dragNode], dropId)) {
|
||||
Message.warning('不能把菜单移动到它自己的下级')
|
||||
return
|
||||
}
|
||||
// 只有目录能承载子菜单:页面/外链是末端节点,放入会形成「页面挂子菜单」的非法层级。
|
||||
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
|
||||
if (dropPosition === 'inside' && dropNode.b_menu_type !== 'directory') {
|
||||
Message.warning('只能将菜单放入目录下')
|
||||
return
|
||||
}
|
||||
// 未保存的目录尚未落库,其临时 id 不能作为持久化的 b_parent_id
|
||||
if (dropPosition === 'inside' && unsavedMenuIds.has(dropId)) {
|
||||
Message.warning('请先保存目标菜单,再调整层级')
|
||||
return
|
||||
}
|
||||
// 先记录落点节点信息,再从原位置移除被拖节点
|
||||
const dropParentId = dropNode.b_parent_id ?? null
|
||||
const [moved] = dragPos.list.splice(dragPos.index, 1)
|
||||
const movedParentId = moved.b_parent_id ?? null
|
||||
|
||||
const updatedRows = []
|
||||
const renumberSets = new Set()
|
||||
@@ -258,22 +280,31 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||||
}
|
||||
|
||||
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
|
||||
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
|
||||
rebuildTreePositions(menuTreeData.value, updatedRows)
|
||||
// b_parent_id 是权威关系,必须与本节点的派生字段同批落库,
|
||||
// 否则会留下「路径按新父级、父级列还是旧值」的不一致。
|
||||
// 单独补一条:renumberSiblings / rebuildTreePositions 只收集实际变化的字段值,
|
||||
// 它俩不负责父级列本身。
|
||||
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
|
||||
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
|
||||
}
|
||||
|
||||
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
|
||||
// 只改 maindata 会让已落库的 b_xh / b_parent_id 被 hasUnsavedChanges 误判为未保存改动。
|
||||
// 未保存草稿节点例外:它本就要等保存时才落库,应保持脏标记。
|
||||
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
|
||||
// hasUnsavedChanges 误判为未保存改动。未保存草稿节点例外:
|
||||
// 它本就要等保存时才落库,应保持脏标记。
|
||||
if (maindata.value) {
|
||||
const draftId = String(maindata.value.b_id)
|
||||
const draftUnsaved = unsavedMenuIds.has(draftId)
|
||||
const syncBaseline = !draftUnsaved && !!maindata_org.value
|
||||
const draftRow = updatedRows.find((r) => String(r.b_id) === draftId)
|
||||
if (draftRow) {
|
||||
maindata.value.b_xh = draftRow.b_xh
|
||||
if (syncBaseline) maindata_org.value.b_xh = draftRow.b_xh
|
||||
}
|
||||
if (draftId === dragId) {
|
||||
maindata.value.b_parent_id = moved.b_parent_id
|
||||
if (syncBaseline) maindata_org.value.b_parent_id = moved.b_parent_id
|
||||
for (const [field, value] of Object.entries(draftRow)) {
|
||||
if (field === 'b_id') continue
|
||||
maindata.value[field] = value
|
||||
if (syncBaseline) maindata_org.value[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +334,7 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
// 字符串业务键方案:持久化主键 = 菜单编码(基本信息面板录入)。
|
||||
// 未落库节点先用本地临时负数 id 占位,仅用于树 key / 未保存判定,不写入数据库。
|
||||
const newId = createTempId()
|
||||
const parentNode = parentId ? findTreeNode(menuTreeData.value, String(parentId)) : null
|
||||
const newMenu = {
|
||||
b_id: newId,
|
||||
b_parent_id: parentId ? String(parentId) : null,
|
||||
@@ -315,6 +347,9 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
// b_depth / b_path 是 b_parent_id 的派生字段(b_path 非空且无默认值,缺失会新增失败)。
|
||||
// 此处先按父级填一次占位值;保存前会用最终菜单编码重算(临时 id 不落库)。
|
||||
applyMenuPosition(newMenu, parentNode)
|
||||
insertTreeNode(menuTreeData.value, newMenu, { sort: sortTree })
|
||||
const ancestors = expandAncestors(menuTreeData.value, newId)
|
||||
if (ancestors.length) {
|
||||
@@ -359,7 +394,10 @@ async function deleteMenu(id) {
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
|
||||
// s_i18n 无菜单归属列,按资源键删该菜单的全部语言行
|
||||
const reqs = [
|
||||
{ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: buildMenuI18nKey(menuId) }] },
|
||||
]
|
||||
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
|
||||
await saveObjectApi(reqs)
|
||||
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
|
||||
@@ -383,20 +421,20 @@ async function loadMenuConfig(menuId) {
|
||||
const menu = res.data?.[0] || null
|
||||
maindata_org.value = menu ? { ...menu } : null
|
||||
maindata.value = cloneData(maindata_org.value)
|
||||
// 多语言:默认语言复用 b_name,其余读 s_i18n(按 b_module_id 关联)
|
||||
// 多语言:默认语言复用 b_name,其余读 s_i18n(s_i18n 主键为 b_key + b_locale,
|
||||
// 菜单资源键统一 menu.编码,与保存时写入的键一致)
|
||||
const i18nInitValues = { [defaultLocale.value]: menu?.b_name || '' }
|
||||
const i18n_org_rows = []
|
||||
if (menu) {
|
||||
const i18nRes = await loadDataApi(
|
||||
's_i18n',
|
||||
`b_module_id = ${sqlText(id)}`,
|
||||
'b_key ASC, b_locale ASC',
|
||||
`b_key = ${sqlText(buildMenuI18nKey(id))}`,
|
||||
'b_locale ASC',
|
||||
)
|
||||
for (const row of i18nRes.data || []) {
|
||||
if (!row.b_locale || row.b_locale === defaultLocale.value) continue
|
||||
i18nInitValues[row.b_locale] = row.b_value
|
||||
i18n_org_rows.push({
|
||||
b_id: row.b_id,
|
||||
b_key: row.b_key,
|
||||
b_locale: row.b_locale,
|
||||
b_value: row.b_value,
|
||||
@@ -571,8 +609,17 @@ async function beforeSave() {
|
||||
return false
|
||||
}
|
||||
menuData.b_id = menuCode
|
||||
// b_depth / b_path 由 b_parent_id 派生:新建草稿的临时 id 必须在落库前换成最终编码重算,
|
||||
// 否则会把 /-1/ 这类临时路径写进库。父级不在树中(数据异常)时无法派生,保持原值不动。
|
||||
const parentId = menuData.b_parent_id ? String(menuData.b_parent_id) : null
|
||||
if (!parentId) {
|
||||
applyMenuPosition(menuData, null)
|
||||
} else {
|
||||
const parentNode = findTreeNode(menuTreeData.value, parentId)
|
||||
if (parentNode) applyMenuPosition(menuData, parentNode)
|
||||
}
|
||||
// 受管 i18n 键:菜单统一 menu. 前缀
|
||||
menuData.b_i18n = `menu.${menuCode}`
|
||||
menuData.b_i18n = buildMenuI18nKey(menuCode)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -587,7 +634,7 @@ async function buildSaveData() {
|
||||
const isNew = unsavedMenuIds.has(treeId)
|
||||
const menuId = String(menuData.b_id || '').trim()
|
||||
if (!menuId) return null
|
||||
const menuI18nKey = `menu.${menuId}`
|
||||
const menuI18nKey = buildMenuI18nKey(menuId)
|
||||
|
||||
const reqs = []
|
||||
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
|
||||
@@ -602,35 +649,33 @@ async function buildSaveData() {
|
||||
reqs.push(menuRequest)
|
||||
}
|
||||
|
||||
// 多语言(s_i18n):行级 diff —— current(按当前值生成的语言行)与加载基线
|
||||
// diffRows(i18n_org 保留每行 b_id),只提交有变化的语言行,不再整组重建
|
||||
const originByLocale = new Map(i18n_org.value.map((r) => [String(r.b_locale), r]))
|
||||
// 多语言(s_i18n):行级 diff —— current(按当前值生成的语言行)与加载基线,
|
||||
// 行身份 = 主键 (b_key, b_locale),只提交有变化的语言行,不再整组重建
|
||||
const currentI18nRows = []
|
||||
for (const lang of nonDefaultLanguages.value) {
|
||||
const value = String(i18n_values.value[lang.code] || '').trim()
|
||||
if (!value) continue // 清空的语言直接不进 current → 基线行走 deletes
|
||||
// 字符串业务键:s_i18n 主键 b_id = b_key + ':' + b_locale,行内已有值即可稳定定位
|
||||
currentI18nRows.push({
|
||||
b_id: `${menuI18nKey}:${lang.code}`,
|
||||
b_key: menuI18nKey,
|
||||
b_locale: lang.code,
|
||||
b_value: value,
|
||||
})
|
||||
}
|
||||
const previousI18nRows = i18n_org.value.map((r) => ({
|
||||
b_id: r.b_id,
|
||||
b_key: r.b_key,
|
||||
b_locale: r.b_locale,
|
||||
b_value: String(r.b_value ?? '').trim(),
|
||||
}))
|
||||
const i18nChanges = diffRows(currentI18nRows, previousI18nRows)
|
||||
const i18nChanges = diffRows(currentI18nRows, previousI18nRows, {
|
||||
keyFields: ['b_key', 'b_locale'],
|
||||
})
|
||||
if (i18nChanges.inserts.length || i18nChanges.updates.length || i18nChanges.deletes.length) {
|
||||
const i18nReq = { table: 's_i18n', key_field: 'b_id' }
|
||||
const i18nReq = { table: 's_i18n', key_field: 'b_key,b_locale' }
|
||||
if (i18nChanges.inserts.length) {
|
||||
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
|
||||
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_canuse: 1 }))
|
||||
}
|
||||
if (i18nChanges.updates.length) {
|
||||
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
|
||||
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_canuse: 1 }))
|
||||
}
|
||||
if (i18nChanges.deletes.length) i18nReq.deletes = i18nChanges.deletes
|
||||
reqs.push(i18nReq)
|
||||
|
||||
Reference in new issue
Block a user