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

@@ -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,
}
}
+9 -16
View File
@@ -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 模式) ---- */
+2 -2
View File
@@ -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 {