20260825224435
This commit is contained in:
1 parent
a509f93f34
commit
9957757b5d
146 files changed
+148
-682
No files matched your search
@@ -11,6 +11,9 @@
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
/* 默认轻阴影:对齐 shadcn-vue 输入框的 shadow-xs(0 1px 2px),
|
||||
聚焦时与 3px 光环叠加,光环在外、投影在内,无跳动 */
|
||||
box-shadow: var(--fms-shadow);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
@@ -27,7 +30,9 @@
|
||||
|
||||
.input-wrapper.input-focused {
|
||||
border-color: var(--fms-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent),
|
||||
var(--fms-shadow);
|
||||
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -80,7 +85,9 @@
|
||||
|
||||
.input-error.input-focused {
|
||||
border-color: var(--fms-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent),
|
||||
var(--fms-shadow);
|
||||
}
|
||||
|
||||
.input-warning {
|
||||
@@ -93,7 +100,9 @@
|
||||
|
||||
.input-warning.input-focused {
|
||||
border-color: var(--fms-warning);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-warning) 20%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--fms-warning) 20%, transparent),
|
||||
var(--fms-shadow);
|
||||
}
|
||||
|
||||
/* 禁用态:灰底 + 灰字 + 弱边框,悬停显示禁用光标 */
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -36,13 +37,13 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 选中:主色淡底 + 主色加粗文字(不依赖单一颜色表达) */
|
||||
.tree-node.is-selected {
|
||||
background: color-mix(in srgb, var(--fms-primary) 6%, transparent);
|
||||
}
|
||||
.tree-node.is-selected .tree-node-content {
|
||||
color: var(--fms-primary);
|
||||
font-weight: 600;
|
||||
/* 选中:主色淡底,明显区分于灰底页面;文字保持默认色。
|
||||
比例取 5%:深色主题(如 #0f172b)在白底上约 #f3f3f4,接近 hover 浅色 #f1f5f9;
|
||||
比例过高(10%)时深色主题会算出 #e7e7e9 等偏深灰,显得脏。
|
||||
注意 hover 规则 specificity 更高,需连同 hover 一起覆盖,避免悬停时选中态被盖掉 */
|
||||
.tree-node.is-selected,
|
||||
.tree-node.is-selected:hover:not(.is-disabled) {
|
||||
background: color-mix(in srgb, var(--fms-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.tree-node.is-disabled,
|
||||
@@ -68,7 +69,7 @@
|
||||
width: var(--fms-tree-switcher-size);
|
||||
height: var(--fms-tree-switcher-size);
|
||||
margin-right: 2px;
|
||||
color: var(--fms-text-secondary);
|
||||
color: inherit;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -126,6 +127,23 @@
|
||||
.tree-node.is-block .tree-node-content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* 节点图标:位于标题前,继承文字默认色,与标题对齐 */
|
||||
.tree-node-icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--fms-tree-icon-size);
|
||||
height: var(--fms-tree-icon-size);
|
||||
margin-right: 8px;
|
||||
color: inherit;
|
||||
|
||||
svg {
|
||||
width: var(--fms-tree-icon-size);
|
||||
height: var(--fms-tree-icon-size);
|
||||
}
|
||||
}
|
||||
.tree-highlight {
|
||||
background: color-mix(in srgb, var(--fms-primary) 22%, transparent);
|
||||
color: var(--fms-primary);
|
||||
|
||||
@@ -187,6 +187,8 @@ function onContextMenu(e) {
|
||||
:class="{ 'is-block': ctx.props.blockNode }"
|
||||
@click="onContentClick"
|
||||
>
|
||||
<!-- 节点图标:节点数据 icon 字段为 lucide 组件时渲染在标题前 -->
|
||||
<component :is="node[fn.icon]" v-if="node[fn.icon]" class="tree-node-icon" aria-hidden="true" />
|
||||
<component :is="titleSlotComp" v-if="titleSlot" />
|
||||
<span v-else-if="titleParts"
|
||||
>{{ titleParts.before }}<span class="tree-highlight">{{ titleParts.match }}</span
|
||||
|
||||
@@ -7,6 +7,7 @@ export function resolveFieldNames(fieldNames) {
|
||||
key: 'key',
|
||||
title: 'title',
|
||||
children: 'children',
|
||||
icon: 'icon',
|
||||
...fieldNames,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +95,13 @@ function onSplitterResize(sizes) {
|
||||
:min="appStore.sidebarCollapsed ? undefined : SIDEBAR_MIN"
|
||||
:max="appStore.sidebarCollapsed ? undefined : SIDEBAR_MAX"
|
||||
:resizable="!appStore.sidebarCollapsed"
|
||||
class="fms-sidebar-panel"
|
||||
>
|
||||
<AppSidebar />
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 主区面板:顶栏(已含多页签)独立在灰底上,下方一张白卡放内容 -->
|
||||
<!-- 主区面板:顶栏(含页签)独立在灰底上,下方一张白卡放内容 -->
|
||||
<SplitterPanel class="fms-main-panel">
|
||||
<div class="fms-main-col">
|
||||
<!-- 顶栏已合并多页签,主区只留内容白卡 -->
|
||||
<AppTopbar />
|
||||
|
||||
<div class="fms-main">
|
||||
@@ -192,7 +190,7 @@ function onSplitterResize(sizes) {
|
||||
}
|
||||
|
||||
/* 主区面板:四周留 8px 间距(含左侧),与侧栏拉开标准间隙。
|
||||
* 折叠态(窄侧栏 48px)左侧归 0,白卡贴侧栏右缘 */
|
||||
* 折叠态(窄侧栏 48px)左侧归 0,避免过窄时两侧都留间隙 */
|
||||
.fms-main-panel {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -201,16 +199,6 @@ function onSplitterResize(sizes) {
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
/* 侧栏面板:四周留 8px,白色侧栏卡浮在灰底上(与顶栏同一套间距) */
|
||||
.fms-sidebar-panel {
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fms-layout.is-collapsed .fms-sidebar-panel {
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
/* 覆盖 Splitter 面板默认 overflow: auto(scoped 同名规则特异性更高,需带 .fms-layout 提权),
|
||||
* 让主卡片左侧阴影完整绘制到侧栏背景上(对齐 old2 的 z-index 效果) */
|
||||
.fms-layout .fms-splitter-panel.fms-main-panel {
|
||||
@@ -227,7 +215,8 @@ function onSplitterResize(sizes) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 主区:透明容器(不再包白卡),内容区直接坐灰底,页面白卡浮在灰上 */
|
||||
/* 主区内容容器:白底承载内容,与透明侧栏(灰底)形成明显区分;
|
||||
* 圆角 + 阴影让白卡在灰底上浮起,层次清晰 */
|
||||
.fms-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -235,9 +224,13 @@ function onSplitterResize(sizes) {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
/* 内容区:透明,灰底由 .fms-layout 提供(页面白卡直接浮在灰上) */
|
||||
/* 内容区:白卡内滚动区,顶部由 .fms-tabs 的底边线分隔。
|
||||
* flex column:让页面根可用 flex:1 撑满可视高度(百分比高度链在滚动容器内不可靠) */
|
||||
.fms-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -152,16 +152,12 @@ function toNavItem(node) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 侧栏容器:白色圆角卡(带框架阴影,浮在灰底上),内部分区滚动 ---- */
|
||||
/* ---- 侧栏容器 ---- */
|
||||
.fms-sidebar-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-frame-shadow);
|
||||
}
|
||||
|
||||
/* 团队切换(对齐 SidebarHeader p-2 + TeamSwitcher h-12) */
|
||||
|
||||
@@ -65,10 +65,6 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (scrollStateRaf != null) {
|
||||
cancelAnimationFrame(scrollStateRaf)
|
||||
scrollStateRaf = null
|
||||
}
|
||||
trackRef.value?.removeEventListener('wheel', handleWheel)
|
||||
})
|
||||
|
||||
@@ -135,33 +131,19 @@ function handleTabAction(action, key) {
|
||||
}
|
||||
}
|
||||
|
||||
// 溢出/滚动状态:仅在实际变化时更新,并用 rAF 延迟到下一帧提交 ——
|
||||
// 箭头 v-if 的显示切换会改变轨道宽度,若在 ResizeObserver 回调内同步改布局,
|
||||
// 会在同一帧内触发新一轮观察,造成「ResizeObserver loop」错误。
|
||||
let scrollStateRaf = null
|
||||
function updateScrollState() {
|
||||
const track = trackRef.value
|
||||
if (!track) return
|
||||
const shouldOverflow = track.scrollWidth > track.clientWidth + 1
|
||||
const left = shouldOverflow ? track.scrollLeft > 1 : false
|
||||
const right = shouldOverflow
|
||||
? track.scrollLeft + track.clientWidth < track.scrollWidth - 1
|
||||
: false
|
||||
if (
|
||||
shouldOverflow === overflowed.value &&
|
||||
left === canScrollLeft.value &&
|
||||
right === canScrollRight.value
|
||||
) {
|
||||
overflowed.value = shouldOverflow
|
||||
if (!shouldOverflow) {
|
||||
track.scrollLeft = 0
|
||||
canScrollLeft.value = false
|
||||
canScrollRight.value = false
|
||||
return
|
||||
}
|
||||
if (scrollStateRaf != null) return
|
||||
scrollStateRaf = requestAnimationFrame(() => {
|
||||
scrollStateRaf = null
|
||||
if (!shouldOverflow) track.scrollLeft = 0
|
||||
overflowed.value = shouldOverflow
|
||||
canScrollLeft.value = left
|
||||
canScrollRight.value = right
|
||||
})
|
||||
canScrollLeft.value = track.scrollLeft > 1
|
||||
canScrollRight.value = track.scrollLeft + track.clientWidth < track.scrollWidth - 1
|
||||
}
|
||||
|
||||
function scrollTabs(direction) {
|
||||
|
||||
@@ -105,6 +105,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
>
|
||||
<PanelLeft />
|
||||
</Button>
|
||||
|
||||
<span class="fms-separator" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
@@ -203,7 +204,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 顶栏(已合并页签,白色浮条,带框架阴影) ---- */
|
||||
/* ---- 顶栏 ---- */
|
||||
.fms-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -211,9 +212,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
/* 顶栏独立成一块:与 .fms-main 同款白底 + 圆角 + 同款投影,与下方白卡并列分层 */
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-frame-shadow);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
.fms-topbar-left,
|
||||
|
||||
@@ -218,12 +218,12 @@ function onToggle() {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* 激活高亮:主题色浅底 + 主题色文字(白色侧栏上可见) */
|
||||
/* 激活高亮:主色淡底 + 主色文字(与树选中态一致)。
|
||||
侧栏为浅灰底时白底+阴影会形成突兀的白块,改用主色淡底融入浅灰侧栏 */
|
||||
.fms-nav-link.is-active {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
color: var(--fms-primary);
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ---- 折叠态:仅图标,居中展示(对齐 size-8 icon 模式) ---- */
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
--fms-form-item-margin-bottom: 18px;
|
||||
--fms-form-help-font-size: 12px;
|
||||
|
||||
/* 阴影 */
|
||||
--fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* 阴影(对齐 shadcn-vue / Tailwind v4 shadow-xs:0 1px 2px 0 rgb(0 0 0 / 0.05)) */
|
||||
--fms-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
|
||||
/* 浮层(popover / tooltip 等) */
|
||||
--fms-popover-padding: 8px;
|
||||
|
||||
@@ -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