Files
workspace/code/fms/fms-vue/src/views/system/module-management/index.vue
T
oneao fefdcb9ca8 refactor(module-management): R3 完成——form-edit 拆分收尾
- 接 index.vue:ModuleFormEditPanel import 改为 ./components/form-edit/FormEditPanel.vue
- 删除旧 components/ModuleFormEditPanel.vue(3168 行)
- 清理壳组件死样式(lib/ inspector/ prop/ toggle 等已迁至子组件)
- 验证:form-edit 组件图编译通过,dnd 14 例全绿,lint 零新增告警

按停损条款:FormCanvas/useFormDrag 交织最深且无测试保护,保留现状,留待下轮。
2026-08-28 19:04:13 +08:00

908 lines
28 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)读写元数据表。
* 树/草稿/保存逻辑分别抽到 composables/useModuleTree、useModuleConfig、
* useModuleSave;确认弹窗状态机仅本页使用,直接内联在下方。
* 本文件保留模板、状态接线与面板编排。
*/
import { computed, ref } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
import {
Plus,
FolderPlus,
Database,
Search,
Save,
Table2,
List,
Pencil,
Hash,
RefreshCw,
Shield,
Globe,
Info,
FolderTree,
} from '@lucide/vue'
import {
Button,
Input,
Tree,
Dropdown,
Splitter,
SplitterPanel,
Tabs,
Modal,
Message,
} from '@/components/ui'
import { buildModuleI18nKey, buildModuleItemI18nKey } from '@/utils/i18n'
import { useModuleTree, findModuleNode } from './composables/useModuleTree'
import { useModuleConfig } from './composables/useModuleConfig'
import { useModuleSave } from './composables/useModuleSave'
import ModuleBasicPanel from './components/ModuleBasicPanel.vue'
import ModuleFieldsPanel from './components/ModuleFieldsPanel.vue'
import ModuleListConfigPanel from './components/ModuleListConfigPanel.vue'
import ModuleFormEditPanel from './components/form-edit/FormEditPanel.vue'
import ModuleTableEditPanel from './components/table-edit/TableEditPanel.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'
/* ================= 共享状态(index.vue 组装后传入各 composable) ================= */
const moduleTreeData = ref([])
const moduleTreeLoading = ref(false)
const moduleKeyword = ref('')
const moduleSelectedId = ref(null)
const moduleExpandedKeys = ref([])
const unsavedModuleIds = new Set()
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)
// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享
const skipModuleLoad = { value: false }
/* ================= 派生状态(index.vue 组装) ================= */
const selectedModule = computed(() =>
moduleSelectedId.value ? findModuleNode(moduleTreeData.value, moduleSelectedId.value) : null,
)
const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value)
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 moduleType = computed(() => selectedModule.value?.b_module_type || null)
const isDataModule = computed(() => moduleType.value === 'data')
const isQueryModule = computed(() => moduleType.value === 'query')
/* ================= 编辑方式 / 导航 / i18n 展示(面板编排) ================= */
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
})
/* 右侧分组配置导航:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。
每个导航项唯一激活(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 }))),
)
/* ================= composables ================= */
const { saveModule } = useModuleSave({
selectedModule,
drafts,
originals,
loadedModuleId,
moduleSelectedId,
unsavedModuleIds,
configSaving,
busy,
activeSection,
moduleTreeData,
findModuleNode,
basicPanelRef,
autoCodePanelRef,
i18nPanelRef,
getFinalI18nItems: () => finalI18nItems.value,
})
/* ================= 确认弹窗状态机(仅本页使用,不抽 composable) ================= */
/* 通用确认弹窗(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
}
}
/* 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
switchConfirm.value = null
switchSaving.value = false
}
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
}
}
const {
filteredModuleTree,
loadModuleTree,
buildModuleContextMenu,
onModuleContextMenuClick,
addModule,
} = useModuleTree({
moduleTreeData,
moduleTreeLoading,
moduleKeyword,
moduleExpandedKeys,
moduleSelectedId,
unsavedModuleIds,
drafts,
originals,
loadedModuleId,
skipModuleLoad,
busy,
showConfirm,
selectedModule,
})
const {
reloadModule,
onModuleSelect,
onFieldsUpdate,
onAutoCodeUpdate,
syncFields,
} = useModuleConfig({
moduleSelectedId,
moduleTreeData,
findModuleNode,
selectedModule,
busy,
configLoading,
configSyncing,
activeSection,
loadedModuleId,
originals,
drafts,
skipModuleLoad,
showConfirm,
switchConfirm,
i18nPanelRef,
})
/* ================= 快捷键 ================= */
// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys)
useHotkeys([
{
key: 's',
mod: true,
ignoreWhenTyping: false, // 输入框内也能保存
handler: () => void saveModule(),
},
])
/* ================= 面板回调 ================= */
function onModuleUpdate(section, newData) {
if (drafts.value) drafts.value[section] = newData
}
function onModuleI18nSaved() {
i18nPanelRef.value?.refreshKey?.()
}
/* ================= 左树顶部工具栏:新建 / 刷新 ================= */
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)
}
/* ================= 初始化 ================= */
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="ghost" 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>