Files
workspace/code/fms/fms-vue/src/views/system/module-management/index.vue
T
2026-08-21 23:06:56 +08:00

1379 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/**
* 模块管理:模块配置 | 菜单配置(两分区,自定义导航切换,不使用 Tabs 组件样式)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。
* 菜单配置:左侧菜单树 + 右侧面板(基础/权限/多语言)。
* 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。
*/
import { computed, nextTick, ref, watch } from 'vue'
import {
Plus,
FolderPlus,
Database,
Search,
Trash2,
Save,
Table2,
List,
Pencil,
Hash,
Shield,
Globe,
Info,
FolderTree,
Menu,
} 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 Switch from '@/components/ui/switch/switch.vue'
import Tree from '@/components/ui/tree/tree.vue'
import Tabs from '@/components/ui/tabs/tabs.vue'
import TabPane from '@/components/ui/tabs/tab-pane.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 ModuleEditConfigPanel from './components/ModuleEditConfigPanel.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'
import MenuDetailPanel from './components/MenuDetailPanel.vue'
/* ================= 顶部切换(Tabs 默认样式) ================= */
const activeZone = ref('module')
/* ================= 模块树 ================= */
const moduleTreeData = ref([])
const moduleTreeLoading = ref(false)
const moduleKeyword = ref('')
const moduleSelectedId = ref(null)
const moduleExpandedKeys = ref([])
const unsavedModuleIds = new Set()
// 模块树右键菜单:空白处新增顶级节点,节点上右键可新增子节点或删除。
// 未保存(草稿)节点不可作为父级,仅可编辑/保存/删除
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', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
moduleTreeData.value = sortTree(buildTree(res.data || []))
unsavedModuleIds.clear()
} catch (e) {
Message.error('加载模块树失败:' + (e?.message || e))
} finally {
moduleTreeLoading.value = false
}
}
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 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
}
loadedModuleId.value = null
void loadModuleConfig(id)
})
function onModuleSelect(keys) {
if (keys.length) moduleSelectedId.value = String(keys[0])
}
/* ================= 通用确认弹窗(ui/modal 为组件式 API) ================= */
const confirmVisible = ref(false)
const confirmState = ref(null)
function showConfirm({ title, content, okText, okType, onOk }) {
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
confirmVisible.value = true
}
function onConfirmOk() {
const state = confirmState.value
if (state?.onOk) state.onOk()
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_canuse: 1,
b_xh: 10,
b_bz: null,
}
// 插入树并选中(跳过 watch 的自动加载,直接用空草稿)
insertModuleNode(moduleTreeData.value, newModule)
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 })) })
}
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 onSetModuleField(field, value) {
if (drafts.value?.module) drafts.value.module[field] = value
}
function onGroupsSaved(newGroups, idMapping) {
if (!drafts.value) return
if (idMapping && idMapping.size) {
remapForeignKeys(drafts.value.listConfig || [], ['b_group_id'], idMapping)
remapForeignKeys(drafts.value.editConfig || [], ['b_group_id'], idMapping)
}
drafts.value.groups = cloneData(newGroups)
originals.value.groups = cloneData(newGroups)
}
function onModuleI18nSaved() {
i18nPanelRef.value?.refreshKey?.()
}
function menuTypeLabel(type) {
const labels = { directory: '目录', page: '页面', data: '数据页面', external: '外链' }
return labels[type] || ''
}
const referencedGroupIds = computed(() => {
const ids = new Set()
for (const row of drafts.value?.listConfig || []) {
if (row.b_group_id != null) ids.add(String(row.b_group_id))
}
for (const row of drafts.value?.editConfig || []) {
if (row.b_group_id != null) ids.add(String(row.b_group_id))
}
return Array.from(ids)
})
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.dbtype || null,
b_length: col.length ?? 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
if (loadedModuleId.value == null || String(loadedModuleId.value) !== String(moduleSelectedId.value)) return
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
}
const autoCodeErrors = await autoCodePanelRef.value?.validate?.()
if (autoCodeErrors?.length) {
activeSection.value = 'auto-code'
Message.warning('请修正表单中标红的错误项')
return
}
// 跨面板校验:自动编码依赖保存表
const autoCodeError = validateAutoCode()
if (autoCodeError) {
Message.warning(autoCodeError)
return
}
const moduleData = drafts.value?.module
if (!moduleData) return
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_view', 'listConfig'],
['s_field_edit', 'editConfig'],
['s_field_query', 'queryConfig'],
['s_autocode', 'autoCode'],
['s_power', 'powers'],
]
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)) {
await allocateTemporaryIds([req.inserts || [], req.updates || []])
saveReqs.push(req)
}
}
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) saveReqs.push(i18nReq)
}
if (saveReqs.length === 0) {
Message.info('没有需要保存的变更')
return
}
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('保存成功')
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
} finally {
configSaving.value = false
}
}
/* ================= 菜单 ================= */
const menuTreeData = ref([])
const menuTreeLoading = ref(false)
const menuKeyword = ref('')
const menuSelectedId = ref(null)
const menuOriginals = ref(null)
const menuDrafts = ref(null)
const menuSaving = ref(false)
const menuDetailRef = ref(null)
const unsavedMenuIds = new Set()
// 菜单树右键菜单:空白处新增顶级菜单,节点上右键可新增子菜单或删除。
// 未保存(草稿)节点不可作为父级
function buildMenuContextMenu(node) {
const isUnsaved = node != null && unsavedMenuIds.has(String(node.b_id))
const items = [{ key: 'add-menu', label: '新增菜单', icon: Plus, disabled: isUnsaved }]
if (node) {
items.push({ divider: true })
items.push({ key: 'delete', label: '删除菜单', icon: Trash2, danger: true })
}
return items
}
function onMenuContextMenuClick({ item, node }) {
const parentId = node ? String(node.b_id) : null
if (item.key === 'add-menu') void addMenu(parentId)
else if (item.key === 'delete') void deleteMenu(parentId)
}
async function loadMenuTree() {
menuTreeLoading.value = true
try {
const res = await loadDataApi('s_menu', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
menuTreeData.value = sortTree(buildTree(res.data || []))
unsavedMenuIds.clear()
} catch (e) {
Message.error('加载菜单树失败:' + (e?.message || e))
} finally {
menuTreeLoading.value = false
}
}
const filteredMenuTree = computed(() => filterTree(menuTreeData.value, menuKeyword.value))
function findMenuNode(nodes, id) {
for (const node of nodes) {
if (String(node.b_id) === String(id)) return node
if (node.children) {
const found = findMenuNode(node.children, id)
if (found) return found
}
}
return null
}
const selectedMenu = computed(() =>
menuSelectedId.value ? findMenuNode(menuTreeData.value, menuSelectedId.value) : null,
)
const parentMenuName = computed(() => {
if (!selectedMenu.value?.b_parent_id) return ''
return findMenuNode(menuTreeData.value, selectedMenu.value.b_parent_id)?.b_name || ''
})
// 数据/查询模块选项(供菜单关联模块下拉)
const moduleOptions = computed(() => {
const out = []
const walk = (nodes) => {
for (const node of nodes) {
if (node.b_module_type === 'data' || node.b_module_type === 'query') {
out.push({ label: node.b_name || node.b_code, value: String(node.b_id) })
}
if (node.children) walk(node.children)
}
}
walk(moduleTreeData.value)
return out
})
async function loadMenuConfig(menuId) {
const id = String(menuId)
try {
const responses = await Promise.all([
loadDataApi('s_menu', `b_id = ${id}`),
loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${escapeSql(String(findMenuNode(menuTreeData.value, id)?.b_code || ''))}'`, 'b_xh ASC, b_id ASC'),
])
if (String(menuSelectedId.value) !== id) return
const original = {
menu: responses[0].data?.[0] || null,
powers: responses[1].data || [],
}
menuOriginals.value = original
menuDrafts.value = cloneData(original)
} catch (e) {
Message.error('菜单配置加载失败:' + (e?.message || e))
}
}
function escapeSql(v) {
return String(v).replace(/'/g, "''")
}
let skipMenuLoad = false
watch(menuSelectedId, (id) => {
if (skipMenuLoad) {
skipMenuLoad = false
return
}
if (!id) {
menuOriginals.value = null
menuDrafts.value = null
return
}
void loadMenuConfig(id)
})
function onMenuSelect(keys) {
if (keys.length) menuSelectedId.value = String(keys[0])
}
async function addMenu(parentId) {
const res = await nextIdApi()
const newId = String(res.data)
const newMenu = {
b_id: newId,
b_parent_id: parentId ? String(parentId) : null,
b_code: '',
b_name: '',
b_i18n: null,
b_menu_type: 'directory',
b_route: null,
b_module_id: null,
b_power_code: null,
b_icon: null,
b_xh: 10,
b_canuse: 1,
}
insertModuleNode(menuTreeData.value, newMenu)
unsavedMenuIds.add(newId)
skipMenuLoad = true
menuSelectedId.value = newId
await nextTick()
menuDrafts.value = cloneData({ menu: newMenu, powers: [] })
menuOriginals.value = cloneData(menuDrafts.value)
}
// 菜单树:是否存在「已保存」的子菜单(未保存草稿子树不算)
function hasSavedMenuDescendant(node) {
for (const child of node.children || []) {
if (!unsavedMenuIds.has(String(child.b_id))) return true
if (hasSavedMenuDescendant(child)) return true
}
return false
}
function collectUnsavedMenuIds(node, set = new Set()) {
set.add(String(node.b_id))
for (const child of node.children || []) collectUnsavedMenuIds(child, set)
return set
}
async function deleteMenu(id) {
const menu = id != null ? findMenuNode(menuTreeData.value, id) : selectedMenu.value
if (!menu) return
const menuId = String(menu.b_id)
if (unsavedMenuIds.has(menuId)) {
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
removeModuleNode(menuTreeData.value, menuId)
menuSelectedId.value = null
return
}
if (hasSavedMenuDescendant(menu)) {
Message.warning('该菜单下还有已保存的子菜单,请先删除子菜单')
return
}
showConfirm({
title: '删除菜单',
content: `确定删除菜单「${menu.b_name || menu.b_code}」?将同时删除该菜单的权限点配置。`,
okText: '删除',
okType: 'danger',
onOk: async () => {
try {
const id = String(menu.b_id)
const code = String(menu.b_code || '')
const reqs = []
const powerRes = await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${escapeSql(code)}'`)
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 })) })
}
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: id }] })
await saveObjectApi(reqs)
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
removeModuleNode(menuTreeData.value, id)
menuSelectedId.value = null
Message.success('菜单已删除')
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
}
},
})
}
async function saveMenu() {
if (!selectedMenu.value || menuSaving.value) return
const menuId = String(selectedMenu.value.b_id)
menuSaving.value = true
try {
const saveReqs = []
const menuData = menuDrafts.value?.menu
if (!menuData) return
// 字段级校验:错误已显示在对应 FormItem 下方,此处只做汇总与定位
const basicErrors = await menuDetailRef.value?.validate?.()
if (basicErrors?.length) {
Message.warning('请修正表单中标红的错误项')
return
}
const menuCode = String(menuData.b_code || '').trim()
syncModuleItemI18nKeys(menuDrafts.value.powers, 'menu', menuCode, 'b_code')
menuData.b_i18n = buildModuleI18nKey('menu', menuCode)
const isNew = unsavedMenuIds.has(menuId)
if (menuData && (isNew || JSON.stringify(menuData) !== JSON.stringify(menuOriginals.value?.menu))) {
const req = { table: 's_menu', key_field: 'b_id' }
if (isNew) req.inserts = [menuData]
else req.updates = [menuData]
saveReqs.push(req)
}
// 菜单权限点(s_power, menu 类型)
const current = menuDrafts.value.powers || []
const previous = menuOriginals.value?.powers || []
const changes = diffRows(current, previous)
// 保存前同步 b_resource 为当前菜单编码
for (const row of changes.inserts || []) row.b_resource = menuCode
for (const row of changes.updates || []) row.b_resource = menuCode
const powerReq = tableChange('s_power', changes)
if (hasChanges(powerReq)) {
await allocateTemporaryIds([powerReq.inserts || [], powerReq.updates || []])
saveReqs.push(powerReq)
}
// 多语言
const i18nReq = menuDetailRef.value?.saveI18n?.()
if (i18nReq) saveReqs.push(i18nReq)
if (saveReqs.length === 0) {
Message.info('没有需要保存的变更')
return
}
await saveObjectApi(saveReqs)
menuOriginals.value = cloneData(menuDrafts.value)
menuDetailRef.value?.markI18nSaved?.()
unsavedMenuIds.delete(menuId)
const node = findMenuNode(menuTreeData.value, menuId)
if (node) Object.assign(node, cloneData(menuData))
Message.success('保存成功')
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
} finally {
menuSaving.value = false
}
}
function onMenuUpdate(section, newData) {
if (menuDrafts.value) menuDrafts.value[section] = newData
}
/* ================= 初始化 ================= */
loadModuleTree()
loadMenuTree()
</script>
<template>
<div class="mm-page">
<!-- 顶部两分区切换(Tabs 默认样式) -->
<Tabs v-model="activeZone" class="mm-zone-tabs">
<TabPane tab-key="module">
<template #label><Table2 :size="14" /> 模块配置</template>
</TabPane>
<TabPane tab-key="menu">
<template #label><Menu :size="14" /> 菜单配置</template>
</TabPane>
</Tabs>
<!-- ================ 模块配置 ================ -->
<div v-show="activeZone === 'module'" class="mm-body">
<aside class="mm-tree-panel">
<Input v-model="moduleKeyword" placeholder="搜索模块(右键树节点操作)" allow-clear>
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<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' }"
default-expand-all
:selected-keys="moduleSelectedId ? [moduleSelectedId] : []"
:context-menu="buildModuleContextMenu"
@select="onModuleSelect"
@context-menu-click="onModuleContextMenuClick"
/>
</div>
</aside>
<main class="mm-main">
<template v-if="selectedModule">
<div class="mm-header">
<div class="mm-header__left">
<span class="mm-header__name">{{ selectedModule.b_name || selectedModule.b_code }}</span>
<span class="mm-header__type">{{ moduleTypeLabel }}</span>
<span v-if="unsavedModuleIds.has(String(selectedModule.b_id))" class="mm-header__unsaved">
未保存
</span>
</div>
<div class="mm-header__right">
<label class="mm-header__switch">
<span>启用</span>
<Switch
:model-value="drafts?.module?.b_canuse === 1"
:disabled="busy"
@update:model-value="(v) => onSetModuleField('b_canuse', v ? 1 : 0)"
/>
</label>
<Button type="primary" :loading="configSaving" :disabled="configLoading" @click="saveModule">
<Save :size="14" /> 保存
</Button>
</div>
</div>
<div class="mm-body__inner">
<Tabs v-model="activeSection" class="mm-section-tabs">
<TabPane tab-key="basic">
<template #label><Info :size="14" /> 基本信息</template>
<div v-if="isDataModule || isQueryModule" class="mm-section-toolbar">
<span class="mm-section-toolbar__label">模块基础信息</span>
</div>
<ModuleBasicPanel
ref="basicPanelRef"
:data="drafts?.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => onModuleUpdate('module', v)"
@i18n-saved="onModuleI18nSaved"
/>
</TabPane>
<TabPane tab-key="fields" v-if="isDataModule || isQueryModule">
<template #label><Table2 :size="14" /> 字段定义</template>
<div class="mm-section-toolbar">
<span class="mm-section-toolbar__label">字段定义</span>
<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"
/>
</TabPane>
<TabPane tab-key="list" v-if="isDataModule || isQueryModule">
<template #label><List :size="14" /> 列表配置</template>
<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"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onModuleUpdate('listConfig', v)"
@groups-saved="onGroupsSaved"
@sync-edit="(v) => onModuleUpdate('editConfig', v)"
@sync-query="(v) => onModuleUpdate('queryConfig', v)"
/>
</TabPane>
<TabPane tab-key="edit" v-if="isDataModule">
<template #label><Pencil :size="14" /> 编辑配置</template>
<ModuleEditConfigPanel
:module-id="String(selectedModule.b_id)"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:groups="drafts?.groups"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onModuleUpdate('editConfig', v)"
@groups-saved="onGroupsSaved"
@sync-list="(v) => onModuleUpdate('listConfig', v)"
@sync-query="(v) => onModuleUpdate('queryConfig', v)"
/>
</TabPane>
<TabPane tab-key="query" v-if="isDataModule || isQueryModule">
<template #label><Search :size="14" /> 查询配置</template>
<ModuleQueryConfigPanel
:module-id="String(selectedModule.b_id)"
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:loading="busy"
@update="(v) => onModuleUpdate('queryConfig', v)"
/>
</TabPane>
<TabPane tab-key="auto-code" v-if="isDataModule">
<template #label><Hash :size="14" /> 自动编码</template>
<ModuleAutoCodePanel
ref="autoCodePanelRef"
:module-id="String(selectedModule.b_id)"
:auto-code="drafts?.autoCode"
:fields="drafts?.fields"
:loading="busy"
@update="onAutoCodeUpdate"
/>
</TabPane>
<TabPane tab-key="powers" v-if="isDataModule || isQueryModule">
<template #label><Shield :size="14" /> 权限配置</template>
<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"
/>
</TabPane>
<TabPane tab-key="i18n" v-if="moduleType && moduleType !== 'category'">
<template #label><Globe :size="14" /> 多语言</template>
<ModuleI18nPanel
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="finalI18nItems"
:loading="busy"
/>
</TabPane>
</Tabs>
</div>
</template>
<div v-else class="mm-main__empty">
<FolderTree :size="32" />
<span>请选择左侧模块节点</span>
</div>
</main>
</div>
<!-- ================ 菜单配置 ================ -->
<div v-show="activeZone === 'menu'" class="mm-body">
<aside class="mm-tree-panel">
<Input v-model="menuKeyword" placeholder="搜索菜单(右键树节点操作)" allow-clear>
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<div class="mm-tree-panel__scroll">
<div v-if="menuTreeLoading" class="mm-tree-empty">加载中...</div>
<Tree
v-else
:tree-data="filteredMenuTree"
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
default-expand-all
:selected-keys="menuSelectedId ? [menuSelectedId] : []"
:context-menu="buildMenuContextMenu"
@select="onMenuSelect"
@context-menu-click="onMenuContextMenuClick"
/>
</div>
</aside>
<main class="mm-main">
<template v-if="selectedMenu">
<div class="mm-header">
<div class="mm-header__left">
<span class="mm-header__name">{{ selectedMenu.b_name || selectedMenu.b_code }}</span>
<span class="mm-header__type">{{ menuTypeLabel(selectedMenu.b_menu_type) }}</span>
<span v-if="unsavedMenuIds.has(String(selectedMenu.b_id))" class="mm-header__unsaved">
未保存
</span>
</div>
<div class="mm-header__right">
<Button type="primary" :loading="menuSaving" @click="saveMenu">
<Save :size="14" /> 保存
</Button>
</div>
</div>
<div class="mm-body__inner">
<MenuDetailPanel
ref="menuDetailRef"
:menu="menuDrafts?.menu"
:parent-name="parentMenuName"
:module-options="moduleOptions"
:powers="menuDrafts?.powers"
:loading="menuSaving"
@update="(v) => onMenuUpdate('menu', v)"
@update-powers="(v) => onMenuUpdate('powers', v)"
/>
</div>
</template>
<div v-else class="mm-main__empty">
<FolderTree :size="32" />
<span>请选择左侧菜单节点</span>
</div>
</main>
</div>
<!-- 通用确认弹窗 -->
<Modal v-model:open="confirmVisible" title="确认" :ok-text="confirmState?.okText || '确定'">
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">{{ confirmState?.content }}</p>
</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;
}
/* ---- 顶部两分区切换(Tabs):与下方内容留 8px 间距 ---- */
.mm-zone-tabs {
flex: none;
margin-bottom: 8px;
:deep(.tabs-content) {
padding-top: 0;
}
}
/* ---- 主体左右布局:占据剩余全部高度 ---- */
.mm-body {
display: flex;
gap: 8px;
flex: 1;
min-height: 0;
}
.mm-tree-panel {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
width: 260px;
min-height: 0;
padding: 8px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
}
.mm-tree-panel__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.mm-tree-search__icon {
color: var(--fms-text-secondary);
}
.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;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
}
.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;
}
.mm-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: none;
padding: 10px 16px;
border-bottom: 1px solid var(--fms-border);
}
.mm-header__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mm-header__name {
font-size: 15px;
font-weight: 600;
color: var(--fms-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mm-header__type {
flex: none;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
}
.mm-header__unsaved {
flex: none;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
background: #fef3c7;
color: #b45309;
}
.mm-header__right {
display: flex;
align-items: center;
gap: 12px;
flex: none;
}
.mm-header__switch {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fms-text-secondary);
}
/* ---- section Tabs + 内容 ---- */
.mm-body__inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.mm-section-tabs {
flex: 1;
min-height: 0;
:deep(.tabs-content) {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 16px 16px;
}
}
.mm-section-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.mm-section-toolbar__label {
font-size: 13px;
font-weight: 500;
color: var(--fms-text);
}
</style>