1379 lines
46 KiB
Vue
1379 lines
46 KiB
Vue
<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>
|