Files
workspace/code/fms/fms-vue/src/views/module/menu-management/index.vue
T
2026-09-03 17:30:39 +08:00

1803 lines
57 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(外链)。
*/
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>