1803 lines
57 KiB
Vue
1803 lines
57 KiB
Vue
<script setup>
|
||
/**
|
||
* 菜单管理:管理 s_menu 导航树。
|
||
* 布局:左侧菜单树 + 右侧属性编辑面板。
|
||
* 菜单类型:directory(目录)、page(页面)、external(外链)。
|
||
*/
|
||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||
import { useHotkeys } from '@/composables/useHotkeys'
|
||
import {
|
||
FolderTree,
|
||
Plus,
|
||
Search,
|
||
Save,
|
||
Trash2,
|
||
Folder,
|
||
FileText,
|
||
ExternalLink,
|
||
RefreshCw,
|
||
Languages,
|
||
} from '@lucide/vue'
|
||
import {
|
||
Button,
|
||
Checkbox,
|
||
Input,
|
||
Select,
|
||
Splitter,
|
||
SplitterPanel,
|
||
Tree,
|
||
Modal,
|
||
Message,
|
||
Form,
|
||
FormItem,
|
||
Dropdown,
|
||
} from '@/components/ui'
|
||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||
import { loadDataApi, loadDataBySqlApi, saveObjectApi, nextIdApi } from '@/services/api'
|
||
import {
|
||
buildTree,
|
||
sortTree,
|
||
findTreeNode,
|
||
collectAllKeys,
|
||
expandAncestors,
|
||
filterTreeByText,
|
||
insertTreeNode,
|
||
removeTreeNode,
|
||
} from '@/utils/tree'
|
||
import { cloneData } from '@/utils/dataChanges'
|
||
import { createTempId } from '@/utils/tempId'
|
||
import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||
import { useLanguagesStore } from '@/stores/languages'
|
||
|
||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
|
||
// 语言清单来自数据库(stores/languages),不再写死 zh-CN/en-US。
|
||
const langStore = useLanguagesStore()
|
||
|
||
/** 默认语言 code(b_default=1;数据异常时回退第一个启用语言,均为数据库配置) */
|
||
const defaultLocale = computed(() => {
|
||
const def = langStore.defaultLanguage?.b_code
|
||
if (def) return def
|
||
return langStore.languages[0]?.b_code || ''
|
||
})
|
||
|
||
/** 给 I18nNameModal 的语言列表({ code, name, default }) */
|
||
const I18N_LANGUAGES = computed(() =>
|
||
langStore.languages.map((l) => ({
|
||
code: l.b_code,
|
||
name: l.b_name,
|
||
default: Number(l.b_default) === 1,
|
||
})),
|
||
)
|
||
|
||
/** 非默认语言(走 s_i18n 表,{ code, name }) */
|
||
const nonDefaultLanguages = computed(() =>
|
||
langStore.nonDefaultLanguages.map((l) => ({ code: l.b_code, name: l.b_name })),
|
||
)
|
||
|
||
/** 默认语言名称(hint 文案用,动态取自语言配置) */
|
||
const defaultLocaleName = computed(
|
||
() => langStore.defaultLanguage?.b_name || defaultLocale.value || '',
|
||
)
|
||
|
||
/* ================= 类型映射 ================= */
|
||
const menuTypeOptions = [
|
||
{ label: '目录', value: 'directory' },
|
||
{ label: '页面', value: 'page' },
|
||
{ label: '外链', value: 'external' },
|
||
]
|
||
|
||
const menuTypeIconMap = {
|
||
directory: Folder,
|
||
page: FileText,
|
||
external: ExternalLink,
|
||
}
|
||
|
||
/* ================= 状态 ================= */
|
||
const menuTreeData = ref([])
|
||
const menuTreeLoading = ref(false)
|
||
const menuKeyword = ref('')
|
||
const menuSelectedId = ref(null)
|
||
const menuExpandedKeys = ref([])
|
||
const unsavedMenuIds = new Set()
|
||
|
||
const configLoading = ref(false)
|
||
const configSaving = ref(false)
|
||
const loadedMenuId = ref(null)
|
||
const originals = ref(null)
|
||
const drafts = ref(null)
|
||
const skipMenuLoad = { value: false }
|
||
|
||
/** 多语言翻译:{ [locale]: 名称 },默认语言复用 b_name,其余存 s_i18n */
|
||
const i18nValues = ref({})
|
||
/** 多语言基线快照,用于未保存判断 */
|
||
const originalI18nValues = ref({})
|
||
/** 多语言名称编辑弹窗 */
|
||
const i18nModalVisible = ref(false)
|
||
|
||
/* ================= 派生状态 ================= */
|
||
const selectedMenu = computed(() =>
|
||
menuSelectedId.value ? findTreeNode(menuTreeData.value, menuSelectedId.value) : null,
|
||
)
|
||
|
||
const busy = computed(() => configLoading.value || configSaving.value)
|
||
|
||
const menuType = computed(() => selectedMenu.value?.b_menu_type || null)
|
||
const isDirectory = computed(() => menuType.value === 'directory')
|
||
const isPage = computed(() => menuType.value === 'page')
|
||
const isExternal = computed(() => menuType.value === 'external')
|
||
const menuTypeLocked = computed(
|
||
() => !!selectedMenu.value && !unsavedMenuIds.has(String(selectedMenu.value.b_id)),
|
||
)
|
||
|
||
/* ================= 树加载 ================= */
|
||
watch(menuKeyword, (kw) => {
|
||
menuExpandedKeys.value = kw.trim() ? collectAllKeys(menuTreeData.value) : []
|
||
})
|
||
|
||
const filteredMenuTree = computed(() => filterTreeByText(menuTreeData.value, menuKeyword.value))
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// 目录节点强制非叶子(即使暂无子菜单也显示展开箭头,便于新增子菜单)。
|
||
// 同时预载图标到 node.icon,tree-node.vue 会自动渲染(样式与模块管理一致)。
|
||
// 注意:title 插槽不再手动渲染图标,避免双图标。
|
||
function markDirectoryNonLeaf(nodes) {
|
||
for (const node of nodes) {
|
||
if (node.b_menu_type === 'directory') node.isLeaf = false
|
||
const icon = menuTypeIconMap[node.b_menu_type]
|
||
if (icon) node.icon = icon
|
||
if (node.children) markDirectoryNonLeaf(node.children)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
/* ================= 右键菜单 ================= */
|
||
function buildMenuContextMenu(node) {
|
||
const isUnsaved = node != null && unsavedMenuIds.has(String(node.b_id))
|
||
const items = [
|
||
{ key: 'add-directory', label: '新增目录', icon: Folder, disabled: isUnsaved },
|
||
{ key: 'add-page', label: '新增页面', icon: FileText, disabled: isUnsaved },
|
||
{ key: 'add-external', label: '新增外链', icon: ExternalLink, disabled: isUnsaved },
|
||
]
|
||
if (node) {
|
||
items.push({ divider: true })
|
||
items.push({ key: 'delete', label: '删除', icon: Trash2, danger: true })
|
||
}
|
||
return items
|
||
}
|
||
|
||
function onMenuContextMenuClick({ item, node }) {
|
||
const parentId = node ? String(node.b_id) : null
|
||
if (item.key === 'add-directory') void addMenu(parentId, 'directory')
|
||
else if (item.key === 'add-page') void addMenu(parentId, 'page')
|
||
else if (item.key === 'add-external') void addMenu(parentId, 'external')
|
||
else if (item.key === 'delete') void deleteMenu(parentId)
|
||
}
|
||
|
||
/* ================= 工具栏新增(挂到当前选中节点下,无选中则顶级) ================= */
|
||
const toolbarAddOptions = [
|
||
{ key: 'directory', label: '新增目录', icon: Folder },
|
||
{ key: 'page', label: '新增页面', icon: FileText },
|
||
{ key: 'external', label: '新增外链', icon: ExternalLink },
|
||
]
|
||
|
||
function onToolbarAdd({ key }) {
|
||
const parentId = selectedMenu.value ? String(selectedMenu.value.b_id) : null
|
||
void addMenu(parentId, key)
|
||
}
|
||
|
||
/* ================= 排序(拖拽) ================= */
|
||
// 在树中查找节点所在的父数组及索引
|
||
function findNodePosition(nodes, id) {
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||
if (nodes[i].children) {
|
||
const found = findNodePosition(nodes[i].children, id)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 重排某组兄弟的 b_xh(10 起步、步长 10),收集变化行
|
||
function renumberSiblings(list, updatedRows) {
|
||
list.forEach((s, i) => {
|
||
const nextXh = (i + 1) * 10
|
||
if (s.b_xh !== nextXh) {
|
||
s.b_xh = nextXh
|
||
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
|
||
}
|
||
})
|
||
}
|
||
|
||
async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||
if (menuKeyword.value.trim()) return // 过滤状态下禁止拖拽
|
||
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 dropNode = dropPos.list[dropPos.index]
|
||
const dropParentId = dropNode.b_parent_id ?? null
|
||
const [moved] = dragPos.list.splice(dragPos.index, 1)
|
||
|
||
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))
|
||
|
||
// 若当前编辑的节点排序号/父级变化,同步到草稿(未保存节点落库时随 inserts 一起生效)
|
||
if (drafts.value?.menu) {
|
||
const draftRow = updatedRows.find((r) => String(r.b_id) === String(drafts.value.menu.b_id))
|
||
if (draftRow) drafts.value.menu.b_xh = draftRow.b_xh
|
||
if (String(drafts.value.menu.b_id) === dragId) {
|
||
drafts.value.menu.b_parent_id = moved.b_parent_id
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
/* ================= 新增菜单 ================= */
|
||
/** 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段),用于未保存节点切回时重建草稿 */
|
||
function menuRowOf(node) {
|
||
return {
|
||
b_id: node.b_id,
|
||
b_code: node.b_code,
|
||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||
b_name: node.b_name,
|
||
b_i18n: node.b_i18n ?? null,
|
||
b_menu_type: node.b_menu_type,
|
||
b_route: node.b_route ?? null,
|
||
b_module_id: node.b_module_id ?? null,
|
||
b_icon: node.b_icon ?? null,
|
||
b_xh: node.b_xh,
|
||
b_canuse: node.b_canuse,
|
||
}
|
||
}
|
||
|
||
async function addMenu(parentId, menuTypeValue = 'page') {
|
||
const res = await nextIdApi()
|
||
const newId = String(res.data)
|
||
const newMenu = {
|
||
b_id: newId,
|
||
b_code: '',
|
||
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,
|
||
}
|
||
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()
|
||
drafts.value = cloneData({ menu: newMenu })
|
||
originals.value = cloneData(drafts.value)
|
||
i18nValues.value = { [defaultLocale.value]: '' }
|
||
originalI18nValues.value = { [defaultLocale.value]: '' }
|
||
powers.value = []
|
||
powersBaseline.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_code}」?`,
|
||
okText: '删除',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
const menuPowerCode = menu.b_code ? `menu.${String(menu.b_code).trim()}` : ''
|
||
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
|
||
// 清理权限点:访问(type=menu 按 b_resource 关联)+ 操作/按钮(type=action 按 b_module_id 关联)
|
||
if (menuPowerCode) {
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
deletes: [{ b_power_type: 'menu', b_resource: menuPowerCode }],
|
||
})
|
||
}
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
deletes: [{ b_power_type: 'action', b_module_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 = ${id}`)
|
||
if (String(menuSelectedId.value) !== id) return
|
||
const menu = res.data?.[0] || null
|
||
originals.value = { menu: menu ? { ...menu } : null }
|
||
drafts.value = cloneData(originals.value)
|
||
// 多语言:默认语言复用 b_name,其余读 s_i18n(按 b_module_id 关联)
|
||
const i18nValuesInit = { [defaultLocale.value]: menu?.b_name || '' }
|
||
if (menu) {
|
||
const i18nRes = await loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC')
|
||
for (const row of i18nRes.data || []) {
|
||
if (row.b_locale && row.b_locale !== defaultLocale.value)
|
||
i18nValuesInit[row.b_locale] = row.b_value
|
||
}
|
||
}
|
||
i18nValues.value = i18nValuesInit
|
||
originalI18nValues.value = { ...i18nValuesInit }
|
||
// 操作/按钮权限点:仅页面/外链才有(目录是导航容器,无按钮权限)
|
||
powersLoading.value = true
|
||
try {
|
||
const powerRes =
|
||
menu && menu.b_menu_type !== 'directory'
|
||
? await loadDataApi(
|
||
's_power',
|
||
`b_power_type = 'action' AND b_module_id = ${id}`,
|
||
'b_xh ASC, b_id ASC',
|
||
)
|
||
: { data: [] }
|
||
powers.value = (powerRes.data || []).map((row) => ({
|
||
b_id: row.b_id,
|
||
b_code: row.b_code,
|
||
b_name: row.b_name,
|
||
b_power_type: 'action',
|
||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||
}))
|
||
powersBaseline.value = JSON.stringify(powers.value)
|
||
selectedPowerKeys.value = []
|
||
} finally {
|
||
powersLoading.value = false
|
||
}
|
||
loadedMenuId.value = id
|
||
} catch (e) {
|
||
if (String(menuSelectedId.value) === String(menuId)) {
|
||
loadedMenuId.value = null
|
||
drafts.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
|
||
drafts.value = null
|
||
return
|
||
}
|
||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||
// (会得到 menu 为 null 的「真值」drafts,模板按 null 渲染直接崩溃)。
|
||
// 从树节点重建草稿继续编辑;originals 与之一致(未编辑态)。
|
||
if (unsavedMenuIds.has(String(id))) {
|
||
const node = findTreeNode(menuTreeData.value, String(id))
|
||
configLoading.value = false
|
||
if (!node) {
|
||
loadedMenuId.value = null
|
||
drafts.value = null
|
||
return
|
||
}
|
||
drafts.value = cloneData({ menu: menuRowOf(node) })
|
||
originals.value = cloneData(drafts.value)
|
||
i18nValues.value = { [defaultLocale.value]: node.b_name || '' }
|
||
originalI18nValues.value = { ...i18nValues.value }
|
||
powers.value = []
|
||
powersBaseline.value = '[]'
|
||
loadedMenuId.value = String(id)
|
||
return
|
||
}
|
||
loadedMenuId.value = null
|
||
drafts.value = null
|
||
void loadMenuConfig(id)
|
||
})
|
||
|
||
/* ================= 切换未保存确认 ================= */
|
||
const switchConfirm = ref(null)
|
||
const switchSaving = ref(false)
|
||
|
||
function hasUnsavedChanges() {
|
||
if (!drafts.value || !originals.value) return false
|
||
if (JSON.stringify(drafts.value.menu) !== JSON.stringify(originals.value.menu)) return true
|
||
// 多语言非默认语言变化视为未保存
|
||
if (
|
||
nonDefaultLanguages.value.some(
|
||
(l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code],
|
||
)
|
||
)
|
||
return true
|
||
// 操作/按钮权限点变化视为未保存
|
||
const current = JSON.stringify(powers.value)
|
||
if (current !== powersBaseline.value) return true
|
||
return false
|
||
}
|
||
|
||
function onSwitchCancel() {
|
||
switchConfirm.value = null
|
||
switchSaving.value = false
|
||
}
|
||
|
||
function onSwitchDiscard() {
|
||
const state = switchConfirm.value
|
||
if (!state) return
|
||
menuSelectedId.value = state.pendingId
|
||
switchConfirm.value = null
|
||
}
|
||
|
||
async function onSwitchSave() {
|
||
const state = switchConfirm.value
|
||
if (!state || switchSaving.value) return
|
||
switchSaving.value = true
|
||
try {
|
||
const ok = await saveMenu()
|
||
if (!ok) {
|
||
Message.warning('保存未完成,请处理后再切换')
|
||
return
|
||
}
|
||
menuSelectedId.value = state.pendingId
|
||
switchConfirm.value = null
|
||
} finally {
|
||
switchSaving.value = false
|
||
}
|
||
}
|
||
|
||
function onMenuSelect(keys) {
|
||
if (!keys.length) return
|
||
const nextId = String(keys[0])
|
||
if (nextId === String(menuSelectedId.value)) return
|
||
if (loadedMenuId.value && hasUnsavedChanges()) {
|
||
switchConfirm.value = {
|
||
pendingId: nextId,
|
||
name: selectedMenu.value?.b_name || selectedMenu.value?.b_code || '',
|
||
}
|
||
return
|
||
}
|
||
menuSelectedId.value = nextId
|
||
}
|
||
|
||
/* ================= 保存 ================= */
|
||
const formRef = ref(null)
|
||
|
||
async function saveMenu() {
|
||
if (!selectedMenu.value || busy.value) return false
|
||
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value))
|
||
return false
|
||
const menuId = String(selectedMenu.value.b_id)
|
||
|
||
// Validate form
|
||
const errors = await formRef.value?.validateAll?.()
|
||
if (errors?.length) {
|
||
Message.warning('请修正表单中标红的错误项')
|
||
return false
|
||
}
|
||
|
||
configSaving.value = true
|
||
try {
|
||
const menuData = drafts.value?.menu
|
||
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 !== originals.value?.menu?.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_name(默认语言,直接存菜单表)
|
||
|
||
// 菜单编码由用户填写,但保存时统一去除首尾空格,作为稳定标识使用。
|
||
const menuCode = String(menuData.b_code || '').trim()
|
||
menuData.b_code = menuCode
|
||
|
||
// 受管 i18n 键:菜单统一 menu. 前缀
|
||
const menuI18nKey = `menu.${menuCode}`
|
||
menuData.b_i18n = menuI18nKey
|
||
|
||
// 目录是纯导航容器:可见性由子菜单决定,自身不生成菜单访问权限点。
|
||
// 页面类型菜单的访问权限统一由菜单编码派生。
|
||
const menuIsDirectory = menuData.b_menu_type === 'directory'
|
||
const powerCode = menuCode ? `menu.${menuCode}` : ''
|
||
|
||
const req = { table: 's_menu', key_field: 'b_id' }
|
||
if (isNew) {
|
||
req.inserts = [menuData]
|
||
} else {
|
||
req.updates = [menuData]
|
||
}
|
||
const reqs = [req]
|
||
|
||
// 多语言:先删后插(同表请求内按 del→upd→ins 顺序执行)
|
||
const i18nRows = []
|
||
for (const lang of nonDefaultLanguages.value) {
|
||
const value = (i18nValues.value[lang.code] || '').trim()
|
||
if (!value) continue
|
||
i18nRows.push({
|
||
b_key: menuI18nKey,
|
||
b_locale: lang.code,
|
||
b_value: value,
|
||
b_module_id: menuId,
|
||
b_canuse: 1,
|
||
})
|
||
}
|
||
const hasExistingI18n = !isNew || i18nRows.length > 0
|
||
if (hasExistingI18n || i18nRows.length) {
|
||
const i18nReq = { table: 's_i18n', key_field: 'b_id' }
|
||
if (hasExistingI18n) i18nReq.deletes = [{ b_module_id: menuId }]
|
||
if (i18nRows.length) i18nReq.inserts = i18nRows
|
||
reqs.push(i18nReq)
|
||
}
|
||
|
||
// 目录是纯导航容器:自身不需要菜单访问权限与按钮权限点。
|
||
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
|
||
// - 访问权限(s_power.b_power_type='menu',按菜单编码派生的权限字符关联)
|
||
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
|
||
if (menuIsDirectory) {
|
||
const previousCode = String(originals.value?.menu?.b_code || '').trim()
|
||
const prevPowerCode = previousCode ? `menu.${previousCode}` : ''
|
||
if (prevPowerCode) {
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
deletes: [{ b_power_type: 'menu', b_resource: prevPowerCode }],
|
||
})
|
||
}
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
|
||
})
|
||
}
|
||
|
||
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
|
||
if (powerCode) {
|
||
const previousCode = String(originals.value?.menu?.b_code || '').trim()
|
||
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
|
||
if (previousPowerCode && previousPowerCode !== powerCode) {
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
deletes: [{ b_power_type: 'menu', b_resource: previousPowerCode }],
|
||
})
|
||
}
|
||
// 查是否有已存在的访问权限(按 b_resource = 菜单权限编码)
|
||
const accessPowerRes = menuData.b_id
|
||
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${powerCode}'`)
|
||
: { data: [] }
|
||
const existingAccess = accessPowerRes.data?.[0]
|
||
if (existingAccess) {
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
updates: [
|
||
{
|
||
b_id: existingAccess.b_id,
|
||
b_power_type: 'menu',
|
||
b_code: powerCode,
|
||
b_name: menuData.b_name,
|
||
b_resource: powerCode,
|
||
b_operation: 'access',
|
||
b_module_id: null,
|
||
b_field_id: null,
|
||
b_canuse: menuData.b_canuse ?? 1,
|
||
},
|
||
],
|
||
})
|
||
} else {
|
||
const accessId = String((await nextIdApi()).data)
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
inserts: [
|
||
{
|
||
b_id: accessId,
|
||
b_power_type: 'menu',
|
||
b_code: powerCode,
|
||
b_name: menuData.b_name,
|
||
b_resource: powerCode,
|
||
b_operation: 'access',
|
||
b_module_id: null,
|
||
b_field_id: null,
|
||
b_canuse: menuData.b_canuse ?? 1,
|
||
},
|
||
],
|
||
})
|
||
}
|
||
}
|
||
|
||
// 操作/按钮权限点(s_power.b_power_type='action',diff 保存)
|
||
// 目录是导航容器,不保存操作/按钮权限点
|
||
if (!menuIsDirectory && powers.value.length > 0) {
|
||
const baselineIds = new Set()
|
||
try {
|
||
for (const r of JSON.parse(powersBaseline.value)) {
|
||
if (r.b_id) baselineIds.add(String(r.b_id))
|
||
}
|
||
} catch {
|
||
/* 空基线 */
|
||
}
|
||
const currentIds = new Set(powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)))
|
||
// 被删除的行
|
||
for (const id of baselineIds) {
|
||
if (!currentIds.has(id)) {
|
||
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
|
||
}
|
||
}
|
||
// 新增/更新的行
|
||
for (const row of powers.value) {
|
||
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
|
||
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
|
||
if (row.b_id && baselineIds.has(String(row.b_id))) {
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
updates: [
|
||
{
|
||
b_id: row.b_id,
|
||
b_power_type: 'action',
|
||
b_code: row.b_code,
|
||
b_name: row.b_name,
|
||
b_resource: row.b_code,
|
||
b_operation: 'execute',
|
||
b_module_id: menuId,
|
||
b_field_id: null,
|
||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||
},
|
||
],
|
||
})
|
||
} else {
|
||
// 新增行需要分配主键
|
||
const newId = String((await nextIdApi()).data)
|
||
reqs.push({
|
||
table: 's_power',
|
||
key_field: 'b_id',
|
||
inserts: [
|
||
{
|
||
b_id: newId,
|
||
b_power_type: 'action',
|
||
b_code: row.b_code,
|
||
b_name: row.b_name,
|
||
b_resource: row.b_code,
|
||
b_operation: 'execute',
|
||
b_module_id: menuId,
|
||
b_field_id: null,
|
||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||
},
|
||
],
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
await saveObjectApi(reqs)
|
||
|
||
unsavedMenuIds.delete(menuId)
|
||
// Update tree node(类型变化时 isLeaf / 图标需重新标记)
|
||
const node = findTreeNode(menuTreeData.value, menuId)
|
||
if (node) {
|
||
Object.assign(node, cloneData(menuData))
|
||
delete node.isLeaf
|
||
delete node.icon
|
||
markDirectoryNonLeaf([node])
|
||
}
|
||
// Reload
|
||
await loadMenuConfig(menuId)
|
||
Message.success('保存成功')
|
||
return true
|
||
} catch (e) {
|
||
Message.error('保存失败:' + (e?.message || e))
|
||
return false
|
||
} finally {
|
||
configSaving.value = false
|
||
}
|
||
}
|
||
|
||
/* ================= 表单字段更新 ================= */
|
||
function setField(field, value) {
|
||
if (!drafts.value?.menu) return
|
||
drafts.value.menu[field] = value === '' || value == null ? null : value
|
||
}
|
||
|
||
/** 设置非默认语言的多语言名称 */
|
||
function setI18nValue(locale, value) {
|
||
i18nValues.value = { ...i18nValues.value, [locale]: value }
|
||
}
|
||
|
||
/** 多语言弹窗值对象(默认语言=菜单名称,其余=各语言) */
|
||
const i18nModalModel = computed(() => {
|
||
const menu = drafts.value?.menu
|
||
const values = { [defaultLocale.value]: menu?.b_name || '' }
|
||
for (const lang of nonDefaultLanguages.value)
|
||
values[lang.code] = i18nValues.value[lang.code] || ''
|
||
return values
|
||
})
|
||
|
||
/** 多语言弹窗确定时写回草稿与 i18nValues */
|
||
function onI18nModalChange(values) {
|
||
if (drafts.value?.menu) setField('b_name', values[defaultLocale.value] ?? '')
|
||
for (const lang of nonDefaultLanguages.value) setI18nValue(lang.code, values[lang.code] || '')
|
||
}
|
||
|
||
function onMenuTypeChange(v) {
|
||
if (!drafts.value?.menu) return
|
||
if (menuTypeLocked.value) return
|
||
drafts.value.menu.b_menu_type = v
|
||
// 目录和外链不绑定业务模块;页面同时使用路由和模块。
|
||
if (v === 'directory') {
|
||
drafts.value.menu.b_route = null
|
||
drafts.value.menu.b_module_id = null
|
||
} else if (v === 'external') {
|
||
drafts.value.menu.b_module_id = null
|
||
}
|
||
}
|
||
|
||
/* ================= 树节点启用/停用 ================= */
|
||
async function onToggleEnable(node, checked) {
|
||
const id = String(node.b_id)
|
||
const newVal = checked ? 1 : 0
|
||
if (Number(node.b_canuse) === newVal) return
|
||
// 更新树节点
|
||
node.b_canuse = newVal
|
||
// 若当前编辑该节点,同步草稿
|
||
if (drafts.value?.menu && String(drafts.value.menu.b_id) === id) {
|
||
drafts.value.menu.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) {
|
||
node.b_canuse = newVal === 1 ? 0 : 1
|
||
if (drafts.value?.menu && String(drafts.value.menu.b_id) === id) {
|
||
drafts.value.menu.b_canuse = node.b_canuse
|
||
}
|
||
Message.error('操作失败:' + (e?.message || e))
|
||
}
|
||
}
|
||
|
||
/* ================= 权限点(访问 + 操作/按钮) ================= */
|
||
/** 操作/按钮权限点草稿(b_power_type 统一存 action,按钮以 b_name 区分) */
|
||
const powers = ref([])
|
||
/** 权限点基线快照(JSON 字符串),用于未保存判断 */
|
||
const powersBaseline = ref('')
|
||
const powersLoading = ref(false)
|
||
|
||
/** 权限点表格列配置 */
|
||
const powerColumns = shallowRef([
|
||
{
|
||
dataIndex: 'b_code',
|
||
title: '权限编码',
|
||
width: 150,
|
||
editor: { type: 'input' },
|
||
},
|
||
{
|
||
dataIndex: 'b_name',
|
||
title: '权限名称',
|
||
width: 180,
|
||
editor: { type: 'input' },
|
||
},
|
||
{
|
||
dataIndex: 'b_canuse',
|
||
title: '启用',
|
||
align: 'center',
|
||
width: 70,
|
||
editor: { type: 'checkbox' },
|
||
},
|
||
{
|
||
dataIndex: 'actions',
|
||
title: '操作',
|
||
align: 'center',
|
||
width: 70,
|
||
actions: [
|
||
{
|
||
type: 'delete',
|
||
label: '删除',
|
||
onClick: (row) => removePowerRow(row),
|
||
},
|
||
],
|
||
},
|
||
])
|
||
|
||
/** 权限点表格引用与选中行 */
|
||
const powerTableRef = ref(null)
|
||
const selectedPowerKeys = ref([])
|
||
const rowSelection = computed(() => ({
|
||
selectedRowKeys: selectedPowerKeys.value,
|
||
onChange: (keys) => {
|
||
selectedPowerKeys.value = keys
|
||
},
|
||
}))
|
||
|
||
/** 新增操作/按钮权限点 */
|
||
async function addPowerRow() {
|
||
if (powersLoading.value) return
|
||
const newId = createTempId()
|
||
const newRow = {
|
||
b_id: newId,
|
||
b_code: '',
|
||
b_name: '',
|
||
b_power_type: 'action',
|
||
b_canuse: 1,
|
||
}
|
||
powers.value = [...powers.value, newRow]
|
||
selectedPowerKeys.value = []
|
||
await nextTick()
|
||
powerTableRef.value?.editRow?.(String(newId), 'b_code')
|
||
}
|
||
|
||
/** 删除某权限点 */
|
||
function removePowerRow(row) {
|
||
powers.value = powers.value.filter((r) => String(r.b_id) !== String(row.b_id))
|
||
selectedPowerKeys.value = selectedPowerKeys.value.filter((k) => String(k) !== String(row.b_id))
|
||
}
|
||
|
||
/** 批量删除权限点 */
|
||
function batchDeletePowers() {
|
||
const keys = selectedPowerKeys.value
|
||
if (!keys.length) return
|
||
const delSet = new Set(keys.map(String))
|
||
powers.value = powers.value.filter((r) => !delSet.has(String(r.b_id)))
|
||
selectedPowerKeys.value = []
|
||
Message.success(`已删除 ${keys.length} 条权限`)
|
||
}
|
||
|
||
/* ================= 权限来源库(全库聚合,快速导入) ================= */
|
||
/** 全库去重的操作权限(s_power.b_power_type='action',按编码聚合) */
|
||
const powerLibrary = ref([])
|
||
const powerLibraryLoading = ref(false)
|
||
/** 来源库勾选的权限编码 */
|
||
const selectedLibCodes = ref([])
|
||
/** 来源库表格引用与行选择 */
|
||
const libTableRef = ref(null)
|
||
|
||
const powerLibraryColumns = shallowRef([
|
||
// 权限库按容器自然分配列宽,不提供固定宽度,也不启用列宽拖拽
|
||
{ dataIndex: 'b_code', title: '权限编码' },
|
||
{ dataIndex: 'b_name', title: '权限名称' },
|
||
])
|
||
|
||
const libRowSelection = computed(() => ({
|
||
selectedRowKeys: selectedLibCodes.value,
|
||
onChange: (keys) => {
|
||
selectedLibCodes.value = keys
|
||
},
|
||
}))
|
||
|
||
/** 聚合全库出现的操作权限(编码/名称,去重) */
|
||
async function loadPowerLibrary() {
|
||
powerLibraryLoading.value = true
|
||
try {
|
||
// 参考旧系统 module-powervalue:把全库权限按编码聚合为"来源库"
|
||
const res = await loadDataBySqlApi(
|
||
`SELECT b_code, MAX(b_name) AS b_name FROM s_power
|
||
WHERE b_power_type = 'action' AND b_code IS NOT NULL AND b_code <> ''
|
||
GROUP BY b_code ORDER BY b_code`,
|
||
)
|
||
powerLibrary.value = (res.data || []).map((row) => ({
|
||
b_code: row.b_code,
|
||
b_name: row.b_name,
|
||
}))
|
||
selectedLibCodes.value = []
|
||
} catch (e) {
|
||
Message.error('加载权限库失败:' + (e?.message || e))
|
||
powerLibrary.value = []
|
||
} finally {
|
||
powerLibraryLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 把来源库勾选的权限导入当前菜单(跳过已存在编码) */
|
||
function importSelectedPowers() {
|
||
const codes = selectedLibCodes.value
|
||
if (!codes.length) {
|
||
Message.warning('请先在权限库中勾选要导入的权限')
|
||
return
|
||
}
|
||
const existing = new Set(powers.value.map((r) => String(r.b_code || '').trim()))
|
||
const added = []
|
||
for (const code of codes) {
|
||
if (existing.has(String(code).trim())) continue
|
||
const lib = powerLibrary.value.find((r) => String(r.b_code) === String(code))
|
||
added.push({
|
||
b_id: createTempId(),
|
||
b_code: lib?.b_code || code,
|
||
b_name: lib?.b_name || '',
|
||
b_power_type: 'action',
|
||
b_canuse: 1,
|
||
})
|
||
existing.add(String(code).trim())
|
||
}
|
||
if (!added.length) {
|
||
Message.warning('勾选的权限均已存在,无需导入')
|
||
return
|
||
}
|
||
powers.value = [...powers.value, ...added]
|
||
selectedLibCodes.value = []
|
||
Message.success(`已导入 ${added.length} 条权限`)
|
||
}
|
||
|
||
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
|
||
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', 'query')",
|
||
'b_xh ASC, b_id ASC',
|
||
)
|
||
moduleOptions.value = (res.data || []).map((m) => ({
|
||
label: `[${m.b_code}] ${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 saveMenu(),
|
||
},
|
||
])
|
||
|
||
/* ================= 确认弹窗 ================= */
|
||
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()
|
||
loadPowerLibrary()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mm-page">
|
||
<div class="mm-body">
|
||
<Splitter class="mm-body-splitter">
|
||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||
<aside class="mm-tree-panel">
|
||
<div class="mm-tree-header">
|
||
<Input v-model="menuKeyword" allow-clear class="mm-tree-search">
|
||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||
</Input>
|
||
<div class="mm-tree-header__actions">
|
||
<Button
|
||
type="ghost"
|
||
class="mm-tree-header__icon-btn"
|
||
title="刷新菜单树"
|
||
aria-label="刷新菜单树"
|
||
@click="loadMenuTree"
|
||
>
|
||
<RefreshCw :size="14" />
|
||
</Button>
|
||
<Dropdown :items="toolbarAddOptions" @click="onToolbarAdd">
|
||
<Button
|
||
type="ghost"
|
||
class="mm-tree-header__icon-btn"
|
||
title="新增菜单"
|
||
aria-label="新增菜单"
|
||
>
|
||
<Plus :size="14" />
|
||
</Button>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
<div class="mm-tree-panel__scroll">
|
||
<div v-if="menuTreeLoading" class="mm-tree-empty">加载中...</div>
|
||
<Tree
|
||
v-else
|
||
:tree-data="filteredMenuTree"
|
||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||
:expanded-keys="menuExpandedKeys"
|
||
@update:expanded-keys="(keys) => (menuExpandedKeys = keys)"
|
||
:selected-keys="menuSelectedId ? [menuSelectedId] : []"
|
||
:context-menu="buildMenuContextMenu"
|
||
:draggable="!menuKeyword.trim()"
|
||
@select="onMenuSelect"
|
||
@context-menu-click="onMenuContextMenuClick"
|
||
@drop="onMenuDrop"
|
||
>
|
||
<template #title="{ node }">
|
||
<span class="mm-tree-node">
|
||
<span
|
||
class="mm-tree-node__name"
|
||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||
>
|
||
{{ node.b_name || node.b_code || '(未命名)' }}
|
||
</span>
|
||
<Checkbox
|
||
class="mm-tree-node__enable"
|
||
:model-value="Number(node.b_canuse) === 1"
|
||
@click.stop
|
||
@change="(checked) => onToggleEnable(node, checked)"
|
||
/>
|
||
</span>
|
||
</template>
|
||
</Tree>
|
||
</div>
|
||
</aside>
|
||
</SplitterPanel>
|
||
|
||
<SplitterPanel>
|
||
<main class="mm-main">
|
||
<template v-if="selectedMenu">
|
||
<!-- 顶部操作栏 -->
|
||
<div class="mm-main-toolbar">
|
||
<Button
|
||
type="primary"
|
||
:loading="configSaving"
|
||
:disabled="configLoading || !drafts"
|
||
@click="saveMenu"
|
||
>
|
||
<Save :size="14" /> 保存
|
||
</Button>
|
||
<Button
|
||
type="outline"
|
||
:disabled="busy || !drafts"
|
||
@click="loadMenuConfig(menuSelectedId)"
|
||
>
|
||
<RefreshCw :size="14" /> 重载
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 主体:三块 Splitter 分割 -->
|
||
<div v-if="drafts" 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>
|
||
<Form
|
||
ref="formRef"
|
||
:model="drafts.menu"
|
||
:rules="{
|
||
b_code: [{ required: true, message: '请填写菜单编码' }],
|
||
b_name: [{ required: true, message: '请填写菜单名称' }],
|
||
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
|
||
b_route:
|
||
isPage || isExternal
|
||
? [{ required: true, message: '请填写路由地址' }]
|
||
: [],
|
||
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
|
||
// 留空时保存为空,后续需要再补选。
|
||
b_module_id: [],
|
||
}"
|
||
layout="vertical"
|
||
:gap="20"
|
||
>
|
||
<FormItem label="菜单名称" name="b_name" required>
|
||
<div class="mm-name-field">
|
||
<Input
|
||
class="mm-name-field__input"
|
||
:model-value="drafts.menu.b_name"
|
||
@update:model-value="(v) => setField('b_name', v)"
|
||
/>
|
||
<Button
|
||
type="ghost"
|
||
class="mm-name-field__i18n"
|
||
title="多语言名称"
|
||
@click="i18nModalVisible = true"
|
||
>
|
||
<Languages :size="16" />
|
||
</Button>
|
||
</div>
|
||
</FormItem>
|
||
<FormItem label="菜单编码" name="b_code" required>
|
||
<Input
|
||
:model-value="drafts.menu.b_code"
|
||
@update:model-value="(v) => setField('b_code', v)"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="菜单类型" name="b_menu_type" required>
|
||
<Select
|
||
:model-value="drafts.menu.b_menu_type"
|
||
:options="menuTypeOptions"
|
||
:disabled="menuTypeLocked || busy"
|
||
@update:model-value="onMenuTypeChange"
|
||
/>
|
||
</FormItem>
|
||
<!-- page/external: 路由地址 -->
|
||
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
||
<Input
|
||
:model-value="drafts.menu.b_route"
|
||
@update:model-value="(v) => setField('b_route', v)"
|
||
/>
|
||
</FormItem>
|
||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||
<Input
|
||
:model-value="drafts.menu.b_route"
|
||
@update:model-value="(v) => setField('b_route', v)"
|
||
/>
|
||
</FormItem>
|
||
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
|
||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
|
||
<Select
|
||
:model-value="drafts.menu.b_module_id"
|
||
:options="moduleOptions"
|
||
clearable
|
||
@update:model-value="(v) => setField('b_module_id', v)"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="图标" name="b_icon">
|
||
<IconPicker
|
||
:model-value="drafts.menu.b_icon"
|
||
@update:model-value="(v) => setField('b_icon', v)"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="排序号" name="b_xh">
|
||
<Input
|
||
:model-value="drafts.menu.b_xh"
|
||
type="number"
|
||
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
|
||
/>
|
||
</FormItem>
|
||
</Form>
|
||
</div>
|
||
</SplitterPanel>
|
||
|
||
<!-- 权限面板:仅页面/外链(目录无权限) -->
|
||
<SplitterPanel v-if="!isDirectory">
|
||
<div class="mm-power-card">
|
||
<div class="mm-power-card__head">
|
||
<div class="mm-pane__title">当前菜单权限</div>
|
||
<div class="mm-power-toolbar">
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="powersLoading"
|
||
@click="addPowerRow"
|
||
>
|
||
<Plus :size="12" /> 新增
|
||
</Button>
|
||
<Button
|
||
type="danger"
|
||
size="small"
|
||
:disabled="powersLoading || !selectedPowerKeys.length"
|
||
@click="batchDeletePowers"
|
||
>
|
||
<Trash2 :size="12" /> 删除{{
|
||
selectedPowerKeys.length ? `(${selectedPowerKeys.length})` : ''
|
||
}}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div v-if="powersLoading" class="mm-power-loading">加载中...</div>
|
||
<div v-else class="mm-power-table-wrap">
|
||
<FmsTable
|
||
ref="powerTableRef"
|
||
v-model:columns="powerColumns"
|
||
:data-source="powers"
|
||
:editable="true"
|
||
row-key="b_id"
|
||
:row-selection="rowSelection"
|
||
:col-resizable="true"
|
||
@change="() => {}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</SplitterPanel>
|
||
|
||
<!-- 权限来源库:全库聚合,快速导入 -->
|
||
<SplitterPanel v-if="!isDirectory" :default-size="260">
|
||
<div class="mm-power-card">
|
||
<div class="mm-power-card__head">
|
||
<div class="mm-pane__title">权限库</div>
|
||
<div class="mm-power-toolbar">
|
||
<Button
|
||
type="outline"
|
||
size="small"
|
||
:loading="powerLibraryLoading"
|
||
@click="loadPowerLibrary"
|
||
>
|
||
<RefreshCw :size="12" /> 刷新
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="!selectedLibCodes.length"
|
||
@click="importSelectedPowers"
|
||
>
|
||
<Plus :size="12" /> 导入选中{{
|
||
selectedLibCodes.length ? `(${selectedLibCodes.length})` : ''
|
||
}}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div v-if="powerLibraryLoading" class="mm-power-loading">加载中...</div>
|
||
<div v-else class="mm-lib-table-wrap">
|
||
<FmsTable
|
||
ref="libTableRef"
|
||
v-model:columns="powerLibraryColumns"
|
||
:data-source="powerLibrary"
|
||
:seq="false"
|
||
row-key="b_code"
|
||
:row-selection="libRowSelection"
|
||
:auto-fit-width="true"
|
||
@change="() => {}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</SplitterPanel>
|
||
</Splitter>
|
||
</div>
|
||
<div v-else-if="configLoading" class="mm-config-state">
|
||
<span class="mm-config-state__spinner"></span>
|
||
<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>
|
||
/* ============================================================
|
||
* 菜单管理页面样式
|
||
* 与 module-management 保持视觉统一
|
||
* ============================================================ */
|
||
|
||
/* ---- 页面根 ---- */
|
||
.mm-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ---- 主体左右布局 ---- */
|
||
.mm-body {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ---- Splitter 面板 ----
|
||
* 面板左右留 3px(白卡之间灰缝 3 + 分隔条 6 + 3 = 12px)。
|
||
* 常显线平时透明,hover / 拖拽时显示整条 100% 高度的细线 + 手柄提示 */
|
||
.mm-body-splitter :deep(> .fms-splitter-panel) {
|
||
overflow: hidden;
|
||
padding: 0 3px;
|
||
}
|
||
|
||
.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-tree-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
padding: 8px;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
box-shadow: var(--fms-shadow);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mm-tree-panel__scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.mm-tree-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0;
|
||
}
|
||
|
||
.mm-tree-search {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.mm-tree-search__icon {
|
||
color: var(--fms-text-secondary);
|
||
}
|
||
|
||
.mm-tree-header__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.mm-tree-header__icon-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
border: 1px solid var(--fms-border);
|
||
border-radius: var(--fms-control-radius);
|
||
background: var(--fms-card);
|
||
color: var(--fms-text-secondary);
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 150ms ease,
|
||
background 150ms ease,
|
||
color 150ms ease;
|
||
|
||
&:hover:not(:disabled) {
|
||
background: var(--fms-secondary);
|
||
color: var(--fms-text);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
/* ---- 树节点 ---- */
|
||
.mm-tree-node {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
padding: 2px 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.mm-tree-node__name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
|
||
&.is-disabled {
|
||
color: var(--fms-text-disabled, #9ca3af);
|
||
}
|
||
}
|
||
|
||
/* 树节点启用勾选框:靠右、不随文字缩放 */
|
||
.mm-tree-node__enable {
|
||
flex-shrink: 0;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.mm-tree-empty {
|
||
padding: 20px 0;
|
||
text-align: center;
|
||
color: var(--fms-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* 菜单名称 + 多语言图标按钮 */
|
||
.mm-name-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.mm-name-field__input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.mm-name-field__i18n {
|
||
flex-shrink: 0;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
/* ---- 右侧主区 ---- */
|
||
.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;
|
||
}
|
||
}
|
||
|
||
/* 右栏:权限卡片 */
|
||
.mm-power-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
padding: 8px 10px;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
box-shadow: var(--fms-shadow);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mm-power-card__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
|
||
.mm-pane__title {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
/* 权限点 FmsTable 容器(撑满卡片剩余高度) */
|
||
.mm-power-table-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 来源库表格容器(撑满卡片剩余高度) */
|
||
.mm-lib-table-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 权限点列表工具条 */
|
||
.mm-power-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.mm-power-loading {
|
||
padding: 20px 0;
|
||
text-align: center;
|
||
color: var(--fms-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* 区块标题 */
|
||
.mm-pane__title {
|
||
margin-bottom: 10px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--fms-text);
|
||
}
|
||
|
||
/* 各配置区块间距 */
|
||
.mm-config-pane + .mm-config-pane {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.mm-config-state__spinner {
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 2px solid var(--fms-border);
|
||
border-top-color: var(--fms-primary);
|
||
border-radius: 50%;
|
||
animation: mm-spin 0.6s linear infinite;
|
||
}
|
||
|
||
@keyframes mm-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|