20260825224435
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user