Files
workspace/code/fms/fms-vue/src/views/system/module-management/index.vue
T
2026-08-26 22:44:45 +08:00

1521 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/**
* 模块管理:模块配置(单分区)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。
* 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。
*/
import { computed, nextTick, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
import {
Plus,
FolderPlus,
Folder,
Database,
Search,
Trash2,
Save,
Table2,
List,
Pencil,
Hash,
RefreshCw,
Shield,
Globe,
Info,
FolderTree,
} from '@lucide/vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import Tree from '@/components/ui/tree/tree.vue'
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 { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api'
import { buildTree, sortTree } from '@/utils/tree'
import { createTempId } from '@/utils/tempId'
import {
cloneData,
diffRows,
allocateTemporaryIds,
remapForeignKeys,
tableChange,
hasChanges,
} from '@/utils/dataChanges'
import { buildModuleI18nKey, buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import ModuleBasicPanel from './components/ModuleBasicPanel.vue'
import ModuleFieldsPanel from './components/ModuleFieldsPanel.vue'
import ModuleListConfigPanel from './components/ModuleListConfigPanel.vue'
import ModuleFormEditPanel from './components/ModuleFormEditPanel.vue'
import ModuleTableEditPanel from './components/ModuleTableEditPanel.vue'
import ModuleQueryConfigPanel from './components/ModuleQueryConfigPanel.vue'
import ModuleAutoCodePanel from './components/ModuleAutoCodePanel.vue'
import ModulePowersPanel from './components/ModulePowersPanel.vue'
import ModuleI18nPanel from './components/ModuleI18nPanel.vue'
/* ================= 快捷键 ================= */
// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys)
useHotkeys([
{
key: 's',
mod: true,
ignoreWhenTyping: false, // 输入框内也能保存
handler: () => void saveModule(),
},
])
/* ================= 模块树 ================= */
const moduleTreeData = ref([])
const moduleTreeLoading = ref(false)
const moduleKeyword = ref('')
const moduleSelectedId = ref(null)
const moduleExpandedKeys = ref([])
const unsavedModuleIds = new Set()
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(moduleKeyword, (kw) => {
moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : []
})
// 递归收集所有节点 key(搜索时展开全部用)
function collectAllKeys(nodes) {
const keys = []
const walk = (list) => {
for (const node of list) {
keys.push(String(node.b_id))
if (node.children) walk(node.children)
}
}
walk(nodes)
return keys
}
// 从根到目标节点的祖先路径(新建节点时展开,保证可见)
function expandAncestors(nodes, id) {
const keys = []
const walk = (list, ancestors) => {
for (const node of list) {
const key = String(node.b_id)
if (key === id) { keys.push(...ancestors); return true }
if (node.children && walk(node.children, [...ancestors, key])) return true
}
return false
}
walk(nodes, [])
return keys
}
// 模块树右键菜单:空白处新增顶级节点,节点上右键可新增子节点或删除。
// 未保存(草稿)节点不可作为父级,仅可编辑/保存/删除
function buildModuleContextMenu(node) {
const isUnsaved = node != null && unsavedModuleIds.has(String(node.b_id))
const items = [
{ key: 'add-category', label: '新增分类', icon: FolderPlus, disabled: isUnsaved },
{ key: 'add-data', label: '新增数据模块', icon: Database, disabled: isUnsaved },
{ key: 'add-query', label: '新增查询模块', icon: Search, disabled: isUnsaved },
]
if (node) {
items.push({ divider: true })
items.push({ key: 'delete', label: '删除模块', icon: Trash2, danger: true })
}
return items
}
function onModuleContextMenuClick({ item, node }) {
const parentId = node ? String(node.b_id) : null
if (item.key === 'add-category') void addModule(parentId, 'category')
else if (item.key === 'add-data') void addModule(parentId, 'data')
else if (item.key === 'add-query') void addModule(parentId, 'query')
else if (item.key === 'delete') void deleteModule(parentId)
}
async function loadModuleTree() {
moduleTreeLoading.value = true
try {
// 加载全部模块(含停用):停用节点在树中变灰显示,便于重新启用
const res = await loadDataApi('s_module', '1 = 1', 'b_xh ASC, b_id ASC')
moduleTreeData.value = markCategoryNonLeaf(sortTree(buildTree(res.data || [])))
unsavedModuleIds.clear()
} catch (e) {
Message.error('加载模块树失败:' + (e?.message || e))
} finally {
moduleTreeLoading.value = false
}
}
// 分类节点标记为非叶子:即使暂无子模块也显示折叠箭头(提示可挂子模块)。
// 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
}
const filteredModuleTree = computed(() => filterTree(moduleTreeData.value, moduleKeyword.value))
function filterTree(nodes, keyword) {
const kw = String(keyword || '').trim().toLowerCase()
if (!kw) return nodes
const result = []
for (const node of nodes) {
const hit =
String(node.b_name || '').toLowerCase().includes(kw) ||
String(node.b_code || '').toLowerCase().includes(kw)
const children = node.children ? filterTree(node.children, kw) : undefined
if (hit || children?.length) {
result.push({ ...node, children })
}
}
return result
}
/* ================= 模块配置加载 ================= */
const configLoading = ref(false)
const configSaving = ref(false)
const configSyncing = ref(false)
const loadedModuleId = ref(null)
const originals = ref(null)
const drafts = ref(null)
const activeSection = ref('basic')
const basicPanelRef = ref(null)
const autoCodePanelRef = ref(null)
const i18nPanelRef = ref(null)
function findModuleNode(nodes, id) {
for (const node of nodes) {
if (String(node.b_id) === String(id)) return node
if (node.children) {
const found = findModuleNode(node.children, id)
if (found) return found
}
}
return null
}
const selectedModule = computed(() =>
moduleSelectedId.value ? findModuleNode(moduleTreeData.value, moduleSelectedId.value) : null,
)
const moduleTypeLabel = computed(() => {
const labels = { category: '分类', data: '数据模块', query: '查询模块' }
return labels[selectedModule.value?.b_module_type] || ''
})
const parentModuleName = computed(() => {
if (!selectedModule.value?.b_parent_id) return ''
return findModuleNode(moduleTreeData.value, selectedModule.value.b_parent_id)?.b_name || ''
})
const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value)
// 是否有未保存的修改(用于「重载」按钮的确认提示)
const hasModuleChanges = computed(() => {
if (!drafts.value || !originals.value) return false
return JSON.stringify(drafts.value) !== JSON.stringify(originals.value)
})
// 重载当前模块:重新从后端加载数据;若有未保存修改,先让使用者确认放弃
function reloadModule() {
if (!selectedModule.value || busy.value) return
const name = selectedModule.value.b_name || selectedModule.value.b_code
const doReload = () => {
loadModuleConfig(selectedModule.value.b_id)
}
if (hasModuleChanges.value) {
showConfirm({
title: '重载模块',
content: `「${name}」有未保存的修改,确定放弃修改并重新加载数据?`,
okText: '重载',
onOk: doReload,
})
} else {
doReload()
}
}
const moduleType = computed(() => selectedModule.value?.b_module_type || null)
const isDataModule = computed(() => moduleType.value === 'data')
const isQueryModule = computed(() => moduleType.value === 'query')
async function loadModuleConfig(moduleId) {
const id = String(moduleId)
const type = findModuleNode(moduleTreeData.value, id)?.b_module_type
configLoading.value = true
try {
let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes
if (type === 'data') {
const responses = await Promise.all([
loadDataApi('s_module', `b_id = ${id}`),
loadDataApi('s_field', `b_module_id = ${id}`, 'b_id ASC'),
loadDataApi('s_field_group', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_edit', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'),
loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'),
])
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes] = responses
} else if (type === 'query') {
const responses = await Promise.all([
loadDataApi('s_module', `b_id = ${id}`),
loadDataApi('s_field', `b_module_id = ${id}`, 'b_id ASC'),
loadDataApi('s_field_group', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'),
])
;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, powersRes] = responses
} else {
moduleRes = await loadDataApi('s_module', `b_id = ${id}`)
}
if (String(moduleSelectedId.value) !== id) return
const original = {
module: moduleRes.data?.[0] || null,
fields: fieldsRes?.data || [],
groups: groupsRes?.data || [],
listConfig: listRes?.data || [],
editConfig: editRes?.data || [],
queryConfig: queryRes?.data || [],
autoCode: autoCodeRes?.data || [],
powers: powersRes?.data || [],
}
originals.value = original
drafts.value = cloneData(original)
loadedModuleId.value = id
activeSection.value = 'basic'
} catch (e) {
if (String(moduleSelectedId.value) === String(moduleId)) {
loadedModuleId.value = null
drafts.value = null
Message.error('模块配置加载失败:' + (e?.message || e))
}
} finally {
if (String(moduleSelectedId.value) === String(moduleId)) configLoading.value = false
}
}
let skipModuleLoad = false
watch(moduleSelectedId, (id) => {
if (skipModuleLoad) {
skipModuleLoad = false
configLoading.value = false
return
}
if (!id) {
configLoading.value = false
loadedModuleId.value = null
drafts.value = null
return
}
// 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading)
loadedModuleId.value = null
drafts.value = null
void loadModuleConfig(id)
})
function onModuleSelect(keys) {
if (!keys.length) return
const nextId = String(keys[0])
if (nextId === String(moduleSelectedId.value)) return
// 有未保存修改时先询问,避免用户困惑「刚才改的内容去哪了」
if (loadedModuleId.value && hasModuleChanges.value) {
switchConfirm.value = {
type: 'module',
pendingId: nextId,
name: selectedModule.value?.b_name || selectedModule.value?.b_code || '',
}
return
}
moduleSelectedId.value = nextId
}
/* ================= 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) ================= */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
switchConfirm.value = null
switchSaving.value = false
}
async function onSwitchDiscard() {
const state = switchConfirm.value
if (!state) return
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
}
async function onSwitchSave() {
const state = switchConfirm.value
if (!state || switchSaving.value) return
switchSaving.value = true
try {
const ok = await saveModule()
if (!ok) {
// 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
Message.warning('保存未完成,已留在当前对象,请处理后再切换')
return
}
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
} finally {
switchSaving.value = false
}
}
/* ================= 通用确认弹窗(ui/modal 为组件式 API) ================= */
const confirmVisible = ref(false)
const confirmState = ref(null)
// 确认操作进行中:按钮转圈、弹窗锁定,等待 onOk 完成后再关闭
const confirmSaving = ref(false)
function showConfirm({ title, content, okText, okType, onOk }) {
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
confirmVisible.value = true
}
async function onConfirmOk() {
const state = confirmState.value
if (!state?.onOk || confirmSaving.value) return
confirmSaving.value = true
try {
await state.onOk()
} catch (e) {
// onOk 内部已处理错误提示,这里兜底避免未捕获异常
Message.error('操作失败:' + (e?.message || e))
} finally {
confirmSaving.value = false
confirmVisible.value = false
}
}
/* ================= 模块:新增 / 删除 ================= */
async function addModule(parentId, moduleType) {
const res = await nextIdApi()
const newId = String(res.data)
const newModule = {
b_id: newId,
b_code: '',
b_parent_id: parentId ? String(parentId) : null,
b_name: '',
b_i18n: null,
b_module_type: moduleType,
b_viewtable: null,
b_savetable: null,
b_keyfield: null,
b_orderfield: null,
b_delete_policy: moduleType === 'data' ? 'restrict' : null,
b_edit_mode: moduleType === 'data' ? 'form' : null,
b_canuse: 1,
b_xh: 10,
b_bz: null,
// 分类节点强制非叶子,即使暂无子模块也显示折叠箭头
isLeaf: moduleType === 'category' ? false : undefined,
}
// 插入树并选中(跳过 watch 的自动加载,直接用空草稿)
insertModuleNode(moduleTreeData.value, newModule)
// 展开祖先路径,保证新建节点在收起的树中可见
const ancestors = expandAncestors(moduleTreeData.value, newId)
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
await nextTick()
// 新建时直接给空草稿
drafts.value = cloneData({
module: newModule,
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
autoCode: [],
powers: [],
})
originals.value = cloneData(drafts.value)
loadedModuleId.value = newId
}
function insertModuleNode(nodes, node) {
if (node.b_parent_id == null) {
nodes.push(node)
sortTree(nodes)
return
}
for (const n of nodes) {
if (String(n.b_id) === String(node.b_parent_id)) {
n.children = n.children || []
n.children.push(node)
sortTree(n.children)
return
}
if (n.children) insertModuleNode(n.children, node)
}
}
// 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除)
function hasSavedModuleDescendant(node) {
for (const child of node.children || []) {
if (!unsavedModuleIds.has(String(child.b_id))) return true
if (hasSavedModuleDescendant(child)) return true
}
return false
}
// 收集节点子树内全部未保存的模块 ID(删除时清理标记)
function collectUnsavedModuleIds(node, set = new Set()) {
set.add(String(node.b_id))
for (const child of node.children || []) collectUnsavedModuleIds(child, set)
return set
}
async function deleteModule(id) {
const module = id != null ? findModuleNode(moduleTreeData.value, id) : selectedModule.value
if (!module || busy.value) return
const moduleId = String(module.b_id)
if (unsavedModuleIds.has(moduleId)) {
// 未保存草稿:本地递归移除子树并清理标记(草稿下不应有已保存子孙)
collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k))
removeModuleNode(moduleTreeData.value, moduleId)
moduleSelectedId.value = null
return
}
if (hasSavedModuleDescendant(module)) {
Message.warning('该模块下还有已保存的子模块,请先删除子模块')
return
}
showConfirm({
title: '删除模块',
content: `确定删除模块「${module.b_name || module.b_code}」?将同时删除该模块的字段、列表/编辑/查询配置、自动编码、权限等配置数据。`,
okText: '删除',
okType: 'danger',
onOk: async () => {
try {
const id = String(module.b_id)
const reqs = []
// 收集子表行
const subTables = [
['s_field', 'b_module_id'],
['s_field_group', 'b_module_id'],
['s_field_view', 'b_module_id'],
['s_field_edit', 'b_module_id'],
['s_field_query', 'b_module_id'],
['s_autocode', 'b_module_id'],
]
for (const [table, field] of subTables) {
const res = await loadDataApi(table, `${field} = ${id}`)
const rows = res.data || []
if (rows.length) {
reqs.push({ table, key_field: 'b_id', deletes: rows.map((r) => ({ b_id: r.b_id })) })
}
}
const powerRes = await loadDataApi('s_power', `b_module_id = ${id}`)
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 })) })
}
// 多语言(s_i18n 无 b_module_id 字段,需按模块编码匹配 b_key 前缀)。
// 若不清理,删除后残留的孤儿键会被同编码新模块的多语言面板加载,
// 进而产生指向已不存在记录的 UPDATE,导致后端「修改记录数异常: 0」。
const moduleCode = String(module.b_code || '').trim()
if (moduleCode) {
const prefix = module.b_module_type === 'category' ? 'menu' : 'module'
const i18nRes = await loadDataApi(
's_i18n',
`b_key = '${prefix}.${moduleCode}' OR b_key LIKE '${prefix}.${moduleCode}.%' ` +
`OR b_key LIKE 'field.${moduleCode}.%' OR b_key LIKE 'power.${moduleCode}.%'`,
)
const i18nRows = i18nRes.data || []
if (i18nRows.length) {
reqs.push({
table: 's_i18n',
key_field: 'b_id',
deletes: i18nRows.map((r) => ({ b_id: r.b_id })),
})
}
}
reqs.push({ table: 's_module', key_field: 'b_id', deletes: [{ b_id: id }] })
await saveObjectApi(reqs)
// 本地移除整棵子树(未保存子孙随父一并清理),并清除未保存标记
collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k))
removeModuleNode(moduleTreeData.value, id)
moduleSelectedId.value = null
Message.success('模块已删除')
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
}
},
})
}
function removeModuleNode(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) {
nodes.splice(i, 1)
return
}
if (nodes[i].children) removeModuleNode(nodes[i].children, id)
}
}
/* ================= 模块:面板回调 ================= */
function onModuleUpdate(section, newData) {
if (drafts.value) drafts.value[section] = newData
}
function onFieldsUpdate(newFields) {
if (!drafts.value) return
applyDisabledFieldDefaults(drafts.value.fields, newFields)
drafts.value.fields = newFields
}
function applyDisabledFieldDefaults(previousFields, nextFields) {
const previousStates = new Map()
for (const field of previousFields || []) {
if (field.b_id != null) previousStates.set(String(field.b_id), Number(field.b_canuse) === 1)
}
const disabledIds = new Set()
for (const field of nextFields || []) {
if (field.b_id == null) continue
if (previousStates.get(String(field.b_id)) && Number(field.b_canuse) !== 1) {
disabledIds.add(String(field.b_id))
}
}
if (!disabledIds.size) return
for (const row of drafts.value.listConfig || []) {
if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0
}
for (const row of drafts.value.editConfig || []) {
if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0
}
for (const row of drafts.value.queryConfig || []) {
if (disabledIds.has(String(row.b_field_id))) row.b_canuse = 0
}
}
function onAutoCodeUpdate(rows) {
if (!drafts.value) return
const previousRule = drafts.value.autoCode?.[0]
const previousFieldId =
previousRule && Number(previousRule.b_canuse) === 1 ? previousRule.b_field_id : null
drafts.value.autoCode = rows
const rule = rows && rows.length ? rows[0] : null
const nextFieldId = rule && Number(rule.b_canuse) === 1 ? rule.b_field_id : null
if (previousFieldId != null && String(previousFieldId) !== String(nextFieldId)) {
for (const config of drafts.value.editConfig || []) {
if (String(config.b_field_id) === String(previousFieldId)) config.b_readonly = 0
}
}
if (nextFieldId == null) return
for (const config of drafts.value.editConfig || []) {
if (String(config.b_field_id) === String(nextFieldId)) config.b_readonly = 1
}
}
function onModuleI18nSaved() {
i18nPanelRef.value?.refreshKey?.()
}
/* ================= 树节点元信息(面包屑) ================= */
// 从根到当前节点的名称路径(面包屑用)
function buildBreadcrumb(node, finder) {
const names = []
let cur = node
const seen = new Set()
while (cur && !seen.has(String(cur.b_id))) {
seen.add(String(cur.b_id))
names.unshift(cur.b_name || cur.b_code)
if (cur.b_parent_id == null) break
cur = finder(String(cur.b_parent_id))
}
return names
}
const moduleBreadcrumb = computed(() =>
selectedModule.value
? buildBreadcrumb(selectedModule.value, (id) => findModuleNode(moduleTreeData.value, id))
: [],
)
/* ================= 左树顶部工具栏:新建 / 刷新 ================= */
const moduleNewItems = [
{ key: 'category', label: '新增分类', icon: FolderPlus },
{ key: 'data', label: '新增数据模块', icon: Database },
{ key: 'query', label: '新增查询模块', icon: Search },
]
// 工具栏新增:挂到当前选中节点下(无选中则顶级);右键菜单仍保留
function onModuleNewClick({ key }) {
const parentId = moduleSelectedId.value ? String(moduleSelectedId.value) : null
void addModule(parentId, key)
}
/* ================= 右侧分组配置导航 ================= */
// 配置导航分组:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。
// 每个导航项唯一激活(activeSection)。
const configGroups = computed(() => {
const groups = []
groups.push({
key: 'overview',
label: '概览',
items: [{ key: 'basic', label: '基本信息', icon: Info }],
})
if (isDataModule.value || isQueryModule.value) {
groups.push({
key: 'structure',
label: '数据结构',
items: [{ key: 'fields', label: '字段定义', icon: Table2 }],
})
// 界面配置:表单设计(数据模块)→ 列表配置 → 查询配置
const uiItems = []
if (isDataModule.value) {
const editLen = drafts.value?.editConfig?.length ?? 0
uiItems.push({
key: 'edit',
label: editNavLabel.value,
icon: Pencil,
badge: editLen ? '已配置' : '',
})
}
const listLen = drafts.value?.listConfig?.length ?? 0
uiItems.push({
key: 'list',
label: '列表配置',
icon: List,
badge: listLen ? '已配置' : '',
})
const queryLen = drafts.value?.queryConfig?.length ?? 0
uiItems.push({
key: 'query',
label: '查询配置',
icon: Search,
badge: queryLen ? '已配置' : '',
})
groups.push({ key: 'display', label: '界面配置', items: uiItems })
const capabilityItems = []
if (isDataModule.value) {
capabilityItems.push({
key: 'auto-code',
label: '自动编码',
icon: Hash,
})
}
capabilityItems.push({
key: 'powers',
label: '权限',
icon: Shield,
})
groups.push({ key: 'capability', label: '业务能力', items: capabilityItems })
}
if (moduleType.value && moduleType.value !== 'category') {
groups.push({
key: 'advanced',
label: '高级设置',
items: [{ key: 'i18n', label: '多语言', icon: Globe }],
})
}
return groups
})
/* 横向胶囊 Tabs 的数据源:展平 configGroups 的分组为平铺 items */
const configNavItems = computed(() =>
configGroups.value.flatMap((group) => group.items.map((item) => ({ ...item }))),
)
// 编辑方式(form 主表表单 / table 子表表格)决定「表单设计」面板的形态与导航标签
const editMode = computed(() => drafts.value?.module?.b_edit_mode || 'form')
const editNavLabel = computed(() => (editMode.value === 'table' ? '表格设计' : '表单设计'))
const finalI18nItems = computed(() => {
const module = drafts.value?.module
if (!module) return []
const items = []
const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_module_type, module.b_code)
if (moduleKey) {
items.push({
key: moduleKey,
managed: true,
defaultValue: module.b_name || '',
id: `module:${moduleKey}`,
})
}
for (const field of drafts.value?.fields || []) {
const key = buildModuleItemI18nKey('field', module.b_code, field.b_field)
if (key) items.push({ key, managed: true, defaultValue: field.b_name || '', id: `field:${key}` })
}
for (const power of drafts.value?.powers || []) {
const key = buildModuleItemI18nKey('power', module.b_code, power.b_code)
if (key) items.push({ key, managed: true, defaultValue: power.b_name || '', id: `power:${key}` })
}
return items
})
/* ================= 模块:同步字段 ================= */
async function syncFields() {
const module = drafts.value?.module
if (!module) return
const view = String(module.b_viewtable || '').trim()
if (!view || busy.value) return
configSyncing.value = true
try {
const res = await describeApi(view)
const columns = res.data || []
const existingFields = drafts.value.fields || []
const viewColumnSet = new Set(columns.map((col) => String(col.name || '').toLowerCase()))
const existingMap = new Map()
for (const field of existingFields) {
if (field.b_field) existingMap.set(field.b_field.toLowerCase(), field)
}
const mergedFields = []
for (const col of columns) {
const existing = existingMap.get(String(col.name).toLowerCase())
if (existing) {
mergedFields.push({ ...existing, b_canuse: 1 })
} else {
mergedFields.push({
b_id: createTempId(),
b_module_id: String(module.b_id),
b_field: col.name,
b_name: col.name,
b_i18n: null,
b_type: 'input',
b_dbtype: col.type || null,
b_length: col.size ?? null,
b_nullable: col.nullable ?? 1,
b_default_value: null,
b_canuse: 1,
})
}
}
// 视图中已不存在的字段:移除并清理关联配置行
const removedIds = new Set()
for (const existing of existingFields) {
if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) {
removedIds.add(String(existing.b_id))
}
}
if (removedIds.size) {
for (const section of ['listConfig', 'editConfig', 'queryConfig']) {
drafts.value[section] = (drafts.value[section] || []).filter(
(row) => !removedIds.has(String(row.b_field_id)),
)
}
i18nPanelRef.value?.removeByManagedIds?.(Array.from(removedIds).map((id) => `field:${id}`))
}
for (const field of mergedFields) {
field.b_i18n = buildModuleItemI18nKey('field', module.b_code, field.b_field)
}
drafts.value.fields = mergedFields
Message.success(`已同步 ${mergedFields.length} 个字段`)
} catch (e) {
Message.error('同步字段失败:' + (e?.message || e))
} finally {
configSyncing.value = false
}
}
/* ================= 模块:保存 ================= */
function validateAutoCode() {
const rows = drafts.value?.autoCode || []
const rule = rows.length ? rows[0] : null
if (!rule || Number(rule.b_canuse) !== 1) return ''
if (!String(drafts.value.module?.b_savetable || '').trim()) return '启用自动编码前请先配置保存表'
return ''
}
async function saveModule() {
if (!selectedModule.value || busy.value) return false
if (loadedModuleId.value == null || String(loadedModuleId.value) !== String(moduleSelectedId.value)) return false
const moduleId = String(selectedModule.value.b_id)
configSaving.value = true
try {
const saveReqs = []
// 基础信息与自动编码的字段级校验:错误已显示在对应 FormItem 下方,此处只做汇总与定位
const basicErrors = await basicPanelRef.value?.validate?.()
if (basicErrors?.length) {
activeSection.value = 'basic'
Message.warning('请修正表单中标红的错误项')
return false
}
const autoCodeErrors = await autoCodePanelRef.value?.validate?.()
if (autoCodeErrors?.length) {
activeSection.value = 'auto-code'
Message.warning('请修正表单中标红的错误项')
return false
}
// 跨面板校验:自动编码依赖保存表
const autoCodeError = validateAutoCode()
if (autoCodeError) {
Message.warning(autoCodeError)
return false
}
const moduleData = drafts.value?.module
if (!moduleData) return false
const moduleCode = String(moduleData.b_code || '').trim()
syncModuleItemI18nKeys(drafts.value.fields, 'field', moduleCode, 'b_field')
syncModuleItemI18nKeys(drafts.value.powers, 'power', moduleCode, 'b_code')
moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleCode)
const isNew = unsavedModuleIds.has(moduleId)
if (moduleData && (isNew || JSON.stringify(moduleData) !== JSON.stringify(originals.value?.module))) {
const moduleRequest = { table: 's_module', key_field: 'b_id' }
if (isNew) moduleRequest.inserts = [moduleData]
else moduleRequest.updates = [moduleData]
saveReqs.push(moduleRequest)
}
const subTables = [
['s_field', 'fields'],
['s_field_group', 'groups'],
['s_field_view', 'listConfig'],
['s_field_edit', 'editConfig'],
['s_field_query', 'queryConfig'],
['s_autocode', 'autoCode'],
['s_power', 'powers'],
]
// 收集字段/分组的临时 id → 真实 id 映射,用于跨表外键重映射
const fieldIdMap = new Map()
const groupIdMap = new Map()
for (const [table, section] of subTables) {
const current = drafts.value[section] || []
const previous = originals.value?.[section] || []
const changes = diffRows(current, previous)
if (table === 's_autocode') {
changes.updates = changes.updates.map((row) => {
const { b_currentperiod: _p, b_currentvalue: _v, ...config } = row
return config
})
}
const req = tableChange(table, changes)
if (hasChanges(req)) {
const mapping = await allocateTemporaryIds([req.inserts || [], req.updates || []])
if (table === 's_field') mapping.forEach((v, k) => fieldIdMap.set(k, v))
if (table === 's_field_group') mapping.forEach((v, k) => groupIdMap.set(k, v))
saveReqs.push(req)
}
}
// 统一重映射跨表外键:列表/编辑/查询配置中引用字段、分组的临时 id 换成真实 id
if (fieldIdMap.size) {
for (const section of ['listConfig', 'editConfig', 'queryConfig']) {
remapForeignKeys(drafts.value[section] || [], ['b_field_id'], fieldIdMap)
}
}
if (groupIdMap.size) {
for (const section of ['listConfig', 'editConfig', 'queryConfig']) {
remapForeignKeys(drafts.value[section] || [], ['b_group_id'], groupIdMap)
}
}
const i18nPanel = i18nPanelRef.value
if (i18nPanel && typeof i18nPanel.reconcileManagedRows === 'function') {
i18nPanel.reconcileManagedRows(finalI18nItems.value)
}
if (i18nPanel && typeof i18nPanel.buildSaveRequest === 'function') {
const i18nReq = i18nPanel.buildSaveRequest()
if (i18nReq) {
// 为新增的多语言记录分配真实主键,并回写内部状态,避免二次保存重复插入
if (i18nReq.inserts?.length) {
await allocateTemporaryIds([i18nReq.inserts])
i18nPanel.applyInsertedIds?.(i18nReq.inserts)
}
saveReqs.push(i18nReq)
}
}
if (saveReqs.length === 0) {
Message.info('没有需要保存的变更')
return true
}
await saveObjectApi(saveReqs)
originals.value = cloneData(drafts.value)
i18nPanel?.markSaved?.()
unsavedModuleIds.delete(moduleId)
// 更新树节点显示(名称/编码可能变化)
const node = findModuleNode(moduleTreeData.value, moduleId)
if (node) Object.assign(node, cloneData(moduleData))
Message.success('保存成功')
return true
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
return false
} finally {
configSaving.value = false
}
}
/* ================= 初始化 ================= */
loadModuleTree()
</script>
<template>
<div class="mm-page">
<!-- ================ 模块配置 ================ -->
<div class="mm-body">
<Splitter class="mm-body-splitter">
<SplitterPanel :default-size="248" :min="180" :max="420">
<aside class="mm-tree-panel">
<div class="mm-tree-header">
<Input v-model="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
v-else
:tree-data="filteredModuleTree"
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
:expanded-keys="moduleExpandedKeys"
@update:expanded-keys="(keys) => (moduleExpandedKeys = keys)"
:selected-keys="moduleSelectedId ? [moduleSelectedId] : []"
:context-menu="buildModuleContextMenu"
@select="onModuleSelect"
@context-menu-click="onModuleContextMenuClick"
>
<template #title="{ node }">
<span class="mm-tree-node">
<span
class="mm-tree-node__name"
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
>
{{ node.b_name || node.b_code }}
</span>
</span>
</template>
</Tree>
</div>
</aside>
</SplitterPanel>
<SplitterPanel>
<main class="mm-main">
<template v-if="selectedModule">
<Tabs
v-if="drafts"
v-model="activeSection"
nav-only
class="mm-config-nav"
:items="configNavItems"
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="busy || !selectedModule" @click="reloadModule">
<RefreshCw :size="14" /> 重载
</Button>
<Button
type="primary"
:loading="configSaving"
:disabled="configLoading"
@click="saveModule"
>
<Save :size="14" /> 保存
</Button>
</div>
</template>
</Tabs>
<div class="mm-body__inner">
<div v-if="drafts" class="mm-config-content">
<div v-show="activeSection === 'basic'" class="mm-config-pane">
<ModuleBasicPanel
ref="basicPanelRef"
:data="drafts?.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => onModuleUpdate('module', v)"
@i18n-saved="onModuleI18nSaved"
/>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'fields'"
class="mm-config-pane"
>
<div class="mm-section-toolbar">
<Button type="outline" :disabled="busy" @click="syncFields">同步字段</Button>
</div>
<ModuleFieldsPanel
:module-id="String(selectedModule.b_id)"
:module-code="drafts?.module?.b_code || ''"
:fields="drafts?.fields"
:loading="busy"
@update="onFieldsUpdate"
@i18n-saved="onModuleI18nSaved"
@sync-fields="syncFields"
/>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'list'"
class="mm-config-pane"
>
<ModuleListConfigPanel
:module-id="String(selectedModule.b_id)"
:list-config="drafts?.listConfig"
:edit-config="drafts?.editConfig"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:groups="drafts?.groups"
:loading="busy"
@update="(v) => onModuleUpdate('listConfig', v)"
@sync-edit="(v) => onModuleUpdate('editConfig', v)"
@sync-query="(v) => onModuleUpdate('queryConfig', v)"
/>
</div>
<div v-if="isDataModule" v-show="activeSection === 'edit'" class="mm-config-pane">
<ModuleTableEditPanel
v-if="editMode === 'table'"
:module-id="String(selectedModule.b_id)"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:groups="drafts?.groups"
:edit-mode="editMode"
:active="activeSection === 'edit'"
:loading="busy"
@update="(v) => onModuleUpdate('editConfig', v)"
@update:edit-mode="
(v) => onModuleUpdate('module', { ...(drafts?.module || {}), b_edit_mode: v })
"
@update-groups="(v) => onModuleUpdate('groups', v)"
@sync-list="(v) => onModuleUpdate('listConfig', v)"
@sync-query="(v) => onModuleUpdate('queryConfig', v)"
/>
<ModuleFormEditPanel
v-else
:module-id="String(selectedModule.b_id)"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:groups="drafts?.groups"
:edit-mode="editMode"
:active="activeSection === 'edit'"
:loading="busy"
@update="(v) => onModuleUpdate('editConfig', v)"
@update:edit-mode="
(v) => onModuleUpdate('module', { ...(drafts?.module || {}), b_edit_mode: v })
"
@update-groups="(v) => onModuleUpdate('groups', v)"
@sync-list="(v) => onModuleUpdate('listConfig', v)"
@sync-query="(v) => onModuleUpdate('queryConfig', v)"
/>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'query'"
class="mm-config-pane"
>
<ModuleQueryConfigPanel
:module-id="String(selectedModule.b_id)"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:loading="busy"
@update="(v) => onModuleUpdate('queryConfig', v)"
/>
</div>
<div v-if="isDataModule" v-show="activeSection === 'auto-code'" class="mm-config-pane">
<ModuleAutoCodePanel
ref="autoCodePanelRef"
:module-id="String(selectedModule.b_id)"
:auto-code="drafts?.autoCode"
:fields="drafts?.fields"
:loading="busy"
@update="onAutoCodeUpdate"
/>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'powers'"
class="mm-config-pane"
>
<ModulePowersPanel
:module-id="String(selectedModule.b_id)"
:module-code="drafts?.module?.b_code || ''"
:powers="drafts?.powers"
:loading="busy"
@update="(v) => onModuleUpdate('powers', v)"
@i18n-saved="onModuleI18nSaved"
/>
</div>
<div
v-if="moduleType && moduleType !== 'category'"
v-show="activeSection === 'i18n'"
class="mm-config-pane"
>
<ModuleI18nPanel
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="finalI18nItems"
:loading="busy"
/>
</div>
</div>
<!-- 加载中/无数据占位:避免配置区一片空白 -->
<div v-else-if="configLoading" class="mm-config-state">
<span class="mm-config-state__spinner"></span>
<span>加载中...</span>
</div>
<div v-else class="mm-config-state">暂无配置数据</div>
</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"
title="确认"
:confirm-loading="confirmSaving"
:mask-closable="!confirmSaving"
:keyboard="!confirmSaving"
>
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">{{ confirmState?.content }}</p>
<template #footer>
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false">取消</Button>
<Button
:type="confirmState?.okType || 'primary'"
:loading="confirmSaving"
@click="onConfirmOk"
>
{{ confirmState?.okText || '确定' }}
</Button>
</template>
</Modal>
<!-- 切换对象未保存保护:保存并切换 / 放弃修改 / 取消 -->
<Modal
:open="!!switchConfirm"
title="有未保存的修改"
:mask-closable="false"
:confirm-loading="switchSaving"
@cancel="onSwitchCancel"
>
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">
「{{ switchConfirm?.name || '当前对象' }}」有未保存的修改,切换前请选择如何处理:
</p>
<template #footer>
<Button type="outline" :disabled="switchSaving" @click="onSwitchCancel">取消</Button>
<Button type="outline" :disabled="switchSaving" @click="onSwitchDiscard">放弃修改</Button>
<Button type="primary" :loading="switchSaving" @click="onSwitchSave">保存并切换</Button>
</template>
</Modal>
</div>
</template>
<style lang="scss" scoped>
/* 页面根:在 .fms-page-wrap(flex column)中 flex:1 撑满剩余高度;
内容超高时随内容增高,由外层 .fms-content 滚动 */
.mm-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- 主体左右布局:占据剩余全部高度,由 Splitter 管理左右面板宽度 ---- */
.mm-body {
display: flex;
flex: 1;
min-height: 0;
}
/* Splitter 适配:
* - 面板内容各自内部滚动,避免双滚动条
* - 面板左右留 3px,两张白卡之间形成灰缝(3 + 分隔条 6 + 3 = 12px),无需边框线
* - 隐藏分隔条常显的 1px 线(hover 时的手柄仍会显示,可拖拽调宽) */
.mm-body-splitter :deep(> .fms-splitter-panel) {
overflow: hidden;
padding: 0 3px;
}
.mm-body-splitter :deep(.fms-splitter-bar::before) {
display: none;
}
/* ---- 左树顶部 header:搜索框在左,操作在右,无边框无标题 ---- */
.mm-tree-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
}
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease;
&:hover:not(:disabled) {
background: var(--fms-secondary);
color: var(--fms-text);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.mm-tree-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 8px;
/* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔 */
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.mm-tree-panel__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.mm-tree-search__icon {
color: var(--fms-text-secondary);
}
/* 树节点单行:名称(停用变灰)撑满剩余空间,类型 tag 靠右。
.mm-tree-node 是 .tree-node-content(flex 容器)的子元素,
需 flex: 1 占满整行宽度,内部名称的 flex: 1 才有空间可撑,tag 才能贴右。 */
.mm-tree-node {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
padding: 2px 0;
line-height: 1.3;
}
.mm-tree-node__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-disabled {
color: var(--fms-text-disabled, #9ca3af);
}
}
.mm-tree-empty {
padding: 20px 0;
text-align: center;
color: var(--fms-text-secondary);
font-size: 13px;
}
/* ---- 右侧主区 ---- */
.mm-main {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
/* 灰底画布上的白卡:与左侧树卡用灰缝分隔 */
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
}
.mm-main__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
color: var(--fms-text-secondary);
font-size: 13px;
}
/* 操作按钮组(Tabs 的 rightExtra 插槽内) */
.mm-header__actions {
display: flex;
align-items: center;
gap: 12px;
flex: none;
}
/* ---- 右侧:配置工作区 ---- */
.mm-body__inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- 配置导航:横向 Tab 导航(line 类型) ---- */
.mm-config-nav.tabs {
flex: none;
}
/* 页签高度(42px):仅作用于本页 tab 项,不改变组件内部样式,也不影响按钮 */
.mm-config-nav.tabs :deep(.tabs-tab) {
height: 42px;
}
/* 标签内布局:图标 + 文字 + badge */
.mm-config-nav__icon {
flex: none;
color: var(--fms-text-secondary);
}
.mm-config-nav .tabs-tab-active .mm-config-nav__icon {
color: var(--fms-primary);
}
.mm-config-nav__badge {
flex: none;
padding: 0 5px;
border-radius: 999px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 10px;
line-height: 16px;
}
/* 内容区:工作区内部滚动,面板各自滚动,避免多层滚动条 */
.mm-config-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mm-config-pane {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
/* 滚动容器内边距:内容与滚动边缘保持间距,聚焦光环(3px)不被 overflow: auto 裁剪 */
padding: 12px 16px 16px;
}
/* 配置区加载中 / 空态占位 */
.mm-config-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
min-height: 120px;
color: var(--fms-text-secondary);
font-size: 13px;
}
.mm-config-state__spinner {
width: 18px;
height: 18px;
border: 2px solid var(--fms-border);
border-top-color: var(--fms-primary);
border-radius: 50%;
animation: mm-config-spin 0.8s linear infinite;
}
@keyframes mm-config-spin {
to {
transform: rotate(360deg);
}
}
.mm-section-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
</style>