1113 lines
38 KiB
Vue
1113 lines
38 KiB
Vue
<script setup>
|
||
/**
|
||
* 菜单管理:管理 s_menu 导航树。
|
||
* 布局:左侧菜单树 + 右侧属性编辑面板(菜单配置 / 多语言)。
|
||
* 菜单类型:directory(目录)、page(页面)、external(外链)。
|
||
*
|
||
* 本文件是编排层,只保留三类职责:
|
||
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序(含 b_depth / b_path 维护),
|
||
* 以及树节点启用停用的落库;
|
||
* 2. 菜单配置(主表 / 多语言)的加载与保存编排
|
||
* (校验 → diff → 分配真实主键 → 落库 → 重载);
|
||
* 3. 跨面板联动:菜单编码派生多语言键、未保存切换确认与统一确认弹窗。
|
||
* 面板各自的字段编辑与表格交互均在对应面板组件内部,
|
||
* 数据以 v-model 直接读写本文件持有的草稿,不存在第二份状态源。
|
||
*
|
||
* 权限点(s_power)统一在「模块管理 → 权限」维护,菜单保存不再派生 menu.* 权限点。
|
||
*/
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import { useHotkeys } from '@/composables/useHotkeys'
|
||
import { FolderTree, Save, RefreshCw } from '@lucide/vue'
|
||
import { Button, Splitter, SplitterPanel, Modal, Message, Spin } from '@/components/ui'
|
||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||
import {
|
||
buildTree,
|
||
sortTree,
|
||
findTreeNode,
|
||
expandAncestors,
|
||
insertTreeNode,
|
||
removeTreeNode,
|
||
} from '@/utils/tree'
|
||
import { cloneData, diffRow, diffRows } from '@/utils/dataChanges'
|
||
import { createTempId } from '@/utils/common'
|
||
import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||
import MenuTreePanel from './MenuTreePanel.vue'
|
||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||
import {
|
||
addRowUpdate,
|
||
applyMenuPosition,
|
||
buildMenuI18nKey,
|
||
findNodePosition,
|
||
markDirectoryNonLeaf,
|
||
menuRowOf,
|
||
rebuildTreePositions,
|
||
renumberSiblings,
|
||
} from './utils'
|
||
import { useI18nStore } from '@/stores/i18n'
|
||
|
||
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
|
||
function sqlText(value) {
|
||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||
}
|
||
|
||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
|
||
// 语言清单来自数据库(stores/i18n),不再写死 zh-CN/en-US。
|
||
const langStore = useI18nStore()
|
||
|
||
/** 默认语言 code(b_default=1;数据异常时回退第一个启用语言,均为数据库配置) */
|
||
const defaultLocale = computed(() => {
|
||
const def = langStore.defaultLanguage?.b_id
|
||
if (def) return def
|
||
return langStore.languages[0]?.b_id || ''
|
||
})
|
||
|
||
/** 给 I18nNameModal 的语言列表({ code, name, default }) */
|
||
const I18N_LANGUAGES = computed(() =>
|
||
langStore.languages.map((l) => ({
|
||
code: l.b_id,
|
||
name: l.b_name,
|
||
default: Number(l.b_default) === 1,
|
||
})),
|
||
)
|
||
|
||
/** 非默认语言(走 s_i18n 表,{ code, name }) */
|
||
const nonDefaultLanguages = computed(() =>
|
||
langStore.nonDefaultLanguages.map((l) => ({ code: l.b_id, name: l.b_name })),
|
||
)
|
||
|
||
/** 默认语言名称(hint 文案用,动态取自语言配置) */
|
||
const defaultLocaleName = computed(
|
||
() => langStore.defaultLanguage?.b_name || defaultLocale.value || '',
|
||
)
|
||
|
||
/* ================= 状态 ================= */
|
||
const menuTreeData = ref([])
|
||
const menuTreeLoading = ref(false)
|
||
const menuSelectedId = ref(null)
|
||
const menuExpandedKeys = ref([])
|
||
const unsavedMenuIds = new Set()
|
||
|
||
const configLoading = ref(false)
|
||
const configSaving = ref(false)
|
||
const loadedMenuId = ref(null)
|
||
const maindata = ref(null) // 当前编辑的菜单行(s_menu 主表行)
|
||
const maindata_org = ref(null) // 数据库加载的菜单行原始基线(保存 diff、放弃修改还原用)
|
||
const skipMenuLoad = { value: false }
|
||
|
||
/** 多语言翻译:{ [locale]: 名称 },默认语言复用 b_name,其余存 s_i18n */
|
||
const i18n_values = ref({})
|
||
/** 多语言基线快照,用于未保存判断 */
|
||
const i18n_values_org = ref({})
|
||
/** s_i18n 加载基线(保留每行 b_id),保存时行级 diffRows */
|
||
const i18n_org = ref([])
|
||
/** 多语言名称编辑弹窗 */
|
||
const i18nModalVisible = ref(false)
|
||
|
||
/** 菜单基础信息面板(保存前校验入口) */
|
||
const basicPanelRef = ref(null)
|
||
|
||
/* ================= 派生状态 ================= */
|
||
const selectedMenu = computed(() =>
|
||
menuSelectedId.value ? findTreeNode(menuTreeData.value, menuSelectedId.value) : null,
|
||
)
|
||
|
||
const busy = computed(() => configLoading.value || configSaving.value)
|
||
|
||
// 菜单类型以草稿为准:新建/编辑时切换类型,右侧字段(路由 / 绑定模块)
|
||
// 必须立即联动;若只读树节点,切换类型后字段区不动,
|
||
// 改成 page 却拿不到路由输入框 → 保存被「必须填写路由地址」拦下且无从修改(死循环)。
|
||
// 草稿未就绪(加载中/未选中)时回退树节点。
|
||
const menuType = computed(
|
||
() => maindata.value?.b_menu_type || selectedMenu.value?.b_menu_type || null,
|
||
)
|
||
const menuTypeLocked = computed(
|
||
() => !!selectedMenu.value && !unsavedMenuIds.has(String(selectedMenu.value.b_id)),
|
||
)
|
||
|
||
/* ================= 树加载 ================= */
|
||
function hasSavedMenuDescendant(node) {
|
||
for (const child of node.children || []) {
|
||
if (!unsavedMenuIds.has(String(child.b_id))) return true
|
||
if (hasSavedMenuDescendant(child)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function collectUnsavedMenuIds(node, set = new Set()) {
|
||
set.add(String(node.b_id))
|
||
for (const child of node.children || []) collectUnsavedMenuIds(child, set)
|
||
return set
|
||
}
|
||
|
||
async function loadMenuTree() {
|
||
menuTreeLoading.value = true
|
||
try {
|
||
const res = await loadDataApi('s_menu', '1 = 1', 'b_xh ASC, b_id ASC')
|
||
menuTreeData.value = markDirectoryNonLeaf(sortTree(buildTree(res.data || [])))
|
||
unsavedMenuIds.clear()
|
||
} catch (e) {
|
||
Message.error('加载菜单树失败:' + (e?.message || e))
|
||
} finally {
|
||
menuTreeLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 工具栏「刷新」:loadMenuTree 会用库里数据整体重建树,
|
||
* 未保存的新建菜单会凭空消失、当前草稿也会与新树脱节,故有未保存内容时先确认。
|
||
*/
|
||
function requestReloadTree() {
|
||
const hasDraftNode = [...unsavedMenuIds].some((id) => findTreeNode(menuTreeData.value, id))
|
||
// 选中未保存节点时,maindata 与 maindata_org 一致(未编辑态),hasUnsavedChanges 判不出来,需单独看
|
||
const editingDraftNode =
|
||
!!menuSelectedId.value && unsavedMenuIds.has(String(menuSelectedId.value))
|
||
const dirty = loadedMenuId.value && hasUnsavedChanges()
|
||
if (!hasDraftNode && !dirty) {
|
||
void loadMenuTree()
|
||
return
|
||
}
|
||
showConfirm({
|
||
title: '刷新菜单树',
|
||
content:
|
||
'刷新会用数据库数据重建整棵树,当前未保存的修改与未保存的新建菜单都将丢失。确定继续吗?',
|
||
okText: '刷新',
|
||
onOk: async () => {
|
||
// 选中项若为未保存节点,刷新后它必然不在树上:先清空选中,避免右侧残留悬空草稿。
|
||
// 已保存节点则记录 id,树刷新后连同草稿一起重载,保持左右两侧一致。
|
||
const reloadId =
|
||
editingDraftNode || !menuSelectedId.value ? null : String(menuSelectedId.value)
|
||
if (editingDraftNode) menuSelectedId.value = null
|
||
await loadMenuTree()
|
||
if (reloadId) await loadMenuConfig(reloadId)
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 工具栏「重载」:重载会放弃当前未保存的改动,先确认 */
|
||
function requestReloadConfig() {
|
||
const id = menuSelectedId.value
|
||
if (!id) return
|
||
if (!(loadedMenuId.value && hasUnsavedChanges())) {
|
||
void loadMenuConfig(id)
|
||
return
|
||
}
|
||
showConfirm({
|
||
title: '重载菜单配置',
|
||
content: '重载会放弃当前未保存的修改,改为从数据库重新加载。确定继续吗?',
|
||
okText: '重载',
|
||
onOk: async () => {
|
||
await loadMenuConfig(id)
|
||
},
|
||
})
|
||
}
|
||
|
||
/* ================= 树面板事件 ================= */
|
||
function onMenuSelect(nextId) {
|
||
confirmIfUnsaved(() => {
|
||
menuSelectedId.value = nextId
|
||
})
|
||
}
|
||
|
||
/** 右键菜单 / 工具栏新增:新菜单会切走当前草稿,先过未保存确认 */
|
||
function onMenuAdd({ parentId, menuType: newType }) {
|
||
// 未保存(草稿)节点不能作为父级:其 id 尚未落库,子菜单会挂在临时 id 下。
|
||
// 右键菜单对草稿节点已禁用新增项,此处兜住工具栏新增。
|
||
if (parentId && unsavedMenuIds.has(String(parentId))) {
|
||
Message.warning('请先保存当前新建的菜单,再在其下新增子菜单')
|
||
return
|
||
}
|
||
confirmIfUnsaved(() => void addMenu(parentId, newType))
|
||
}
|
||
|
||
async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||
const dragId = String(dragKey)
|
||
const dropId = String(dropKey)
|
||
if (dragId === dropId) return
|
||
|
||
const dragPos = findNodePosition(menuTreeData.value, dragId)
|
||
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()
|
||
|
||
if (dropPosition === 'inside') {
|
||
// 成为落点节点的子节点
|
||
moved.b_parent_id = dropNode.b_id
|
||
if (!dropNode.children) {
|
||
dropNode.children = []
|
||
dropNode.isLeaf = false
|
||
}
|
||
dropNode.children.push(moved)
|
||
renumberSets.add(dropNode.children)
|
||
renumberSets.add(dragPos.list)
|
||
} else {
|
||
// 同级插入:落点节点前 / 后
|
||
moved.b_parent_id = dropParentId
|
||
let targetList = dropPos.list
|
||
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
|
||
// 同一组内拖拽且被拖节点在落点之前:移除后索引需前移一位
|
||
if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) {
|
||
targetIndex -= 1
|
||
}
|
||
targetList.splice(targetIndex, 0, moved)
|
||
renumberSets.add(targetList)
|
||
}
|
||
|
||
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 / 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) {
|
||
for (const [field, value] of Object.entries(draftRow)) {
|
||
if (field === 'b_id') continue
|
||
maindata.value[field] = value
|
||
if (syncBaseline) maindata_org.value[field] = value
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!updatedRows.length) return
|
||
|
||
// 未保存草稿不能通过 updates 持久化,仅本地排序,落库后自然生效
|
||
const persistedRows = updatedRows.filter((r) => !unsavedMenuIds.has(String(r.b_id)))
|
||
if (!persistedRows.length) return
|
||
|
||
try {
|
||
const reqs = persistedRows.map((r) => ({
|
||
table: 's_menu',
|
||
key_field: 'b_id',
|
||
updates: [r],
|
||
}))
|
||
await saveObjectApi(reqs)
|
||
Message.success('排序已更新')
|
||
} catch (e) {
|
||
Message.error('排序更新失败:' + (e?.message || e))
|
||
// Reload tree on failure to restore correct order
|
||
await loadMenuTree()
|
||
}
|
||
}
|
||
|
||
/* ================= 新增菜单 ================= */
|
||
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,
|
||
b_name: '',
|
||
b_i18n: null,
|
||
b_menu_type: menuTypeValue,
|
||
b_route: null,
|
||
b_module_id: null,
|
||
b_icon: null,
|
||
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) {
|
||
menuExpandedKeys.value = [...new Set([...menuExpandedKeys.value, ...ancestors])]
|
||
}
|
||
// 目录节点强制非叶子(即使暂无子菜单也显示展开箭头)
|
||
if (menuTypeValue === 'directory') {
|
||
const treeNode = findTreeNode(menuTreeData.value, newId)
|
||
if (treeNode) treeNode.isLeaf = false
|
||
}
|
||
unsavedMenuIds.add(newId)
|
||
skipMenuLoad.value = true
|
||
menuSelectedId.value = newId
|
||
await nextTick()
|
||
maindata.value = cloneData(newMenu)
|
||
maindata_org.value = cloneData(maindata.value)
|
||
i18n_values.value = { [defaultLocale.value]: '' }
|
||
i18n_values_org.value = { [defaultLocale.value]: '' }
|
||
i18n_org.value = []
|
||
loadedMenuId.value = newId
|
||
}
|
||
|
||
/* ================= 删除菜单 ================= */
|
||
async function deleteMenu(id) {
|
||
const menu = id != null ? findTreeNode(menuTreeData.value, id) : selectedMenu.value
|
||
if (!menu || busy.value) return
|
||
const menuId = String(menu.b_id)
|
||
if (unsavedMenuIds.has(menuId)) {
|
||
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
|
||
removeTreeNode(menuTreeData.value, menuId)
|
||
menuSelectedId.value = null
|
||
return
|
||
}
|
||
if (hasSavedMenuDescendant(menu)) {
|
||
Message.warning('该菜单下还有已保存的子菜单,请先删除子菜单')
|
||
return
|
||
}
|
||
showConfirm({
|
||
title: '删除菜单',
|
||
content: `确定删除菜单「${menu.b_name || menu.b_id}」?`,
|
||
okText: '删除',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
// 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))
|
||
removeTreeNode(menuTreeData.value, menuId)
|
||
menuSelectedId.value = null
|
||
Message.success('菜单已删除')
|
||
} catch (e) {
|
||
Message.error('删除失败:' + (e?.message || e))
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/* ================= 加载选中菜单配置 ================= */
|
||
async function loadMenuConfig(menuId) {
|
||
const id = String(menuId)
|
||
configLoading.value = true
|
||
try {
|
||
const res = await loadDataApi('s_menu', `b_id = ${sqlText(id)}`)
|
||
if (String(menuSelectedId.value) !== id) return
|
||
const menu = res.data?.[0] || null
|
||
maindata_org.value = menu ? { ...menu } : null
|
||
maindata.value = cloneData(maindata_org.value)
|
||
// 多语言:默认语言复用 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_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_key: row.b_key,
|
||
b_locale: row.b_locale,
|
||
b_value: row.b_value,
|
||
})
|
||
}
|
||
}
|
||
i18n_values.value = i18nInitValues
|
||
i18n_values_org.value = { ...i18nInitValues }
|
||
i18n_org.value = i18n_org_rows
|
||
loadedMenuId.value = id
|
||
} catch (e) {
|
||
if (String(menuSelectedId.value) === String(menuId)) {
|
||
loadedMenuId.value = null
|
||
maindata.value = null
|
||
Message.error('菜单加载失败:' + (e?.message || e))
|
||
}
|
||
} finally {
|
||
if (String(menuSelectedId.value) === String(menuId)) configLoading.value = false
|
||
}
|
||
}
|
||
|
||
watch(menuSelectedId, (id) => {
|
||
if (skipMenuLoad.value) {
|
||
skipMenuLoad.value = false
|
||
configLoading.value = false
|
||
return
|
||
}
|
||
if (!id) {
|
||
configLoading.value = false
|
||
loadedMenuId.value = null
|
||
maindata.value = null
|
||
return
|
||
}
|
||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||
// (会得到主行为 null 的空数据,模板按 null 渲染直接崩溃)。
|
||
// 从树节点重建主行继续编辑;maindata_org 与之一致(未编辑态)。
|
||
if (unsavedMenuIds.has(String(id))) {
|
||
const node = findTreeNode(menuTreeData.value, String(id))
|
||
configLoading.value = false
|
||
if (!node) {
|
||
loadedMenuId.value = null
|
||
maindata.value = null
|
||
return
|
||
}
|
||
maindata.value = cloneData(menuRowOf(node))
|
||
maindata_org.value = cloneData(maindata.value)
|
||
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
|
||
i18n_values_org.value = { ...i18n_values.value }
|
||
i18n_org.value = []
|
||
loadedMenuId.value = String(id)
|
||
return
|
||
}
|
||
loadedMenuId.value = null
|
||
maindata.value = null
|
||
void loadMenuConfig(id)
|
||
})
|
||
|
||
/* ================= 未保存确认 ================= */
|
||
// 待确认动作:{ name, proceed };null 表示无待确认项
|
||
const switchConfirm = ref(null)
|
||
const switchSaving = ref(false)
|
||
|
||
function hasUnsavedChanges() {
|
||
if (!maindata.value) return false
|
||
if (JSON.stringify(maindata.value) !== JSON.stringify(maindata_org.value)) return true
|
||
// 多语言非默认语言变化视为未保存
|
||
if (
|
||
nonDefaultLanguages.value.some(
|
||
(l) => i18n_values.value[l.code] !== i18n_values_org.value[l.code],
|
||
)
|
||
)
|
||
return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 统一的「有未保存改动就先确认」闸门:无改动直接执行 action,有改动弹窗等待用户选择。
|
||
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单 / 刷新树)都必须经此,
|
||
* 否则会出现「某条路径静默丢弃编辑」的不一致。
|
||
*/
|
||
function confirmIfUnsaved(action) {
|
||
if (loadedMenuId.value && hasUnsavedChanges()) {
|
||
switchConfirm.value = {
|
||
name: selectedMenu.value?.b_name || selectedMenu.value?.b_id || '',
|
||
proceed: action,
|
||
}
|
||
return
|
||
}
|
||
action()
|
||
}
|
||
|
||
function onSwitchCancel() {
|
||
switchConfirm.value = null
|
||
switchSaving.value = false
|
||
}
|
||
|
||
function onSwitchDiscard() {
|
||
const state = switchConfirm.value
|
||
if (!state) return
|
||
switchConfirm.value = null
|
||
state.proceed()
|
||
}
|
||
|
||
async function onSwitchSave() {
|
||
const state = switchConfirm.value
|
||
if (!state || switchSaving.value) return
|
||
switchSaving.value = true
|
||
try {
|
||
const ok = await handleSave()
|
||
if (!ok) {
|
||
Message.warning('保存未完成,请处理后再继续')
|
||
return
|
||
}
|
||
switchConfirm.value = null
|
||
state.proceed()
|
||
} finally {
|
||
switchSaving.value = false
|
||
}
|
||
}
|
||
|
||
/* ================= 保存(handleSave → beforeSave → buildSaveData → 接口 → afterSave) ================= */
|
||
/** 保存前校验、字段同步与数据准备;返回 false 终止保存 */
|
||
async function beforeSave() {
|
||
const menuId = String(selectedMenu.value.b_id)
|
||
// 校验表单:错误已显示在对应 FormItem 下方,此处只做汇总提示
|
||
const errors = await basicPanelRef.value?.validate?.()
|
||
if (errors?.length) {
|
||
Message.warning('请修正表单中标红的错误项')
|
||
return false
|
||
}
|
||
const menuData = maindata.value
|
||
if (!menuData) return false
|
||
|
||
const isNew = unsavedMenuIds.has(menuId)
|
||
const allowedMenuTypes = new Set(['directory', 'page', 'external'])
|
||
if (!allowedMenuTypes.has(menuData.b_menu_type)) {
|
||
Message.warning('菜单类型只能是目录、页面或外链')
|
||
return false
|
||
}
|
||
if (!isNew && menuData.b_menu_type !== maindata_org.value?.b_menu_type) {
|
||
Message.warning('已保存菜单不允许修改菜单类型')
|
||
return false
|
||
}
|
||
if (menuData.b_parent_id && String(menuData.b_parent_id) === menuId) {
|
||
Message.warning('上级菜单不能选择当前菜单')
|
||
return false
|
||
}
|
||
const menuTypeRules = {
|
||
directory: { route: false, module: false },
|
||
page: { route: true, module: true },
|
||
external: { route: true, module: false },
|
||
}
|
||
const typeRule = menuTypeRules[menuData.b_menu_type]
|
||
if (typeRule.route && !String(menuData.b_route || '').trim()) {
|
||
Message.warning(
|
||
menuData.b_menu_type === 'external' ? '外链菜单必须填写外链地址' : '页面菜单必须填写路由地址',
|
||
)
|
||
return false
|
||
}
|
||
// 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空
|
||
if (!typeRule.route && menuData.b_route) menuData.b_route = null
|
||
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
|
||
|
||
// 字符串业务键:菜单 b_id 即编码,创建后不可修改;保存时统一去除首尾空格
|
||
const menuCode = String(menuData.b_id || '').trim()
|
||
if (isNew && (!menuCode || /^-\d+$/.test(menuCode))) {
|
||
Message.warning('新建菜单必须先填写菜单编码')
|
||
return false
|
||
}
|
||
if (!menuCode) {
|
||
Message.warning('菜单编码不能为空')
|
||
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 = buildMenuI18nKey(menuCode)
|
||
return true
|
||
}
|
||
|
||
/** 组装保存请求:s_menu / s_i18n 均与各自基线对比,只提交差异;全部无变化返回 null */
|
||
async function buildSaveData() {
|
||
const treeId = String(selectedMenu.value.b_id)
|
||
const menuData = maindata.value
|
||
if (!menuData) return null
|
||
|
||
// 树节点身份(未保存新建时为本地临时负数 id)仅用于 isNew 判定;
|
||
// 持久化身份一律使用菜单编码 b_id(子数据引用 / i18n 归属均用它)
|
||
const isNew = unsavedMenuIds.has(treeId)
|
||
const menuId = String(menuData.b_id || '').trim()
|
||
if (!menuId) return null
|
||
const menuI18nKey = buildMenuI18nKey(menuId)
|
||
|
||
const reqs = []
|
||
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
|
||
const menuChanges = diffRow({
|
||
current: menuData,
|
||
original: isNew ? null : maindata_org.value || null,
|
||
})
|
||
if (menuChanges.hasChanges) {
|
||
const menuRequest = { table: 's_menu', key_field: 'b_id' }
|
||
if (menuChanges.inserts.length) menuRequest.inserts = menuChanges.inserts
|
||
if (menuChanges.updates.length) menuRequest.updates = menuChanges.updates
|
||
reqs.push(menuRequest)
|
||
}
|
||
|
||
// 多语言(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
|
||
currentI18nRows.push({
|
||
b_key: menuI18nKey,
|
||
b_locale: lang.code,
|
||
b_value: value,
|
||
})
|
||
}
|
||
const previousI18nRows = i18n_org.value.map((r) => ({
|
||
b_key: r.b_key,
|
||
b_locale: r.b_locale,
|
||
b_value: String(r.b_value ?? '').trim(),
|
||
}))
|
||
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_key,b_locale' }
|
||
if (i18nChanges.inserts.length) {
|
||
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_canuse: 1 }))
|
||
}
|
||
if (i18nChanges.updates.length) {
|
||
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_canuse: 1 }))
|
||
}
|
||
if (i18nChanges.deletes.length) i18nReq.deletes = i18nChanges.deletes
|
||
reqs.push(i18nReq)
|
||
}
|
||
|
||
return reqs.length ? reqs : null
|
||
}
|
||
|
||
/** 保存成功后的树同步、服务端重载与状态更新 */
|
||
async function afterSave() {
|
||
const treeId = String(selectedMenu.value?.b_id ?? '')
|
||
const menuData = maindata.value
|
||
const savedKey = String(menuData?.b_id || treeId).trim()
|
||
unsavedMenuIds.delete(treeId)
|
||
// 保存后重新加载:以数据库落库结果为准重建树(新建菜单临时 id → 编码自然生效),
|
||
// 再切到持久化编码加载配置;无需本地做「临时 → 正式」键引用替换。
|
||
await loadMenuTree()
|
||
if (savedKey === String(menuSelectedId.value)) {
|
||
void loadMenuConfig(savedKey)
|
||
} else {
|
||
menuSelectedId.value = savedKey
|
||
}
|
||
Message.success('保存成功')
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!selectedMenu.value || busy.value) return false
|
||
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value))
|
||
return false
|
||
configSaving.value = true
|
||
try {
|
||
if (!(await beforeSave())) return false
|
||
const saveData = await buildSaveData()
|
||
if (!saveData) {
|
||
// 无任何差异:按规范不调用保存接口
|
||
Message.info('没有需要保存的变更')
|
||
return true
|
||
}
|
||
await saveObjectApi(saveData)
|
||
await afterSave()
|
||
return true
|
||
} catch (e) {
|
||
Message.error('保存失败:' + (e?.message || e))
|
||
return false
|
||
} finally {
|
||
configSaving.value = false
|
||
}
|
||
}
|
||
|
||
/* ================= 多语言名称 ================= */
|
||
/** 设置非默认语言的多语言名称 */
|
||
function setI18nValue(locale, value) {
|
||
i18n_values.value = { ...i18n_values.value, [locale]: value }
|
||
}
|
||
|
||
/** 多语言弹窗值对象(默认语言=菜单名称,其余=各语言) */
|
||
const i18nModalModel = computed(() => {
|
||
const menu = maindata.value
|
||
const values = { [defaultLocale.value]: menu?.b_name || '' }
|
||
for (const lang of nonDefaultLanguages.value)
|
||
values[lang.code] = i18n_values.value[lang.code] || ''
|
||
return values
|
||
})
|
||
|
||
/** 多语言弹窗确定时写回草稿与 i18n_values */
|
||
function onI18nModalChange(values) {
|
||
if (maindata.value) {
|
||
maindata.value.b_name = values[defaultLocale.value] ?? ''
|
||
}
|
||
for (const lang of nonDefaultLanguages.value) setI18nValue(lang.code, values[lang.code] || '')
|
||
}
|
||
|
||
/* ================= 树节点启用/停用 ================= */
|
||
async function onToggleEnable({ node, checked }) {
|
||
const id = String(node.b_id)
|
||
const newVal = checked ? 1 : 0
|
||
// 搜索状态下 title 插槽拿到的是 filterTreeByText 产出的浅拷贝(utils/tree.js),
|
||
// 改它既改不到原树节点、也不在响应式图里(不触发重渲染)。
|
||
// 因此一律按 id 反查真实节点操作,避免「库里改了、清空搜索后 UI 又回到旧值」。
|
||
const target = findTreeNode(menuTreeData.value, id) || node
|
||
if (Number(target.b_canuse) === newVal) return
|
||
// 更新树节点
|
||
target.b_canuse = newVal
|
||
// 已落库节点的勾选即时写入 s_menu(下方 saveObjectApi),主行与 maindata_org 基线必须同步更新:
|
||
// 否则 maindata.b_canuse 变了、maindata_org.b_canuse 没变,
|
||
// hasUnsavedChanges() 会把「已保存」的勾选误判为未保存改动,切节点时弹出确认框。
|
||
// 未保存草稿(unsavedMenuIds)不在此列:它本就要等保存时才落库,需保持脏标记。
|
||
if (maindata.value && String(maindata.value.b_id) === id) {
|
||
maindata.value.b_canuse = newVal
|
||
if (!unsavedMenuIds.has(id) && maindata_org.value) {
|
||
maindata_org.value.b_canuse = newVal
|
||
}
|
||
}
|
||
// 未保存草稿仅本地,落库时生效
|
||
if (unsavedMenuIds.has(id)) return
|
||
try {
|
||
await saveObjectApi([
|
||
{ table: 's_menu', key_field: 'b_id', updates: [{ b_id: id, b_canuse: newVal }] },
|
||
])
|
||
Message.success(newVal ? '菜单已启用' : '菜单已停用')
|
||
} catch (e) {
|
||
target.b_canuse = newVal === 1 ? 0 : 1
|
||
// 失败回滚:草稿与基线一起还原,避免残留脏标记
|
||
if (maindata.value && String(maindata.value.b_id) === id) {
|
||
maindata.value.b_canuse = target.b_canuse
|
||
if (maindata_org.value) maindata_org.value.b_canuse = target.b_canuse
|
||
}
|
||
Message.error('操作失败:' + (e?.message || e))
|
||
}
|
||
}
|
||
|
||
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
|
||
const moduleOptions = ref([])
|
||
let moduleOptionsLoaded = false
|
||
|
||
async function loadModuleOptions() {
|
||
if (moduleOptionsLoaded) return
|
||
try {
|
||
const res = await loadDataApi(
|
||
's_module',
|
||
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
|
||
'b_xh ASC, b_id ASC',
|
||
)
|
||
moduleOptions.value = (res.data || []).map((m) => ({
|
||
label: `[${m.b_id}] ${m.b_name}`,
|
||
value: String(m.b_id),
|
||
}))
|
||
moduleOptionsLoaded = true
|
||
} catch (e) {
|
||
// Silently fail; modules will be empty
|
||
}
|
||
}
|
||
|
||
/* ================= 快捷键 ================= */
|
||
useHotkeys([
|
||
{
|
||
key: 's',
|
||
mod: true,
|
||
ignoreWhenTyping: false,
|
||
handler: () => void handleSave(),
|
||
},
|
||
])
|
||
|
||
/* ================= 确认弹窗 ================= */
|
||
const confirmVisible = ref(false)
|
||
const confirmState = ref(null)
|
||
const confirmSaving = ref(false)
|
||
|
||
function showConfirm({ title, content, okText, okType, onOk }) {
|
||
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
|
||
confirmVisible.value = true
|
||
}
|
||
|
||
async function onConfirmOk() {
|
||
if (!confirmState.value?.onOk || confirmSaving.value) return
|
||
confirmSaving.value = true
|
||
try {
|
||
await confirmState.value.onOk()
|
||
confirmVisible.value = false
|
||
} finally {
|
||
confirmSaving.value = false
|
||
}
|
||
}
|
||
|
||
/* ================= 初始化 ================= */
|
||
loadMenuTree()
|
||
loadModuleOptions()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mm-page">
|
||
<div class="mm-body">
|
||
<Splitter class="mm-body-splitter">
|
||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||
<MenuTreePanel
|
||
v-model:expanded-keys="menuExpandedKeys"
|
||
:tree-data="menuTreeData"
|
||
:loading="menuTreeLoading"
|
||
:selected-id="menuSelectedId"
|
||
:unsaved-ids="unsavedMenuIds"
|
||
@select="onMenuSelect"
|
||
@add="onMenuAdd"
|
||
@delete="(id) => void deleteMenu(id)"
|
||
@drop="(payload) => void onMenuDrop(payload)"
|
||
@refresh="requestReloadTree"
|
||
@toggle-enable="(payload) => void onToggleEnable(payload)"
|
||
/>
|
||
</SplitterPanel>
|
||
|
||
<SplitterPanel>
|
||
<main class="mm-main">
|
||
<template v-if="selectedMenu">
|
||
<!-- 顶部操作栏 -->
|
||
<div class="mm-main-toolbar">
|
||
<Button
|
||
type="primary"
|
||
:loading="configSaving"
|
||
:disabled="configLoading || !maindata"
|
||
@click="handleSave"
|
||
>
|
||
<Save :size="14" /> 保存
|
||
</Button>
|
||
<Button type="outline" :disabled="busy || !maindata" @click="requestReloadConfig">
|
||
<RefreshCw :size="14" /> 重载
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 主体:三块 Splitter 分割 -->
|
||
<div v-if="maindata" class="mm-edit-body">
|
||
<Splitter class="mm-edit-splitter">
|
||
<SplitterPanel :default-size="360">
|
||
<div class="mm-form-col__card">
|
||
<div class="mm-form-col__head">
|
||
<div class="mm-pane__title">菜单配置</div>
|
||
</div>
|
||
<MenuBasicPanel
|
||
ref="basicPanelRef"
|
||
v-model="maindata"
|
||
:type-locked="menuTypeLocked"
|
||
:module-options="moduleOptions"
|
||
:loading="busy"
|
||
@open-i18n="i18nModalVisible = true"
|
||
/>
|
||
</div>
|
||
</SplitterPanel>
|
||
</Splitter>
|
||
</div>
|
||
<div v-else-if="configLoading" class="mm-config-state">
|
||
<Spin />
|
||
<span>加载中...</span>
|
||
</div>
|
||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||
</template>
|
||
<div v-else class="mm-main__empty">
|
||
<FolderTree :size="32" />
|
||
<span>请选择左侧菜单节点</span>
|
||
</div>
|
||
</main>
|
||
</SplitterPanel>
|
||
</Splitter>
|
||
</div>
|
||
|
||
<!-- 通用确认弹窗 -->
|
||
<Modal
|
||
v-model:open="confirmVisible"
|
||
title="确认"
|
||
:confirm-loading="confirmSaving"
|
||
:mask-closable="!confirmSaving"
|
||
:keyboard="!confirmSaving"
|
||
>
|
||
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">{{ confirmState?.content }}</p>
|
||
<template #footer>
|
||
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false"
|
||
>取消</Button
|
||
>
|
||
<Button
|
||
:type="confirmState?.okType || 'primary'"
|
||
:loading="confirmSaving"
|
||
@click="onConfirmOk"
|
||
>
|
||
{{ confirmState?.okText || '确定' }}
|
||
</Button>
|
||
</template>
|
||
</Modal>
|
||
|
||
<!-- 多语言名称编辑(通用组件,与模块配置共用) -->
|
||
<I18nNameModal
|
||
:open="i18nModalVisible"
|
||
:languages="I18N_LANGUAGES"
|
||
:model-value="i18nModalModel"
|
||
:hint="`${defaultLocaleName}即「菜单名称」;其余语言保存到多语言表(s_i18n,键 menu.编码)。`"
|
||
@update:model-value="onI18nModalChange"
|
||
@update:open="(v) => (i18nModalVisible = v)"
|
||
/>
|
||
|
||
<!-- 切换菜单未保存确认 -->
|
||
<UnsavedChangesModal
|
||
:open="!!switchConfirm"
|
||
:name="switchConfirm?.name || ''"
|
||
:saving="switchSaving"
|
||
save-text="保存并继续"
|
||
discard-text="放弃修改"
|
||
@save="onSwitchSave"
|
||
@discard="onSwitchDiscard"
|
||
@update:open="(v) => !v && onSwitchCancel()"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ============================================================
|
||
* 菜单管理页面样式(仅页面布局)
|
||
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel)一起维护
|
||
* ============================================================ */
|
||
|
||
/* ---- 页面根 ---- */
|
||
.mm-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ---- 主体左右布局 ---- */
|
||
.mm-body {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ---- Splitter 面板 ----
|
||
* 面板不再左右留白,两张白卡紧贴;灰缝由分隔条自身的触发区宽度
|
||
* (--fms-splitter-trigger-size,默认 6px)承担。
|
||
* 常显线平时透明,hover / 拖拽时显示整条 100% 高度的细线 + 手柄提示 */
|
||
.mm-body-splitter :deep(> .fms-splitter-panel) {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mm-body-splitter :deep(.fms-splitter-bar::before) {
|
||
opacity: 0;
|
||
}
|
||
|
||
.mm-body-splitter :deep(.fms-splitter-bar:hover::before),
|
||
.mm-body-splitter :deep(.fms-splitter-bar-active::before),
|
||
.mm-body-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* ---- 右侧主区 ---- */
|
||
.mm-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
gap: 8px;
|
||
}
|
||
|
||
.mm-main__empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
color: var(--fms-text-secondary);
|
||
font-size: 14px;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
box-shadow: var(--fms-shadow);
|
||
|
||
svg {
|
||
color: var(--fms-text-secondary);
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
|
||
/* ---- 顶部操作栏 ---- */
|
||
.mm-main-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
padding: 8px 10px;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
}
|
||
|
||
/* ---- 主体三块 Splitter ---- */
|
||
.mm-edit-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mm-edit-splitter :deep(> .fms-splitter-panel) {
|
||
overflow: hidden;
|
||
padding: 0 3px;
|
||
}
|
||
|
||
/* 第一个面板(菜单配置)去左边距;最后一个面板去右边距 */
|
||
.mm-edit-splitter :deep(> .fms-splitter-panel:first-child) {
|
||
padding-left: 0;
|
||
}
|
||
|
||
.mm-edit-splitter :deep(> .fms-splitter-panel:last-child) {
|
||
padding-right: 0;
|
||
}
|
||
|
||
/* 左栏:表单卡片 */
|
||
.mm-form-col__card {
|
||
height: 100%;
|
||
padding: 8px 10px;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
box-shadow: var(--fms-shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* 左卡片头部 */
|
||
.mm-form-col__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* 区块标题 */
|
||
.mm-pane__title {
|
||
margin-bottom: 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--fms-text);
|
||
}
|
||
|
||
.mm-config-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
color: var(--fms-text-secondary);
|
||
font-size: 14px;
|
||
}
|
||
</style>
|