20260825224435

This commit is contained in:
oneao committed 2026-08-25 22:44:36 +08:00
1 parent a509f93f34
commit 9957757b5d
146 files changed
+148 -682

No files matched your search

@@ -1,8 +1,7 @@
<script setup>
/**
* 模块管理:模块配置 | 菜单配置(两分区,自定义导航切换,不使用 Tabs 组件样式)。
* 模块管理:模块配置(单分区)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。
* 菜单配置:左侧菜单树 + 右侧面板(基础/权限/多语言)。
* 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。
*/
import { computed, nextTick, ref, watch } from 'vue'
@@ -10,6 +9,7 @@ import { useHotkeys } from '@/composables/useHotkeys'
import {
Plus,
FolderPlus,
Folder,
Database,
Search,
Trash2,
@@ -23,10 +23,6 @@ import {
Globe,
Info,
FolderTree,
Menu,
Folder,
FileText,
ExternalLink,
} from '@lucide/vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
@@ -37,7 +33,6 @@ import Dropdown from '@/components/ui/dropdown/dropdown.vue'
import Splitter from '@/components/ui/splitter/splitter.vue'
import SplitterPanel from '@/components/ui/splitter/panel.vue'
import Tabs from '@/components/ui/tabs/tabs.vue'
import TabPane from '@/components/ui/tabs/tab-pane.vue'
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api'
import { buildTree, sortTree } from '@/utils/tree'
import { createTempId } from '@/utils/tempId'
@@ -58,10 +53,6 @@ import ModuleQueryConfigPanel from './components/ModuleQueryConfigPanel.vue'
import ModuleAutoCodePanel from './components/ModuleAutoCodePanel.vue'
import ModulePowersPanel from './components/ModulePowersPanel.vue'
import ModuleI18nPanel from './components/ModuleI18nPanel.vue'
import MenuDetailPanel from './components/MenuDetailPanel.vue'
/* ================= 顶部切换(Tabs 默认样式) ================= */
const activeZone = ref('module')
/* ================= 快捷键 ================= */
// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys)
@@ -70,10 +61,7 @@ useHotkeys([
key: 's',
mod: true,
ignoreWhenTyping: false, // 输入框内也能保存
handler: () => {
if (activeZone.value === 'menu') void saveMenu()
else void saveModule()
},
handler: () => void saveModule(),
},
])
@@ -158,9 +146,17 @@ async function loadModuleTree() {
// 分类节点标记为非叶子:即使暂无子模块也显示折叠箭头(提示可挂子模块)。
// Tree 组件依据 node.isLeaf 判定叶子;isLeaf === false 强制非叶子。
// 同时按模块类型附加 lucide 图标(icon 字段),供 Tree 组件渲染在标题前。
const moduleTypeIconMap = {
category: Folder,
data: Database,
query: Search,
}
function markCategoryNonLeaf(nodes) {
for (const node of nodes) {
if (node.b_module_type === 'category') node.isLeaf = false
const icon = moduleTypeIconMap[node.b_module_type]
if (icon) node.icon = icon
if (node.children) markCategoryNonLeaf(node.children)
}
return nodes
@@ -345,8 +341,8 @@ function onModuleSelect(keys) {
moduleSelectedId.value = nextId
}
/* ================= 切换对象未保存保护(保存并切换 / 放弃修改 / 取消) ================= */
const switchConfirm = ref(null) // { type: 'module' | 'menu', pendingId, name }
/* ================= 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) ================= */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
@@ -357,8 +353,7 @@ function onSwitchCancel() {
async function onSwitchDiscard() {
const state = switchConfirm.value
if (!state) return
if (state.type === 'module') moduleSelectedId.value = state.pendingId
else menuSelectedId.value = state.pendingId
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
}
@@ -367,10 +362,9 @@ async function onSwitchSave() {
if (!state || switchSaving.value) return
switchSaving.value = true
try {
const ok = state.type === 'module' ? await saveModule() : await saveMenu()
if (!ok) return // 保存失败(校验不通过等):保持原对象,不切换
if (state.type === 'module') moduleSelectedId.value = state.pendingId
else menuSelectedId.value = state.pendingId
const ok = await saveModule()
if (!ok) return
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
} finally {
switchSaving.value = false
@@ -433,6 +427,9 @@ async function addModule(parentId, moduleType) {
if (ancestors.length) {
moduleExpandedKeys.value = [...new Set([...moduleExpandedKeys.value, ...ancestors])]
}
// 单独设置树节点图标(不进入 drafts,避免污染保存数据)
const treeNode = findModuleNode(moduleTreeData.value, newId)
if (treeNode) treeNode.icon = moduleTypeIconMap[moduleType] || null
unsavedModuleIds.add(newId)
skipModuleLoad = true
moduleSelectedId.value = newId
@@ -622,20 +619,7 @@ function onModuleI18nSaved() {
i18nPanelRef.value?.refreshKey?.()
}
/* ================= 树节点元信息(类型图标 / 面包屑 / 副标题) ================= */
const moduleTypeLabelMap = { category: '分类', data: '数据模块', query: '查询模块' }
const menuTypeLabelMap = { directory: '目录', page: '页面', data: '数据页面', external: '外链' }
const moduleTypeIcon = computed(() => {
const icons = { category: Folder, data: Database, query: Search }
return icons[moduleType.value] || Folder
})
const menuTypeIcon = computed(() => {
const icons = { directory: Folder, page: FileText, data: Table2, external: ExternalLink }
return icons[selectedMenu.value?.b_menu_type] || Folder
})
/* ================= 树节点元信息(面包屑) ================= */
// 从根到当前节点的名称路径(面包屑用)
function buildBreadcrumb(node, finder) {
const names = []
@@ -656,12 +640,6 @@ const moduleBreadcrumb = computed(() =>
: [],
)
const menuBreadcrumb = computed(() =>
selectedMenu.value
? buildBreadcrumb(selectedMenu.value, (id) => findMenuNode(menuTreeData.value, id))
: [],
)
/* ================= 左树顶部工具栏:新建 / 刷新 ================= */
const moduleNewItems = [
{ key: 'category', label: '新增分类', icon: FolderPlus },
@@ -675,23 +653,6 @@ function onModuleNewClick({ key }) {
void addModule(parentId, key)
}
// 菜单类型(顶部新建下拉 + 右键菜单共用)
const menuNewItems = [
{ key: 'directory', label: '新增目录', icon: Folder },
{ key: 'page', label: '新增页面', icon: FileText },
{ key: 'data', label: '新增数据页面', icon: Table2 },
{ key: 'external', label: '新增外链', icon: ExternalLink },
]
function onMenuNewClick({ key }) {
const parentId = menuSelectedId.value ? String(menuSelectedId.value) : null
void addMenu(parentId, key)
}
function menuTypeLabel(type) {
return menuTypeLabelMap[type] || ''
}
/* ================= 右侧分组配置导航 ================= */
// 配置导航分组:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。
// 每个导航项唯一激活(activeSection);「分组管理」点击时同时打开分组管理弹窗。
@@ -985,422 +946,29 @@ async function saveModule() {
}
}
/* ================= 菜单 ================= */
const menuTreeData = ref([])
const menuTreeLoading = ref(false)
const menuKeyword = ref('')
const menuSelectedId = ref(null)
const menuExpandedKeys = ref([])
const menuOriginals = ref(null)
const menuDrafts = ref(null)
const menuSaving = ref(false)
const menuDetailRef = ref(null)
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(menuKeyword, (kw) => {
menuExpandedKeys.value = kw.trim() ? collectAllKeys(menuTreeData.value) : []
})
const unsavedMenuIds = new Set()
// 菜单树右键菜单:按类型新增(目录/页面/数据页面/外链),节点上右键可新增子菜单或删除。
// 未保存(草稿)节点不可作为父级
function buildMenuContextMenu(node) {
const isUnsaved = node != null && unsavedMenuIds.has(String(node.b_id))
const items = menuNewItems.map((item) => ({ ...item, 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 === 'delete') void deleteMenu(parentId)
else void addMenu(parentId, item.key)
}
async function loadMenuTree() {
menuTreeLoading.value = true
try {
const res = await loadDataApi('s_menu', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
menuTreeData.value = markMenuNonLeaf(sortTree(buildTree(res.data || [])))
unsavedMenuIds.clear()
} catch (e) {
Message.error('加载菜单树失败:' + (e?.message || e))
} finally {
menuTreeLoading.value = false
}
}
// 目录节点标记为非叶子:即使暂无子菜单也显示折叠箭头(提示可挂子菜单)
function markMenuNonLeaf(nodes) {
for (const node of nodes) {
if (node.b_menu_type === 'directory') node.isLeaf = false
if (node.children) markMenuNonLeaf(node.children)
}
return nodes
}
const filteredMenuTree = computed(() => filterTree(menuTreeData.value, menuKeyword.value))
function findMenuNode(nodes, id) {
for (const node of nodes) {
if (String(node.b_id) === String(id)) return node
if (node.children) {
const found = findMenuNode(node.children, id)
if (found) return found
}
}
return null
}
const selectedMenu = computed(() =>
menuSelectedId.value ? findMenuNode(menuTreeData.value, menuSelectedId.value) : null,
)
const parentMenuName = computed(() => {
if (!selectedMenu.value?.b_parent_id) return ''
return findMenuNode(menuTreeData.value, selectedMenu.value.b_parent_id)?.b_name || ''
})
// 数据/查询模块选项(供菜单关联模块下拉)
const moduleOptions = computed(() => {
const out = []
const walk = (nodes) => {
for (const node of nodes) {
if (node.b_module_type === 'data' || node.b_module_type === 'query') {
out.push({ label: node.b_name || node.b_code, value: String(node.b_id) })
}
if (node.children) walk(node.children)
}
}
walk(moduleTreeData.value)
return out
})
async function loadMenuConfig(menuId) {
const id = String(menuId)
try {
const responses = await Promise.all([
loadDataApi('s_menu', `b_id = ${id}`),
loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${escapeSql(String(findMenuNode(menuTreeData.value, id)?.b_code || ''))}'`, 'b_xh ASC, b_id ASC'),
])
if (String(menuSelectedId.value) !== id) return
const original = {
menu: responses[0].data?.[0] || null,
powers: responses[1].data || [],
}
menuOriginals.value = original
menuDrafts.value = cloneData(original)
} catch (e) {
Message.error('菜单配置加载失败:' + (e?.message || e))
}
}
function escapeSql(v) {
return String(v).replace(/'/g, "''")
}
let skipMenuLoad = false
// 菜单是否有未保存修改
const hasMenuChanges = computed(() => {
if (!menuDrafts.value || !menuOriginals.value) return false
return JSON.stringify(menuDrafts.value) !== JSON.stringify(menuOriginals.value)
})
watch(menuSelectedId, (id) => {
if (skipMenuLoad) {
skipMenuLoad = false
return
}
if (!id) {
menuOriginals.value = null
menuDrafts.value = null
return
}
void loadMenuConfig(id)
})
function onMenuSelect(keys) {
if (!keys.length) return
const nextId = String(keys[0])
if (nextId === String(menuSelectedId.value)) return
// 有未保存修改时先询问
if (hasMenuChanges.value) {
switchConfirm.value = {
type: 'menu',
pendingId: nextId,
name: selectedMenu.value?.b_name || selectedMenu.value?.b_code || '',
}
return
}
menuSelectedId.value = nextId
}
async function addMenu(parentId, menuType = 'directory') {
const res = await nextIdApi()
const newId = String(res.data)
const newMenu = {
b_id: newId,
b_parent_id: parentId ? String(parentId) : null,
b_code: '',
b_name: '',
b_i18n: null,
b_menu_type: menuType,
b_route: null,
b_module_id: null,
b_power_code: null,
b_icon: null,
b_xh: 10,
b_canuse: 1,
// 目录类型强制非叶子显示折叠箭头;其他类型按有无子节点判定
isLeaf: menuType === 'directory' ? false : undefined,
}
insertModuleNode(menuTreeData.value, newMenu)
// 展开祖先路径,保证新建节点在收起的树中可见
const ancestors = expandAncestors(menuTreeData.value, newId)
if (ancestors.length) {
menuExpandedKeys.value = [...new Set([...menuExpandedKeys.value, ...ancestors])]
}
unsavedMenuIds.add(newId)
skipMenuLoad = true
menuSelectedId.value = newId
await nextTick()
menuDrafts.value = cloneData({ menu: newMenu, powers: [] })
menuOriginals.value = cloneData(menuDrafts.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 deleteMenu(id) {
const menu = id != null ? findMenuNode(menuTreeData.value, id) : selectedMenu.value
if (!menu) return
const menuId = String(menu.b_id)
if (unsavedMenuIds.has(menuId)) {
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
removeModuleNode(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 id = String(menu.b_id)
const code = String(menu.b_code || '')
const reqs = []
const powerRes = await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${escapeSql(code)}'`)
const powers = powerRes.data || []
if (powers.length) {
reqs.push({ table: 's_power', key_field: 'b_id', deletes: powers.map((r) => ({ b_id: r.b_id })) })
}
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: id }] })
await saveObjectApi(reqs)
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
removeModuleNode(menuTreeData.value, id)
menuSelectedId.value = null
Message.success('菜单已删除')
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
}
},
})
}
async function saveMenu() {
if (!selectedMenu.value || menuSaving.value) return false
const menuId = String(selectedMenu.value.b_id)
menuSaving.value = true
try {
const saveReqs = []
const menuData = menuDrafts.value?.menu
if (!menuData) return false
// 字段级校验:错误已显示在对应 FormItem 下方,此处只做汇总与定位
const basicErrors = await menuDetailRef.value?.validate?.()
if (basicErrors?.length) {
Message.warning('请修正表单中标红的错误项')
return false
}
const menuCode = String(menuData.b_code || '').trim()
syncModuleItemI18nKeys(menuDrafts.value.powers, 'menu', menuCode, 'b_code')
menuData.b_i18n = buildModuleI18nKey('menu', menuCode)
const isNew = unsavedMenuIds.has(menuId)
if (menuData && (isNew || JSON.stringify(menuData) !== JSON.stringify(menuOriginals.value?.menu))) {
const req = { table: 's_menu', key_field: 'b_id' }
if (isNew) req.inserts = [menuData]
else req.updates = [menuData]
saveReqs.push(req)
}
// 菜单权限点(s_power, menu 类型)
const current = menuDrafts.value.powers || []
const previous = menuOriginals.value?.powers || []
const changes = diffRows(current, previous)
// 保存前同步 b_resource 为当前菜单编码
for (const row of changes.inserts || []) row.b_resource = menuCode
for (const row of changes.updates || []) row.b_resource = menuCode
const powerReq = tableChange('s_power', changes)
if (hasChanges(powerReq)) {
await allocateTemporaryIds([powerReq.inserts || [], powerReq.updates || []])
saveReqs.push(powerReq)
}
// 多语言
const i18nReq = menuDetailRef.value?.saveI18n?.()
if (i18nReq) {
if (i18nReq.inserts?.length) {
await allocateTemporaryIds([i18nReq.inserts])
menuDetailRef.value?.applyI18nInsertedIds?.(i18nReq.inserts)
}
saveReqs.push(i18nReq)
}
if (saveReqs.length === 0) {
Message.info('没有需要保存的变更')
return true
}
await saveObjectApi(saveReqs)
menuOriginals.value = cloneData(menuDrafts.value)
menuDetailRef.value?.markI18nSaved?.()
unsavedMenuIds.delete(menuId)
const node = findMenuNode(menuTreeData.value, menuId)
if (node) Object.assign(node, cloneData(menuData))
Message.success('保存成功')
return true
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
return false
} finally {
menuSaving.value = false
}
}
function onMenuUpdate(section, newData) {
if (menuDrafts.value) menuDrafts.value[section] = newData
}
/* ================= 菜单:配置导航(与模块配置统一三栏结构) ================= */
const menuActiveSection = ref('basic')
const isDirectoryMenu = computed(() => selectedMenu.value?.b_menu_type === 'directory')
const menuConfigGroups = computed(() => {
const groups = [
{ key: 'overview', label: '概览', items: [{ key: 'basic', label: '基本信息', icon: Info }] },
]
const capabilityItems = []
if (!isDirectoryMenu.value) {
const powersLen = menuDrafts.value?.powers?.length ?? 0
capabilityItems.push({
key: 'powers',
label: '权限配置',
icon: Shield,
badge: powersLen ? `${powersLen} 个权限` : '',
})
}
if (capabilityItems.length) {
groups.push({ key: 'capability', label: '业务能力', items: capabilityItems })
}
groups.push({
key: 'advanced',
label: '高级设置',
items: [{ key: 'i18n', label: '多语言', icon: Globe }],
})
return groups
})
/* 菜单横向胶囊 Tabs 的数据源 */
const menuConfigNavItems = computed(() =>
menuConfigGroups.value.flatMap((group) => group.items.map((item) => ({ ...item }))),
)
// 重载当前菜单:若有未保存修改先确认放弃
function reloadMenu() {
if (!selectedMenu.value || menuSaving.value) return
const name = selectedMenu.value.b_name || selectedMenu.value.b_code
const doReload = () => {
loadMenuConfig(selectedMenu.value.b_id)
}
if (hasMenuChanges.value) {
showConfirm({
title: '重载菜单',
content: `「${name}」有未保存的修改,确定放弃修改并重新加载数据?`,
okText: '重载',
onOk: doReload,
})
} else {
doReload()
}
}
/* ================= 初始化 ================= */
loadModuleTree()
loadMenuTree()
</script>
<template>
<div class="mm-page">
<!-- 顶部两分区切换(Tabs 默认样式) -->
<Tabs v-model="activeZone" class="mm-zone-tabs">
<TabPane tab-key="module">
<template #label><Table2 :size="14" /> 模块配置</template>
</TabPane>
<TabPane tab-key="menu">
<template #label><Menu :size="14" /> 菜单配置</template>
</TabPane>
</Tabs>
<!-- ================ 模块配置 ================ -->
<div v-show="activeZone === 'module'" class="mm-body">
<Splitter class="mm-body-splitter" bar-on-hover>
<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">
<span class="mm-tree-header__title">模块</span>
<div class="mm-tree-header__actions">
<Dropdown :items="moduleNewItems" @click="onModuleNewClick">
<button type="button" class="mm-tree-header__icon-btn" title="新建模块" aria-label="新建模块">
<Plus :size="14" />
</button>
</Dropdown>
<button
type="button"
class="mm-tree-header__icon-btn"
title="刷新模块树"
aria-label="刷新模块树"
:disabled="moduleTreeLoading"
@click="loadModuleTree"
>
<RefreshCw :size="14" />
</button>
</div>
</div>
<Input v-model="moduleKeyword" placeholder="搜索模块" allow-clear>
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<Input v-model="moduleKeyword" placeholder="搜索模块" allow-clear class="mm-tree-search">
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<div class="mm-tree-header__actions">
<Dropdown :items="moduleNewItems" @click="onModuleNewClick">
<button type="button" 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="moduleTreeLoading" class="mm-tree-empty">加载中...</div>
<Tree
@@ -1422,9 +990,6 @@ loadMenuTree()
>
{{ node.b_name || node.b_code }}
</span>
<span v-if="node.b_module_type" class="mm-tree-node__tag">
{{ moduleTypeLabelMap[node.b_module_type] }}
</span>
</span>
</template>
</Tree>
@@ -1612,113 +1177,6 @@ loadMenuTree()
</Splitter>
</div>
<!-- ================ 菜单配置 ================ -->
<div v-show="activeZone === 'menu'" class="mm-body">
<Splitter class="mm-body-splitter" bar-on-hover>
<SplitterPanel :default-size="248" :min="180" :max="420">
<aside class="mm-tree-panel">
<div class="mm-tree-header">
<span class="mm-tree-header__title">菜单</span>
<div class="mm-tree-header__actions">
<Dropdown :items="menuNewItems" @click="onMenuNewClick">
<button type="button" class="mm-tree-header__icon-btn" title="新建菜单" aria-label="新建菜单">
<Plus :size="14" />
</button>
</Dropdown>
<button
type="button"
class="mm-tree-header__icon-btn"
title="刷新菜单树"
aria-label="刷新菜单树"
:disabled="menuTreeLoading"
@click="loadMenuTree"
>
<RefreshCw :size="14" />
</button>
</div>
</div>
<Input v-model="menuKeyword" placeholder="搜索菜单" allow-clear>
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<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"
@select="onMenuSelect"
@context-menu-click="onMenuContextMenuClick"
>
<template #title="{ node }">
<span class="mm-tree-node">
<span class="mm-tree-node__name">{{ node.b_name || node.b_code }}</span>
<span v-if="node.b_menu_type" class="mm-tree-node__tag">
{{ menuTypeLabelMap[node.b_menu_type] }}
</span>
</span>
</template>
</Tree>
</div>
</aside>
</SplitterPanel>
<SplitterPanel>
<main class="mm-main">
<template v-if="selectedMenu">
<Tabs
v-if="menuDrafts"
v-model="menuActiveSection"
type="pill"
nav-only
class="mm-config-nav"
:items="menuConfigNavItems"
aria-label="菜单配置导航"
>
<template #label="{ item }">
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">{{ item.label }}</span>
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
</template>
<template #rightExtra>
<div class="mm-header__actions">
<Button type="outline" :disabled="menuSaving" @click="reloadMenu">
<RefreshCw :size="14" /> 重载
</Button>
<Button type="primary" :loading="menuSaving" @click="saveMenu">
<Save :size="14" /> 保存
</Button>
</div>
</template>
</Tabs>
<div class="mm-body__inner">
<MenuDetailPanel
ref="menuDetailRef"
:menu="menuDrafts?.menu"
:parent-name="parentMenuName"
:module-options="moduleOptions"
:powers="menuDrafts?.powers"
:loading="menuSaving"
:active-section="menuActiveSection"
@update:active-section="menuActiveSection = $event"
@update="(v) => onMenuUpdate('menu', v)"
@update-powers="(v) => onMenuUpdate('powers', v)"
/>
</div>
</template>
<div v-else class="mm-main__empty">
<FolderTree :size="32" />
<span>请选择左侧菜单节点</span>
</div>
</main>
</SplitterPanel>
</Splitter>
</div>
<!-- 通用确认弹窗:自定义 footer 使 okType 生效;确认期间按钮转圈并锁定弹窗 -->
<Modal
v-model:open="confirmVisible"
@@ -1770,16 +1228,6 @@ loadMenuTree()
min-height: 0;
}
/* ---- 顶部两分区切换(Tabs):与下方内容留 8px 间距 ---- */
.mm-zone-tabs {
flex: none;
margin-bottom: 8px;
:deep(.tabs-content) {
padding-top: 0;
}
}
/* ---- 主体左右布局:占据剩余全部高度,由 Splitter 管理左右面板宽度 ---- */
.mm-body {
display: flex;
@@ -1792,31 +1240,24 @@ loadMenuTree()
overflow: hidden;
}
/* ---- 左树顶部 header:标题在左,新增/刷新图标在右。
与右侧配置导航同规格:padding 6px + 内容 28px(图标按钮与 Button 等高)
= 40px,两侧 header 的底边框在同一水平线对齐。
负 margin 抵消面板 8px padding,使 border-bottom 横跨面板全宽。 */
/* ---- 左树顶部 header:搜索框在左,操作在右,无边框无标题 ---- */
.mm-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin: -8px -8px 0;
padding: 6px;
border-bottom: 1px solid var(--fms-border);
padding: 0;
}
.mm-tree-header__title {
font-size: 14px;
font-weight: 600;
line-height: 28px;
color: var(--fms-text);
.mm-tree-search {
flex: 1;
min-width: 0;
}
.mm-tree-header__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.mm-tree-header__icon-btn {
@@ -1826,11 +1267,15 @@ loadMenuTree()
width: 28px;
height: 28px;
padding: 0;
border: none;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: transparent;
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);
@@ -1851,9 +1296,6 @@ loadMenuTree()
height: 100%;
min-height: 0;
padding: 8px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
}
.mm-tree-panel__scroll {
@@ -1891,17 +1333,6 @@ loadMenuTree()
}
}
/* 类型 tag(分类 / 数据 / 查询 等),靠右显示 */
.mm-tree-node__tag {
flex: none;
padding: 0 4px;
border-radius: 3px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 11px;
line-height: 16px;
}
.mm-tree-empty {
padding: 20px 0;
text-align: center;
@@ -1919,9 +1350,6 @@ loadMenuTree()
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
}
.mm-main__empty {