使用者试用后决定不要:清单信息与 tab 徽标(脏点/缺 N)重复,且占据概览篇幅。 revert 29f98494(组件、spec、index.vue 接线全部移除),计划文档记录该决策。 阶段一(脏点)与阶段二(表单设计缺 N 徽标)保留。
937 lines
29 KiB
Vue
937 lines
29 KiB
Vue
<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,
|
||
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 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}` })
|
||
}
|
||
// 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管)
|
||
for (const group of drafts.value?.groups || []) {
|
||
const key = String(group.b_i18n || '').trim()
|
||
if (key) items.push({ key, managed: true, defaultValue: group.b_title || '', id: `group:${group.b_id}` })
|
||
}
|
||
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) {
|
||
// 表单设计缺配徽标:启用字段未布局的数量(口径与查询「缺 N」一致)
|
||
uiItems.push({
|
||
key: 'edit',
|
||
label: '表单设计',
|
||
icon: Pencil,
|
||
badge: missingEditCount.value ? `缺 ${missingEditCount.value}` : '',
|
||
})
|
||
}
|
||
// 列表配置行在加载时自动补全全部启用字段,无「缺失」概念,不带徽标
|
||
uiItems.push({ key: 'list', label: '列表配置', icon: List })
|
||
uiItems.push({
|
||
key: 'query',
|
||
label: '查询配置',
|
||
icon: Search,
|
||
badge: missingQueryCount.value ? `缺 ${missingQueryCount.value}` : '',
|
||
})
|
||
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 两面板 props/emits 契约一致,用动态组件消除模板重复 */
|
||
const activeEditPanel = computed(() => (editMode.value === 'table' ? ModuleTableEditPanel : ModuleFormEditPanel))
|
||
|
||
/* tab 脏点:tab key → drafts section 映射。groups 在列表/表单两个面板都可编辑,
|
||
分组变化时两个 tab 都标脏;多语言 tab 的数据独立保存(不走 drafts),不参与脏检查 */
|
||
const TAB_SECTION_MAP = {
|
||
basic: ['module'],
|
||
fields: ['fields'],
|
||
list: ['listConfig', 'groups'],
|
||
edit: ['editConfig', 'groups'],
|
||
query: ['queryConfig'],
|
||
'auto-code': ['autoCode'],
|
||
powers: ['powers'],
|
||
}
|
||
|
||
const dirtyTabKeys = computed(() => {
|
||
const keys = new Set()
|
||
for (const [tabKey, sections] of Object.entries(TAB_SECTION_MAP)) {
|
||
if (sections.some((section) => dirtySections.value.has(section))) keys.add(tabKey)
|
||
}
|
||
return keys
|
||
})
|
||
|
||
/* ================= 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 {
|
||
onModuleSelect,
|
||
onFieldsUpdate,
|
||
onAutoCodeUpdate,
|
||
syncFields,
|
||
dirtySections,
|
||
missingQueryCount,
|
||
missingEditCount,
|
||
fillMissingConfigs,
|
||
} = 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 onGroupsUpdate(groups, removedGroupIds) {
|
||
if (!drafts.value) return
|
||
drafts.value.groups = groups
|
||
const removed = new Set((removedGroupIds || []).map(String))
|
||
if (!removed.size) return
|
||
for (const section of ['listConfig', 'editConfig']) {
|
||
drafts.value[section] = (drafts.value[section] || []).map((row) =>
|
||
removed.has(String(row.b_group_id)) ? { ...row, b_group_id: null } : row,
|
||
)
|
||
}
|
||
}
|
||
|
||
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">
|
||
<!-- 顶部通栏操作:保存 / 同步字段(全 tab 生效,不随内容区切换) -->
|
||
<div class="mm-main-toolbar">
|
||
<Button
|
||
type="primary"
|
||
:loading="configSaving"
|
||
:disabled="configLoading || !drafts"
|
||
@click="saveModule"
|
||
>
|
||
<Save :size="14" /> 保存
|
||
</Button>
|
||
<Button
|
||
v-if="isDataModule || isQueryModule"
|
||
type="outline"
|
||
:disabled="busy"
|
||
title="按数据库视图结构对齐字段定义"
|
||
@click="syncFields"
|
||
>
|
||
同步字段
|
||
</Button>
|
||
</div>
|
||
|
||
<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" />
|
||
<!-- 脏点:有未保存修改的 tab 在图标后显示实心圆点(形状+title,不只靠颜色) -->
|
||
<span
|
||
v-if="dirtyTabKeys.has(item.key)"
|
||
class="mm-config-nav__dirty"
|
||
role="img"
|
||
title="有未保存修改"
|
||
aria-label="有未保存修改"
|
||
></span>
|
||
<span class="mm-config-nav__label">{{ item.label }}</span>
|
||
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
|
||
</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"
|
||
>
|
||
<ModuleFieldsPanel
|
||
:module-id="String(selectedModule.b_id)"
|
||
:module-code="drafts?.module?.b_code || ''"
|
||
:fields="drafts?.fields"
|
||
:loading="busy"
|
||
@update="onFieldsUpdate"
|
||
@i18n-saved="onModuleI18nSaved"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-if="isDataModule || isQueryModule"
|
||
v-show="activeSection === 'list'"
|
||
class="mm-config-pane"
|
||
>
|
||
<ModuleListConfigPanel
|
||
:module-id="String(selectedModule.b_id)"
|
||
:module-code="drafts?.module?.b_code || ''"
|
||
:list-config="drafts?.listConfig"
|
||
:fields="drafts?.fields"
|
||
:groups="drafts?.groups"
|
||
:loading="busy"
|
||
@update="(v) => onModuleUpdate('listConfig', v)"
|
||
@update-groups="onGroupsUpdate"
|
||
@i18n-saved="onModuleI18nSaved"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="isDataModule" v-show="activeSection === 'edit'" class="mm-config-pane">
|
||
<component
|
||
:is="activeEditPanel"
|
||
:module-id="String(selectedModule.b_id)"
|
||
:edit-config="drafts?.editConfig"
|
||
:list-config="drafts?.listConfig"
|
||
: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="onGroupsUpdate"
|
||
@update-list-config="(v) => onModuleUpdate('listConfig', 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"
|
||
:missing-count="missingQueryCount"
|
||
@update="(v) => onModuleUpdate('queryConfig', v)"
|
||
@fill-missing="() => fillMissingConfigs(['queryConfig'])"
|
||
/>
|
||
</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 之上,全 tab 生效) */
|
||
.mm-main-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: none;
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
/* ---- 右侧:配置工作区 ---- */
|
||
.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);
|
||
}
|
||
|
||
/* 脏点:作为 label 插槽的 flex 子元素排在图标后,间距由 .tabs-tab 的 gap 统一管理 */
|
||
.mm-config-nav__dirty {
|
||
flex: none;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--fms-primary);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
</style>
|