Files
workspace/code/fms/fms-vue/src/views/module/menu-management/index.vue
T
2026-09-14 17:28:20 +08:00

1113 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>