1521 lines
51 KiB
Vue
1521 lines
51 KiB
Vue
<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>
|