1003 lines
36 KiB
Vue
1003 lines
36 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 {
|
||
applyTreePosition,
|
||
buildSortedTree,
|
||
computeTreeMove,
|
||
decorateTreeNodes,
|
||
expandAncestors,
|
||
findTreeNode,
|
||
insertTreeNode,
|
||
removeTreeNode,
|
||
sortTree,
|
||
} 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 ModulePickerModal from './ModulePickerModal.vue'
|
||
import { MENU_NON_LEAF_TYPES, MENU_TYPE_ICON_MAP, buildMenuI18nKey, menuRowOf } from './utils'
|
||
import { useI18nStore } from '@/stores/i18n'
|
||
|
||
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
|
||
function sqlText(value) {
|
||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||
}
|
||
|
||
/** 编码数组的顺序无关签名(比较模块绑定集合是否变化) */
|
||
function sortedKey(list) {
|
||
return (list || []).map(String).toSorted().join(',')
|
||
}
|
||
|
||
/* ================= 多语言配置(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)
|
||
|
||
/** 绑定模块草稿(s_menu_module 的模块编码数组,随菜单保存一并提交) */
|
||
const menuModules = ref([])
|
||
/** 绑定模块加载基线(保存时行级 diffRows 的原始集合) */
|
||
const menuModules_org = ref([])
|
||
/** 模块绑定选择弹层 */
|
||
const modulePickerOpen = 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 = decorateTreeNodes(buildSortedTree(res.data || []), {
|
||
typeField: 'b_menu_type',
|
||
nonLeafValues: MENU_NON_LEAF_TYPES,
|
||
iconMap: MENU_TYPE_ICON_MAP,
|
||
})
|
||
unsavedMenuIds.clear()
|
||
} catch (e) {
|
||
Message.error('加载菜单树失败:' + (e?.message || e))
|
||
} finally {
|
||
menuTreeLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 工具栏「重载」:重载会放弃当前未保存的改动,先确认 */
|
||
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 }) {
|
||
// 成环 / 类型 / 未保存守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
|
||
const result = computeTreeMove(menuTreeData.value, {
|
||
dragKey,
|
||
dropKey,
|
||
dropPosition,
|
||
typeField: 'b_menu_type',
|
||
nonLeafValues: MENU_NON_LEAF_TYPES,
|
||
isUnsaved: (id) => unsavedMenuIds.has(id),
|
||
messages: {
|
||
cycle: '不能把菜单移动到它自己的下级',
|
||
notContainer: '只能将菜单放入目录下',
|
||
unsavedTarget: '请先保存目标菜单,再调整层级',
|
||
},
|
||
})
|
||
if (!result.ok) {
|
||
if (result.message) Message.warning(result.message)
|
||
return
|
||
}
|
||
const { updatedRows } = result
|
||
|
||
// 排序即时落库(下方 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_icon: null,
|
||
b_xh: 10,
|
||
b_canuse: 1,
|
||
}
|
||
// b_depth / b_path 是 b_parent_id 的派生字段(b_path 非空且无默认值,缺失会新增失败)。
|
||
// 此处先按父级填一次占位值;保存前会用最终菜单编码重算(临时 id 不落库)。
|
||
applyTreePosition(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) }] },
|
||
// 模块绑定关系随菜单删除(b_menu_id 为复合主键首列,按它批量删)
|
||
{ table: 's_menu_module', key_field: 'b_menu_id', deletes: [{ b_menu_id: 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
|
||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只登记 data / virtual 模块
|
||
const moduleRes = await loadDataApi(
|
||
's_menu_module',
|
||
`b_menu_id = ${sqlText(id)}`,
|
||
'b_xh ASC, b_module_id ASC',
|
||
)
|
||
if (String(menuSelectedId.value) !== id) return
|
||
menuModules_org.value = (moduleRes.data || []).map((row) => String(row.b_module_id))
|
||
menuModules.value = [...menuModules_org.value]
|
||
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
|
||
menuModules.value = []
|
||
menuModules_org.value = []
|
||
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 = []
|
||
menuModules.value = []
|
||
menuModules_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
|
||
// 模块绑定变化视为未保存(顺序无关)
|
||
if (sortedKey(menuModules.value) !== sortedKey(menuModules_org.value)) 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
|
||
}
|
||
// 路由 / 模块按菜单类型收敛:目录、外链不绑模块(清空草稿,保存时按 diff 删除关系行)
|
||
if (!typeRule.route && menuData.b_route) menuData.b_route = null
|
||
if (!typeRule.module && menuModules.value.length) menuModules.value = []
|
||
|
||
// 字符串业务键:菜单 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) {
|
||
applyTreePosition(menuData, null)
|
||
} else {
|
||
const parentNode = findTreeNode(menuTreeData.value, parentId)
|
||
if (parentNode) applyTreePosition(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)
|
||
}
|
||
|
||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只提交增删行;
|
||
// b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。
|
||
const toModuleRows = (list) =>
|
||
list.map((moduleId, index) => ({
|
||
b_menu_id: menuId,
|
||
b_module_id: String(moduleId),
|
||
b_xh: (index + 1) * 10,
|
||
b_canuse: 1,
|
||
}))
|
||
const moduleChanges = diffRows(
|
||
toModuleRows(menuModules.value),
|
||
toModuleRows(menuModules_org.value),
|
||
{ keyFields: ['b_menu_id', 'b_module_id'] },
|
||
)
|
||
if (
|
||
moduleChanges.inserts.length ||
|
||
moduleChanges.updates.length ||
|
||
moduleChanges.deletes.length
|
||
) {
|
||
const moduleReq = { table: 's_menu_module', key_field: 'b_menu_id,b_module_id' }
|
||
if (moduleChanges.inserts.length) moduleReq.inserts = moduleChanges.inserts
|
||
if (moduleChanges.updates.length) moduleReq.updates = moduleChanges.updates
|
||
if (moduleChanges.deletes.length) moduleReq.deletes = moduleChanges.deletes
|
||
reqs.push(moduleReq)
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
/* ================= 加载模块清单(绑定模块选择器) ================= */
|
||
/** 模块平铺行(s_module 启用行;已绑模块名称展示与类型过滤用) */
|
||
const moduleRows = ref([])
|
||
/** 模块树(module 分类 + data / virtual 数据模块;绑定弹层勾选用) */
|
||
const moduleTree = ref([])
|
||
/** 模块编码 → 模块类型(弹层确定时过滤掉 module 分类节点) */
|
||
const moduleTypes = computed(() =>
|
||
Object.fromEntries(moduleRows.value.map((m) => [String(m.b_id), String(m.b_module_type)])),
|
||
)
|
||
let moduleOptionsLoaded = false
|
||
|
||
async function loadModuleOptions() {
|
||
if (moduleOptionsLoaded) return
|
||
try {
|
||
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||
const rows = res.data || []
|
||
moduleRows.value = rows
|
||
moduleTree.value = buildSortedTree(rows)
|
||
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="fms-page">
|
||
<div class="fms-page__body">
|
||
<Splitter class="fms-splitter-clean">
|
||
<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)"
|
||
@toggle-enable="(payload) => void onToggleEnable(payload)"
|
||
/>
|
||
</SplitterPanel>
|
||
|
||
<SplitterPanel>
|
||
<main class="fms-page__main">
|
||
<template v-if="selectedMenu">
|
||
<!-- 顶部操作栏 -->
|
||
<div class="fms-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="fms-splitter-clean mm-edit-splitter">
|
||
<SplitterPanel :default-size="360">
|
||
<div class="fms-card mm-form-col__card">
|
||
<div class="mm-form-col__head">
|
||
<div class="fms-pane-title">菜单配置</div>
|
||
</div>
|
||
<MenuBasicPanel
|
||
ref="basicPanelRef"
|
||
v-model="maindata"
|
||
v-model:modules="menuModules"
|
||
:type-locked="menuTypeLocked"
|
||
:module-rows="moduleRows"
|
||
:loading="busy"
|
||
@open-i18n="i18nModalVisible = true"
|
||
@open-module-picker="modulePickerOpen = true"
|
||
/>
|
||
</div>
|
||
</SplitterPanel>
|
||
</Splitter>
|
||
</div>
|
||
<div v-else-if="configLoading" class="fms-state">
|
||
<Spin />
|
||
<span>加载中...</span>
|
||
</div>
|
||
<div v-else class="fms-state">暂无配置数据</div>
|
||
</template>
|
||
<div v-else class="fms-state fms-card">
|
||
<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>
|
||
|
||
<!-- 绑定模块选择(模块树多选:勾分类节点自动级联其下全部模块) -->
|
||
<ModulePickerModal
|
||
:open="modulePickerOpen"
|
||
:tree-data="moduleTree"
|
||
:checked-keys="menuModules"
|
||
:module-types="moduleTypes"
|
||
@update:open="(v) => (modulePickerOpen = v)"
|
||
@confirm="menuModules = $event"
|
||
/>
|
||
|
||
<!-- 多语言名称编辑(通用组件,与模块配置共用) -->
|
||
<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>
|
||
/* ============================================================
|
||
* 菜单管理页面样式(仅页面专有部分)
|
||
* 通用骨架(页面三层 / Splitter / 白卡 / 工具条 / 状态占位)见 styles/common.scss
|
||
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel)一起维护
|
||
* ============================================================ */
|
||
|
||
/* ---- 主体三块 Splitter ----
|
||
* 面板裁切与「常显线平时透明」已由 .fms-splitter-clean 统一处理,
|
||
* 这里只补面板之间的 3px 灰缝,首尾面板不留边距。 */
|
||
.mm-edit-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mm-edit-splitter :deep(> .fms-splitter-panel) {
|
||
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;
|
||
}
|
||
|
||
/* 左栏:表单卡片(.fms-card 提供白卡三件套,这里只写排布) */
|
||
.mm-form-col__card {
|
||
height: 100%;
|
||
padding: 8px 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* 左卡片头部 */
|
||
.mm-form-col__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
</style>
|