20260902173133

This commit is contained in:
oneao committed 2026-09-02 17:31:35 +08:00
1 parent 6b9cda5424
commit db7813a2d2
16 files changed
+1350 -675

No files matched your search

@@ -19,9 +19,24 @@ import {
RefreshCw,
Languages,
} from '@lucide/vue'
import { Button, Checkbox, Input, Select, Switch, Splitter, SplitterPanel, Tree, Modal, Message, Form, FormItem, Dropdown } from '@/components/ui'
import {
Button,
Checkbox,
Input,
Select,
Switch,
Splitter,
SplitterPanel,
Tree,
Modal,
Message,
Form,
FormItem,
Dropdown,
} from '@/components/ui'
import IconPicker from '@/components/icon-picker/IconPicker.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { loadDataApi, saveObjectApi, nextIdApi } from '@/services/api'
import { loadDataApi, loadDataBySqlApi, saveObjectApi, nextIdApi } from '@/services/api'
import {
buildTree,
sortTree,
@@ -290,6 +305,24 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
}
/* ================= 新增菜单 ================= */
/** 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段),用于未保存节点切回时重建草稿 */
function menuRowOf(node) {
return {
b_id: node.b_id,
b_code: node.b_code,
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
b_name: node.b_name,
b_i18n: node.b_i18n ?? null,
b_menu_type: node.b_menu_type,
b_route: node.b_route ?? null,
b_module_id: node.b_module_id ?? null,
b_power_code: node.b_power_code ?? null,
b_icon: node.b_icon ?? null,
b_xh: node.b_xh,
b_canuse: node.b_canuse,
}
}
async function addMenu(parentId, menuTypeValue = 'page') {
const res = await nextIdApi()
const newId = String(res.data)
@@ -400,16 +433,17 @@ async function loadMenuConfig(menuId) {
}
i18nValues.value = i18nValuesInit
originalI18nValues.value = { ...i18nValuesInit }
// 操作/按钮权限点:按 b_power_type='action' + b_module_id=菜单 id 加载
// 操作/按钮权限点:仅 page/data/external 才有(目录是导航容器,无按钮权限)
powersLoading.value = true
try {
const powerRes = menu
? await loadDataApi(
's_power',
`b_power_type = 'action' AND b_module_id = ${id}`,
'b_xh ASC, b_id ASC',
)
: { data: [] }
const powerRes =
menu && menu.b_menu_type !== 'directory'
? await loadDataApi(
's_power',
`b_power_type = 'action' AND b_module_id = ${id}`,
'b_xh ASC, b_id ASC',
)
: { data: [] }
powers.value = (powerRes.data || []).map((row) => ({
b_id: row.b_id,
b_code: row.b_code,
@@ -446,6 +480,26 @@ watch(menuSelectedId, (id) => {
drafts.value = null
return
}
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
// (会得到 menu 为 null 的「真值」drafts,模板按 null 渲染直接崩溃)。
// 从树节点重建草稿继续编辑;originals 与之一致(未编辑态)。
if (unsavedMenuIds.has(String(id))) {
const node = findTreeNode(menuTreeData.value, String(id))
configLoading.value = false
if (!node) {
loadedMenuId.value = null
drafts.value = null
return
}
drafts.value = cloneData({ menu: menuRowOf(node) })
originals.value = cloneData(drafts.value)
i18nValues.value = { 'zh-CN': node.b_name || '' }
originalI18nValues.value = { ...i18nValues.value }
powers.value = []
powersBaseline.value = '[]'
loadedMenuId.value = String(id)
return
}
loadedMenuId.value = null
drafts.value = null
void loadMenuConfig(id)
@@ -459,7 +513,10 @@ function hasUnsavedChanges() {
if (!drafts.value || !originals.value) return false
if (JSON.stringify(drafts.value.menu) !== JSON.stringify(originals.value.menu)) return true
// 多语言非默认语言变化视为未保存
if (nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code])) return true
if (
nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code])
)
return true
// 操作/按钮权限点变化视为未保存
const current = JSON.stringify(powers.value)
if (current !== powersBaseline.value) return true
@@ -514,7 +571,8 @@ const formRef = ref(null)
async function saveMenu() {
if (!selectedMenu.value || busy.value) return false
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value)) return false
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value))
return false
const menuId = String(selectedMenu.value.b_id)
// Validate form
@@ -535,8 +593,14 @@ async function saveMenu() {
const menuI18nKey = `menu.${menuData.b_code}`
menuData.b_i18n = menuI18nKey
// 权限编码留空时按 菜单编码 自动生成(权限跟着菜单走)
if (!menuData.b_power_code) menuData.b_power_code = `menu.${menuData.b_code}`
// 目录是纯导航容器:可见性由子菜单决定,自身不需要访问权限编码,也不生成权限点。
// 仅 page/data/external 需要访问权限编码;留空时按菜单编码自动生成(权限跟着菜单走)
const menuIsDirectory = menuData.b_menu_type === 'directory'
if (menuIsDirectory) {
menuData.b_power_code = null
} else if (!menuData.b_power_code) {
menuData.b_power_code = `menu.${menuData.b_code}`
}
const isNew = unsavedMenuIds.has(menuId)
const req = { table: 's_menu', key_field: 'b_id' }
@@ -568,15 +632,32 @@ async function saveMenu() {
reqs.push(i18nReq)
}
// 目录是纯导航容器:自身不需要访问权限编码与权限点。
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
// - 访问权限(s_power.b_power_type='menu',按原权限编码关联)
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
if (menuIsDirectory) {
const prevPowerCode = originals.value?.menu?.b_power_code
if (prevPowerCode) {
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'menu', b_resource: prevPowerCode }],
})
}
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
})
}
// 访问权限点(s_power.b_power_type='menu',自动同步)
const powerCode = (menuData.b_power_code || '').trim()
if (powerCode) {
// 查是否有已存在的访问权限(按 b_resource = 菜单权限编码)
const accessPowerRes = menuData.b_id
? await loadDataApi(
's_power',
`b_power_type = 'menu' AND b_resource = '${powerCode}'`,
)
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${powerCode}'`)
: { data: [] }
const existingAccess = accessPowerRes.data?.[0]
if (existingAccess) {
@@ -620,63 +701,66 @@ async function saveMenu() {
}
// 操作/按钮权限点(s_power.b_power_type='action',diff 保存)
const baselineIds = new Set()
try {
for (const r of JSON.parse(powersBaseline.value)) {
if (r.b_id) baselineIds.add(String(r.b_id))
// 目录是导航容器,不保存操作/按钮权限点
if (!menuIsDirectory && powers.value.length > 0) {
const baselineIds = new Set()
try {
for (const r of JSON.parse(powersBaseline.value)) {
if (r.b_id) baselineIds.add(String(r.b_id))
}
} catch {
/* 空基线 */
}
} catch { /* 空基线 */ }
const currentIds = new Set(
powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)),
)
// 被删除的行
for (const id of baselineIds) {
if (!currentIds.has(id)) {
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
const currentIds = new Set(powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)))
// 被删除的行
for (const id of baselineIds) {
if (!currentIds.has(id)) {
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
}
}
}
// 新增/更新的行
for (const row of powers.value) {
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
if (row.b_id && baselineIds.has(String(row.b_id))) {
reqs.push({
table: 's_power',
key_field: 'b_id',
updates: [
{
b_id: row.b_id,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
},
],
})
} else {
// 新增行需要分配主键
const newId = String((await nextIdApi()).data)
reqs.push({
table: 's_power',
key_field: 'b_id',
inserts: [
{
b_id: newId,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
},
],
})
// 新增/更新的行
for (const row of powers.value) {
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
if (row.b_id && baselineIds.has(String(row.b_id))) {
reqs.push({
table: 's_power',
key_field: 'b_id',
updates: [
{
b_id: row.b_id,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
},
],
})
} else {
// 新增行需要分配主键
const newId = String((await nextIdApi()).data)
reqs.push({
table: 's_power',
key_field: 'b_id',
inserts: [
{
b_id: newId,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
},
],
})
}
}
}
@@ -735,6 +819,7 @@ function onMenuTypeChange(v) {
if (v === 'directory') {
drafts.value.menu.b_route = null
drafts.value.menu.b_module_id = null
drafts.value.menu.b_power_code = null
} else if (v === 'page') {
drafts.value.menu.b_module_id = null
} else if (v === 'data') {
@@ -758,7 +843,9 @@ async function onToggleEnable(node, checked) {
// 未保存草稿仅本地,落库时生效
if (unsavedMenuIds.has(id)) return
try {
await saveObjectApi([{ table: 's_menu', key_field: 'b_id', updates: [{ b_id: id, b_canuse: newVal }] }])
await saveObjectApi([
{ table: 's_menu', key_field: 'b_id', updates: [{ b_id: id, b_canuse: newVal }] },
])
Message.success(newVal ? '菜单已启用' : '菜单已停用')
} catch (e) {
node.b_canuse = newVal === 1 ? 0 : 1
@@ -776,44 +863,34 @@ const powers = ref([])
const powersBaseline = ref('')
const powersLoading = ref(false)
/** 权限点表格列配置 */
/** 权限点表格列配置
* 列宽铺满约定(库文档「列宽铺满容器hack方式」):固定列设 width,
* 弹性列(权限名称)设 minWidth 占满剩余宽度,并在 colResizable 中禁用其拖拽。 */
const powerColumns = shallowRef([
{
dataIndex: 'b_code',
title: '权限编码',
width: 170,
editor: { type: 'input', placeholder: '如 add' },
width: 150,
editor: { type: 'input' },
},
{
dataIndex: 'b_name',
title: '权限名称',
width: 170,
editor: { type: 'input', placeholder: '如 新增' },
},
{
dataIndex: 'b_power_type',
title: '类型',
width: 130,
editor: {
type: 'select',
options: [
{ label: '操作权限', value: 'action' },
{ label: '按钮权限', value: 'button' },
],
},
minWidth: 150,
editor: { type: 'input' },
},
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
width: 70,
editor: { type: 'checkbox' },
},
{
dataIndex: 'actions',
title: '操作',
width: 70,
align: 'center',
width: 70,
actions: [
{
type: 'delete',
@@ -885,6 +962,81 @@ function batchDeletePowers() {
Message.success(`已删除 ${keys.length} 条权限`)
}
/* ================= 权限来源库(全库聚合,快速导入) ================= */
/** 全库去重的操作权限(s_power.b_power_type='action',按编码聚合) */
const powerLibrary = ref([])
const powerLibraryLoading = ref(false)
/** 来源库勾选的权限编码 */
const selectedLibCodes = ref([])
/** 来源库表格引用与行选择 */
const libTableRef = ref(null)
const powerLibraryColumns = shallowRef([
// 列宽铺满约定同 powerColumns:编码固定宽,名称弹性占满
{ dataIndex: 'b_code', title: '权限编码', width: 100 },
{ dataIndex: 'b_name', title: '权限名称', minWidth: 80 },
])
const libRowSelection = computed(() => ({
selectedRowKeys: selectedLibCodes.value,
onChange: (keys) => {
selectedLibCodes.value = keys
},
}))
/** 聚合全库出现的操作权限(编码/名称,去重) */
async function loadPowerLibrary() {
powerLibraryLoading.value = true
try {
// 参考旧系统 module-powervalue:把全库权限按编码聚合为"来源库"
const res = await loadDataBySqlApi(
`SELECT b_code, MAX(b_name) AS b_name FROM s_power
WHERE b_power_type = 'action' AND b_code IS NOT NULL AND b_code <> ''
GROUP BY b_code ORDER BY b_code`,
)
powerLibrary.value = (res.data || []).map((row) => ({
b_code: row.b_code,
b_name: row.b_name,
}))
selectedLibCodes.value = []
} catch (e) {
Message.error('加载权限库失败:' + (e?.message || e))
powerLibrary.value = []
} finally {
powerLibraryLoading.value = false
}
}
/** 把来源库勾选的权限导入当前菜单(跳过已存在编码) */
function importSelectedPowers() {
const codes = selectedLibCodes.value
if (!codes.length) {
Message.warning('请先在权限库中勾选要导入的权限')
return
}
const existing = new Set(powers.value.map((r) => String(r.b_code || '').trim()))
const added = []
for (const code of codes) {
if (existing.has(String(code).trim())) continue
const lib = powerLibrary.value.find((r) => String(r.b_code) === String(code))
added.push({
b_id: createTempId(),
b_code: lib?.b_code || code,
b_name: lib?.b_name || '',
b_power_type: 'action',
b_canuse: 1,
})
existing.add(String(code).trim())
}
if (!added.length) {
Message.warning('勾选的权限均已存在,无需导入')
return
}
powers.value = [...powers.value, ...added]
selectedLibCodes.value = []
Message.success(`已导入 ${added.length} 条权限`)
}
/* ================= 加载模块列表(供 data 类型下拉选择) ================= */
const moduleOptions = ref([])
let moduleOptionsLoaded = false
@@ -892,7 +1044,11 @@ let moduleOptionsLoaded = false
async function loadModuleOptions() {
if (moduleOptionsLoaded) return
try {
const res = await loadDataApi('s_module', "b_canuse = 1 AND b_module_type IN ('data', 'query')", 'b_xh ASC, b_id ASC')
const res = await loadDataApi(
's_module',
"b_canuse = 1 AND b_module_type IN ('data', 'query')",
'b_xh ASC, b_id ASC',
)
moduleOptions.value = (res.data || []).map((m) => ({
label: `[${m.b_code}] ${m.b_name}`,
value: String(m.b_id),
@@ -937,6 +1093,7 @@ async function onConfirmOk() {
/* ================= 初始化 ================= */
loadMenuTree()
loadModuleOptions()
loadPowerLibrary()
</script>
<template>
@@ -946,7 +1103,7 @@ loadModuleOptions()
<SplitterPanel :default-size="248" :min="180" :max="420">
<aside class="mm-tree-panel">
<div class="mm-tree-header">
<Input v-model="menuKeyword" placeholder="搜索菜单" allow-clear class="mm-tree-search">
<Input v-model="menuKeyword" allow-clear class="mm-tree-search">
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<div class="mm-tree-header__actions">
@@ -960,7 +1117,12 @@ loadModuleOptions()
<RefreshCw :size="14" />
</Button>
<Dropdown :items="toolbarAddOptions" @click="onToolbarAdd">
<Button type="ghost" class="mm-tree-header__icon-btn" title="新增菜单" aria-label="新增菜单">
<Button
type="ghost"
class="mm-tree-header__icon-btn"
title="新增菜单"
aria-label="新增菜单"
>
<Plus :size="14" />
</Button>
</Dropdown>
@@ -1027,30 +1189,35 @@ loadModuleOptions()
</Button>
</div>
<!-- 编辑面板 -->
<div class="mm-body__inner">
<div v-if="drafts" class="mm-config-content">
<div class="mm-config-pane">
<Form
<!-- 主体:三块 Splitter 分割 -->
<div v-if="drafts" class="mm-edit-body">
<Splitter class="mm-edit-splitter">
<SplitterPanel :default-size="360">
<div class="mm-form-col__card">
<div class="mm-form-col__head">
<div class="mm-pane__title">菜单配置</div>
</div>
<Form
ref="formRef"
:model="drafts.menu"
:rules="{
b_code: [{ required: true, message: '请填写菜单编码' }],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route: isPage || isExternal ? [{ required: true, message: '请填写路由地址' }] : [],
b_route:
isPage || isExternal
? [{ required: true, message: '请填写路由地址' }]
: [],
b_module_id: isData ? [{ required: true, message: '请选择绑定模块' }] : [],
}"
layout="vertical"
:columns="3"
max-width="720px"
:gap="20"
>
<FormItem label="菜单名称" name="b_name" required>
<div class="mm-name-field">
<Input
class="mm-name-field__input"
:model-value="drafts.menu.b_name"
placeholder="请输入菜单名称(简体中文)"
@update:model-value="(v) => setField('b_name', v)"
/>
<Button
@@ -1066,7 +1233,6 @@ loadModuleOptions()
<FormItem label="菜单编码" name="b_code" required>
<Input
:model-value="drafts.menu.b_code"
placeholder="请输入菜单编码,全局唯一"
@update:model-value="(v) => setField('b_code', v)"
/>
</FormItem>
@@ -1074,17 +1240,15 @@ loadModuleOptions()
<Select
:model-value="drafts.menu.b_menu_type"
:options="menuTypeOptions"
placeholder="请选择菜单类型"
@update:model-value="onMenuTypeChange"
/>
</FormItem>
<FormItem label="上级菜单">
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
</FormItem>
<FormItem label="图标" name="b_icon">
<Input
<IconPicker
:model-value="drafts.menu.b_icon"
placeholder="lucide 图标名,如 Settings"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
@@ -1099,7 +1263,6 @@ loadModuleOptions()
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
<Input
:model-value="drafts.menu.b_route"
placeholder="如 /system/module-management"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
@@ -1108,7 +1271,6 @@ loadModuleOptions()
<Select
:model-value="drafts.menu.b_module_id"
:options="moduleOptions"
placeholder="请选择绑定的数据/查询模块"
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
@@ -1116,7 +1278,6 @@ loadModuleOptions()
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
<Input
:model-value="drafts.menu.b_route"
placeholder="如 https://example.com"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
@@ -1126,12 +1287,11 @@ loadModuleOptions()
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<FormItem label="访问权限编码" name="b_power_code">
<FormItem v-if="!isDirectory" label="访问权限编码" name="b_power_code">
<div class="mm-power-code-field">
<Input
class="mm-power-code-field__input"
:model-value="drafts.menu.b_power_code"
placeholder="留空保存时自动生成 menu.编码"
@update:model-value="(v) => setField('b_power_code', v)"
/>
<Button type="outline" size="small" @click="autoPowerCode">
@@ -1140,51 +1300,99 @@ loadModuleOptions()
</div>
</FormItem>
</Form>
</div>
</div>
</SplitterPanel>
<!-- 权限点:访问(自动)+ 操作/按钮 -->
<div class="mm-config-pane">
<div class="mm-pane__title">权限点</div>
<!-- 访问权限:随菜单自动同步(只读展示) -->
<div class="mm-access-bar">
<span class="mm-access-bar__tag">访问</span>
<span class="mm-access-bar__code">{{ accessPowerDesc || '(待生成)' }}</span>
<span class="mm-access-bar__hint">自动同步,随菜单保存</span>
<!-- 权限面板:仅 page/data/external(目录无权限) -->
<SplitterPanel v-if="!isDirectory">
<div class="mm-power-card">
<div class="mm-power-card__head">
<div class="mm-pane__title">当前菜单权限</div>
<div class="mm-power-toolbar">
<Button
type="primary"
size="small"
:disabled="powersLoading"
@click="addPowerRow"
>
<Plus :size="12" /> 新增
</Button>
<Button
type="danger"
size="small"
:disabled="powersLoading || !selectedPowerKeys.length"
@click="batchDeletePowers"
>
<Trash2 :size="12" /> 删除{{
selectedPowerKeys.length ? `(${selectedPowerKeys.length})` : ''
}}
</Button>
</div>
</div>
<div v-if="powersLoading" class="mm-power-loading">加载中...</div>
<div v-else class="mm-power-table-wrap">
<FmsTable
ref="powerTableRef"
v-model:columns="powerColumns"
:data-source="powers"
:editable="true"
row-key="b_id"
:row-selection="rowSelection"
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
@change="() => {}"
/>
</div>
</div>
<div class="mm-power-toolbar">
<Button type="primary" size="small" :disabled="powersLoading" @click="addPowerRow">
<Plus :size="12" /> 新增权限
</Button>
<Button
type="danger"
size="small"
:disabled="powersLoading || !selectedPowerKeys.length"
@click="batchDeletePowers"
>
<Trash2 :size="12" /> 批量删除{{ selectedPowerKeys.length ? `(${selectedPowerKeys.length})` : '' }}
</Button>
<span class="mm-power-toolbar__hint">操作/按钮权限用于页面的按钮级控制。</span>
</SplitterPanel>
<!-- 权限来源库:全库聚合,快速导入 -->
<SplitterPanel v-if="!isDirectory" :default-size="260">
<div class="mm-power-card">
<div class="mm-power-card__head">
<div class="mm-pane__title">权限库</div>
<div class="mm-power-toolbar">
<Button
type="outline"
size="small"
:loading="powerLibraryLoading"
@click="loadPowerLibrary"
>
<RefreshCw :size="12" /> 刷新
</Button>
<Button
type="primary"
size="small"
:disabled="!selectedLibCodes.length"
@click="importSelectedPowers"
>
<Plus :size="12" /> 导入选中{{
selectedLibCodes.length ? `(${selectedLibCodes.length})` : ''
}}
</Button>
</div>
</div>
<div v-if="powerLibraryLoading" class="mm-power-loading">加载中...</div>
<div v-else class="mm-lib-table-wrap">
<FmsTable
ref="libTableRef"
v-model:columns="powerLibraryColumns"
:data-source="powerLibrary"
:seq="false"
row-key="b_code"
:row-selection="libRowSelection"
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
@change="() => {}"
/>
</div>
</div>
<div v-if="powersLoading" class="mm-power-loading">加载中...</div>
<div v-else class="mm-power-table-wrap">
<FmsTable
ref="powerTableRef"
v-model:columns="powerColumns"
:data-source="powers"
:editable="true"
row-key="b_id"
:row-selection="rowSelection"
@change="() => {}"
/>
</div>
</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>
</SplitterPanel>
</Splitter>
</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>
</template>
<div v-else class="mm-main__empty">
<FolderTree :size="32" />
@@ -1205,7 +1413,9 @@ loadModuleOptions()
>
<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="outline" :disabled="confirmSaving" @click="confirmVisible = false"
>取消</Button
>
<Button
:type="confirmState?.okType || 'primary'"
:loading="confirmSaving"
@@ -1397,19 +1607,6 @@ loadModuleOptions()
font-size: 13px;
}
/* 各配置区块(多语言 / 权限)间距 */
.mm-config-pane + .mm-config-pane {
margin-top: 16px;
}
/* 区块标题 */
.mm-pane__title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--fms-text);
}
/* 菜单名称 + 多语言图标按钮 */
.mm-name-field {
display: flex;
@@ -1432,7 +1629,6 @@ loadModuleOptions()
display: flex;
align-items: center;
gap: 8px;
max-width: 560px;
}
.mm-power-code-field__input {
@@ -1440,69 +1636,6 @@ loadModuleOptions()
min-width: 0;
}
/* 权限点列表 */
.mm-power-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.mm-power-toolbar__hint {
font-size: 12px;
color: var(--fms-text-secondary);
}
.mm-power-loading {
padding: 20px 0;
text-align: center;
color: var(--fms-text-secondary);
font-size: 13px;
}
/* 访问权限条(自动同步展示) */
.mm-access-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 6px 10px;
border: 1px dashed var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary);
font-size: 12px;
max-width: 720px;
}
.mm-access-bar__tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: 3px;
background: var(--fms-primary, #1677ff);
color: #fff;
font-size: 11px;
line-height: 18px;
}
.mm-access-bar__code {
flex-shrink: 0;
font-weight: 600;
color: var(--fms-text);
}
.mm-access-bar__hint {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fms-text-secondary);
}
/* 权限点 FmsTable 容器(固定高度,FmsTable 需要显式高度渲染) */
.mm-power-table-wrap {
max-width: 720px;
height: 300px;
}
/* ---- 右侧主区 ---- */
.mm-main {
display: flex;
@@ -1513,10 +1646,7 @@ loadModuleOptions()
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
padding: 8px 10px;
gap: 8px;
}
.mm-main__empty {
@@ -1528,6 +1658,9 @@ loadModuleOptions()
flex: 1;
color: var(--fms-text-secondary);
font-size: 14px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
svg {
color: var(--fms-text-secondary);
@@ -1540,24 +1673,123 @@ loadModuleOptions()
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 0;
flex-shrink: 0;
padding: 8px 10px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
}
/* ---- 内容区 ---- */
.mm-body__inner {
/* ---- 主体三块 Splitter ---- */
.mm-edit-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
display: flex;
overflow: hidden;
}
.mm-config-content {
.mm-edit-splitter :deep(> .fms-splitter-panel) {
overflow: hidden;
padding: 0 3px;
}
/* 第一个面板(菜单配置)去左边距;最后一个面板(权限库)去右边距 */
.mm-edit-splitter :deep(> .fms-splitter-panel:first-child) {
padding-left: 0;
}
.mm-edit-splitter :deep(> .fms-splitter-panel:last-child) {
padding-right: 0;
}
/* 左栏:表单卡片 */
.mm-form-col__card {
height: 100%;
padding: 8px 10px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
}
/* 左卡片头部 */
.mm-form-col__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0;
.mm-pane__title {
margin-bottom: 0;
}
}
/* 右栏:权限卡片 */
.mm-power-card {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px 10px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.mm-power-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
.mm-pane__title {
margin-bottom: 0;
}
}
/* 权限点 FmsTable 容器(撑满卡片剩余高度) */
.mm-power-table-wrap {
flex: 1;
min-height: 0;
height: 100%;
}
.mm-config-pane {
max-width: 720px;
/* 来源库表格容器(撑满卡片剩余高度) */
.mm-lib-table-wrap {
flex: 1;
min-height: 0;
height: 100%;
}
/* 权限点列表工具条 */
.mm-power-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.mm-power-loading {
padding: 20px 0;
text-align: center;
color: var(--fms-text-secondary);
font-size: 13px;
}
/* 区块标题 */
.mm-pane__title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--fms-text);
}
/* 各配置区块间距 */
.mm-config-pane + .mm-config-pane {
margin-top: 16px;
}
.mm-config-state {
@@ -1566,7 +1798,7 @@ loadModuleOptions()
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
flex: 1;
color: var(--fms-text-secondary);
font-size: 14px;
}
@@ -1585,4 +1817,4 @@ loadModuleOptions()
transform: rotate(360deg);
}
}
</style>
</style>
@@ -467,7 +467,6 @@ const treeFeatures = computed(() => ({
:loading="loading"
:data-columns="dataColumns"
:active-key="activeKey"
:row-drag-handle="false"
@back="switchView('design')"
@row-click="onDataRowClick"
@change="() => emit('update', localRows.value)"
@@ -12,8 +12,6 @@ defineProps({
dataColumns: { type: Array, required: true },
/** 高亮行 id(行点击由壳组件更新) */
activeKey: { type: [String, Number], default: null },
/** 是否显示行拖拽把手(表格编辑器关闭,避免与列宽拖拽视觉混淆) */
rowDragHandle: { type: Boolean, default: true },
/** 是否显示「返回设计视图」按钮(数据视图常驻的宿主传 false) */
showBack: { type: Boolean, default: true },
})
@@ -32,7 +30,6 @@ const emit = defineEmits(['back', 'row-click', 'change', 'row-order-change'])
:data-source="rows"
:editable="!loading"
:row-draggable="!loading"
:row-drag-handle="rowDragHandle"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => emit('row-click', row)"
@@ -38,12 +38,17 @@ import {
Message,
} from '@/components/ui'
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api'
import { buildTree, sortTree, findTreeNode, collectAllKeys, expandAncestors, filterTreeByText, insertTreeNode, removeTreeNode } from '@/utils/tree'
import {
buildModuleI18nKey,
buildModuleItemI18nKey,
syncModuleItemI18nKeys,
} from '@/utils/i18n'
buildTree,
sortTree,
findTreeNode,
collectAllKeys,
expandAncestors,
filterTreeByText,
insertTreeNode,
removeTreeNode,
} from '@/utils/tree'
import { buildModuleI18nKey, buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import { createTempId } from '@/utils/tempId'
import {
cloneData,
@@ -176,7 +181,11 @@ function validateAutoCode() {
async function saveModule() {
if (!selectedModule.value || busy.value) return false
if (loadedModuleId.value == null || String(loadedModuleId.value) !== String(moduleSelectedId.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 {
@@ -207,7 +216,10 @@ async function saveModule() {
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))) {
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]
@@ -348,7 +360,9 @@ watch(moduleKeyword, (kw) => {
moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : []
})
const filteredModuleTree = computed(() => filterTreeByText(moduleTreeData.value, moduleKeyword.value))
const filteredModuleTree = computed(() =>
filterTreeByText(moduleTreeData.value, moduleKeyword.value),
)
// 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除)
function hasSavedModuleDescendant(node) {
@@ -404,6 +418,27 @@ function onModuleContextMenuClick({ item, node }) {
else if (item.key === 'delete') void deleteModule(parentId)
}
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),用于未保存模块切回时重建草稿 */
function moduleRowOf(node) {
return {
b_id: node.b_id,
b_code: node.b_code,
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
b_name: node.b_name,
b_i18n: node.b_i18n ?? null,
b_module_type: node.b_module_type,
b_viewtable: node.b_viewtable ?? null,
b_savetable: node.b_savetable ?? null,
b_keyfield: node.b_keyfield ?? null,
b_orderfield: node.b_orderfield ?? null,
b_delete_policy: node.b_delete_policy ?? null,
b_edit_mode: node.b_edit_mode ?? null,
b_canuse: node.b_canuse,
b_xh: node.b_xh,
b_bz: node.b_bz ?? null,
}
}
async function addModule(parentId, moduleType) {
const res = await nextIdApi()
const newId = String(res.data)
@@ -627,7 +662,8 @@ async function loadModuleConfig(moduleId) {
loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'),
loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'),
])
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes] = responses
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes] =
responses
} else if (type === 'query') {
const responses = await Promise.all([
loadDataApi('s_module', `b_id = ${id}`),
@@ -657,7 +693,10 @@ async function loadModuleConfig(moduleId) {
// 只取原始扁平行传给 ModuleI18nPanel,由面板自持 pivot/reconcile/保存 diff
rawI18nRows.value = i18nRes
? (i18nRes.data || []).map((row) => ({
b_id: row.b_id, b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value,
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}))
: []
// 列表配置不再自动补全:行存在即展示,删除即真删,由用户通过字段库手动管理(与查询配置一致)。
@@ -687,6 +726,30 @@ watch(moduleSelectedId, (id) => {
rawI18nRows.value = []
return
}
// 未落库的新模块:临时 id 查库无行,不走 loadModuleConfig(会得到 module 为 null 的草稿)。
// 从树节点重建草稿(与 addModule 的初始空草稿同构),子配置(字段/分组等)从空开始。
if (unsavedModuleIds.has(String(id))) {
const node = findTreeNode(moduleTreeData.value, String(id))
configLoading.value = false
if (!node) {
loadedModuleId.value = null
drafts.value = null
return
}
drafts.value = cloneData({
module: moduleRowOf(node),
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
autoCode: [],
})
rawI18nRows.value = []
originals.value = cloneData(drafts.value)
loadedModuleId.value = String(id)
return
}
// 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading)
loadedModuleId.value = null
drafts.value = null
@@ -702,16 +765,10 @@ const switchSaving = ref(false)
// 是否有未保存的修改(仅切换时按需检查,不做实时监听)
function hasUnsavedChanges() {
if (!drafts.value || !originals.value) return false
const keys = [
'module',
'fields',
'groups',
'listConfig',
'editConfig',
'queryConfig',
'autoCode',
]
return keys.some((key) => JSON.stringify(drafts.value[key]) !== JSON.stringify(originals.value[key]))
const keys = ['module', 'fields', 'groups', 'listConfig', 'editConfig', 'queryConfig', 'autoCode']
return keys.some(
(key) => JSON.stringify(drafts.value[key]) !== JSON.stringify(originals.value[key]),
)
}
function onSwitchCancel() {
@@ -827,7 +884,8 @@ function buildSyncPlan(module, columns) {
for (const existing of drafts.value.fields || []) {
if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) {
removedIds.add(String(existing.b_id))
const key = existing.b_i18n || buildModuleItemI18nKey('field', module.b_code, existing.b_field)
const key =
existing.b_i18n || buildModuleItemI18nKey('field', module.b_code, existing.b_field)
if (key) removedI18nIds.add(`field:${key}`)
}
}
@@ -927,209 +985,224 @@ loadModuleTree()
<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">
<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="新建模块">
<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"
:draggable="!moduleKeyword.trim()"
@select="onModuleSelect"
@context-menu-click="onModuleContextMenuClick"
@drop="onModuleDrop"
>
<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>
<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"
:draggable="!moduleKeyword.trim()"
@select="onModuleSelect"
@context-menu-click="onModuleContextMenuClick"
@drop="onModuleDrop"
>
<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" />
<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)"
/>
</div>
<div
<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"
v-show="activeSection === 'fields'"
class="mm-config-pane"
type="outline"
:disabled="busy"
title="按数据库视图结构对齐字段定义"
@click="syncFields"
>
<ModuleFieldsPanel
:module-id="String(selectedModule.b_id)"
:module-code="drafts?.module?.b_code || ''"
:fields="drafts?.fields"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:module-type="moduleType"
:loading="busy"
@update="onFieldsUpdate"
@update-edit-config="(v) => onModuleUpdate('editConfig', v)"
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
@update-query-config="(v) => onModuleUpdate('queryConfig', v)"
/>
</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"
:fields="drafts?.fields"
:groups="drafts?.groups"
:loading="busy"
@update="(v) => onModuleUpdate('listConfig', v)"
@update-groups="onGroupsUpdate"
/>
</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"
@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="moduleType && moduleType !== 'category'"
v-show="activeSection === 'i18n'"
class="mm-config-pane"
>
<ModuleI18nPanel
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:module="drafts?.module"
:fields="drafts?.fields"
:groups="drafts?.groups"
:raw-rows="rawI18nRows"
:loading="busy"
/>
</div>
同步字段
</Button>
</div>
<!-- 加载中/无数据占位:避免配置区一片空白 -->
<div v-else-if="configLoading" class="mm-config-state">
<span class="mm-config-state__spinner"></span>
<span>加载中...</span>
<div class="mm-body__inner">
<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>
</Tabs>
<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)"
/>
</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"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:module-type="moduleType"
:loading="busy"
@update="onFieldsUpdate"
@update-edit-config="(v) => onModuleUpdate('editConfig', v)"
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
@update-query-config="(v) => onModuleUpdate('queryConfig', v)"
/>
</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"
:fields="drafts?.fields"
:groups="drafts?.groups"
:loading="busy"
@update="(v) => onModuleUpdate('listConfig', v)"
@update-groups="onGroupsUpdate"
/>
</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"
@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="moduleType && moduleType !== 'category'"
v-show="activeSection === 'i18n'"
class="mm-config-pane"
>
<ModuleI18nPanel
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:module="drafts?.module"
:fields="drafts?.fields"
:groups="drafts?.groups"
:raw-rows="rawI18nRows"
: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>
<div v-else class="mm-config-state">暂无配置数据</div>
</div>
</template>
<div v-else class="mm-main__empty">
<FolderTree :size="32" />
<span>请选择左侧模块节点</span>
</div>
</main>
</main>
</SplitterPanel>
</Splitter>
</div>
@@ -1144,7 +1217,9 @@ loadModuleTree()
>
<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="outline" :disabled="confirmSaving" @click="confirmVisible = false"
>取消</Button
>
<Button
:type="confirmState?.okType || 'primary'"
:loading="confirmSaving"
@@ -1320,10 +1395,7 @@ loadModuleTree()
min-width: 0;
min-height: 0;
overflow: hidden;
/* 灰底画布上的白卡:与左侧树卡用灰缝分隔 */
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
gap: 8px;
}
.mm-main__empty {
@@ -1335,6 +1407,13 @@ loadModuleTree()
flex: 1;
color: var(--fms-text-secondary);
font-size: 13px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
svg {
opacity: 0.4;
}
}
/* 顶部通栏操作条:保存 / 同步字段(位于 tabs 之上,全 tab 生效) */
@@ -1344,6 +1423,8 @@ loadModuleTree()
gap: 8px;
flex: none;
padding: 8px 10px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
}
/* ---- 右侧:配置工作区 ---- */
@@ -1352,16 +1433,21 @@ loadModuleTree()
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
/* ---- 配置导航:横向 Tab 导航(line 类型) ---- */
/* ---- 配置导航:横向 Tab 导航(line 类型,使用默认 tabs 底边线 ---- */
.mm-config-nav.tabs {
flex: none;
}
/* 页签高度(42px):仅作用于本页 tab 项,不改变组件内部样式,也不影响按钮 */
/* 页签高度:仅作用于本页 tab 项,不改变组件内部样式,也不影响按钮 */
.mm-config-nav.tabs :deep(.tabs-tab) {
height: 42px;
height: 38px;
}
/* 标签内布局:图标 + 文字 + badge */