refactor(module-management): 阶段三——index.vue 拆出 useModuleTree/useModuleConfig/useModuleSave/useConfirm

This commit is contained in:
oneao committed 2026-08-28 16:11:57 +08:00
1 parent 27752351b9
commit 91237c4a47
5 files changed
+1053 -806

No files matched your search

@@ -0,0 +1,82 @@
/**
* 两套确认弹窗:
* - 通用确认(ui/modal 为组件式 API):showConfirm/onConfirmOk/confirmState
* - 切换对象未保存保护:switchConfirm/onSwitch*(保存并切换 / 放弃修改 / 取消)
*/
import { ref } from 'vue'
import { Message } from '@/components/ui'
export function useConfirm({ saveModule, moduleSelectedId }) {
/* ================= 通用确认弹窗 ================= */
const confirmVisible = ref(false)
const confirmState = ref(null)
// 确认操作进行中:按钮转圈、弹窗锁定,等待 onOk 完成后再关闭
const confirmSaving = ref(false)
function showConfirm({ title, content, okText, okType, onOk }) {
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
confirmVisible.value = true
}
async function onConfirmOk() {
const state = confirmState.value
if (!state?.onOk || confirmSaving.value) return
confirmSaving.value = true
try {
await state.onOk()
} catch (e) {
// onOk 内部已处理错误提示,这里兜底避免未捕获异常
Message.error('操作失败:' + (e?.message || e))
} finally {
confirmSaving.value = false
confirmVisible.value = false
}
}
/* ================= 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) ================= */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
switchConfirm.value = null
switchSaving.value = false
}
function onSwitchDiscard() {
const state = switchConfirm.value
if (!state) return
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
}
async function onSwitchSave() {
const state = switchConfirm.value
if (!state || switchSaving.value) return
switchSaving.value = true
try {
const ok = await saveModule()
if (!ok) {
// 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
Message.warning('保存未完成,已留在当前对象,请处理后再切换')
return
}
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
} finally {
switchSaving.value = false
}
}
return {
confirmVisible,
confirmState,
confirmSaving,
showConfirm,
onConfirmOk,
switchConfirm,
switchSaving,
onSwitchCancel,
onSwitchDiscard,
onSwitchSave,
}
}
@@ -0,0 +1,281 @@
/**
* 草稿加载与脏检查:
* configLoading / originals / drafts / loadedModuleId / skipModuleLoad 约定、
* loadModuleConfig / hasModuleChanges / reloadModule / onModuleSelect /
* watch(moduleSelectedId),以及草稿派生联动(onFieldsUpdate / applyDisabledFieldDefaults /
* onAutoCodeUpdate / syncFields)。
*/
import { computed, watch } from 'vue'
import { Message } from '@/components/ui'
import { loadDataApi, describeApi } from '@/services/api'
import { cloneData } from '@/utils/dataChanges'
import { createTempId } from '@/utils/tempId'
import { buildModuleItemI18nKey } from '@/utils/i18n'
export function useModuleConfig(deps) {
const {
moduleSelectedId,
moduleTreeData,
findModuleNode,
selectedModule,
busy,
configLoading,
configSyncing,
activeSection,
loadedModuleId,
originals,
drafts,
skipModuleLoad,
showConfirm,
switchConfirm,
i18nPanelRef,
} = deps
/* ================= 草稿加载 ================= */
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
}
}
watch(moduleSelectedId, (id) => {
if (skipModuleLoad.value) {
skipModuleLoad.value = 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)
})
/* ================= 脏检查 ================= */
// 是否有未保存的修改(用于「重载」按钮的确认提示)
const hasModuleChanges = computed(() => {
if (!drafts.value || !originals.value) return false
return JSON.stringify(drafts.value) !== JSON.stringify(originals.value)
})
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
}
// 重载当前模块:重新从后端加载数据;若有未保存修改,先让使用者确认放弃
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()
}
}
/* ================= 草稿派生联动 ================= */
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
}
}
/* ================= 模块:同步字段 ================= */
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
}
}
return {
configLoading,
configSyncing,
loadedModuleId,
originals,
drafts,
hasModuleChanges,
loadModuleConfig,
reloadModule,
onModuleSelect,
onFieldsUpdate,
applyDisabledFieldDefaults,
onAutoCodeUpdate,
syncFields,
}
}
@@ -0,0 +1,166 @@
/**
* 模块保存编排:saveModule / validateAutoCode / subTables 常量、
* 临时 id 分配与外键重映射、i18n reconcile 调用。
* 依赖(refs + getter)由 index.vue 注入,不移动模板。
*/
import { Message } from '@/components/ui'
import { saveObjectApi } from '@/services/api'
import {
cloneData,
diffRows,
allocateTemporaryIds,
remapForeignKeys,
tableChange,
hasChanges,
} from '@/utils/dataChanges'
import { buildModuleI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
export function useModuleSave({
selectedModule,
drafts,
originals,
loadedModuleId,
moduleSelectedId,
unsavedModuleIds,
configSaving,
busy,
activeSection,
moduleTreeData,
findModuleNode,
basicPanelRef,
autoCodePanelRef,
i18nPanelRef,
getFinalI18nItems,
}) {
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(getFinalI18nItems())
}
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
}
}
return { saveModule, validateAutoCode, configSaving }
}
@@ -0,0 +1,382 @@
/**
* 模块树状态与操作:
* - 树查询(filterTree/findModuleNode/collectAllKeys/expandAncestors 等纯函数,模块级导出)
* - 树加载/新增/删除/上下文菜单
* - 面包屑推导
*
* findModuleNode 为纯函数导出,供 index.vue 组装 selectedModule 用。
*/
import { computed, nextTick, watch } from 'vue'
import { Database, Folder, FolderPlus, Search, Trash2 } from '@lucide/vue'
import { Message } from '@/components/ui'
import { loadDataApi, saveObjectApi, nextIdApi } from '@/services/api'
import { buildTree, sortTree } from '@/utils/tree'
import { cloneData } from '@/utils/dataChanges'
/* ================= 模块级纯函数导出 ================= */
/** 递归在树中查找指定 id 的节点 */
export 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
}
/** 递归收集所有节点 key(搜索时展开全部用) */
export 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
}
/** 从根到目标节点的祖先路径(新建节点时展开,保证可见) */
export 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
}
/** 分类节点标记为非叶子 + 附加图标 */
const moduleTypeIconMap = {
category: Folder,
data: Database,
query: Search,
}
export 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
}
/** 按名称/编码过滤树(命中节点保留子树) */
export 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
}
/** 插入节点到树(顶级或指定父级下,就地排序) */
export 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)
}
}
/** 从树中移除指定 id 的节点 */
export 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)
}
}
/** 从根到当前节点的名称路径(面包屑用) */
export 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
}
/* ================= 响应式 composable ================= */
/**
* @param {object} deps 依赖(index.vue 组装后传入)
* @param {import('vue').Ref<Array>} deps.moduleTreeData
* @param {import('vue').Ref<boolean>} deps.moduleTreeLoading
* @param {import('vue').Ref<string>} deps.moduleKeyword
* @param {import('vue').Ref<Array>} deps.moduleExpandedKeys
* @param {import('vue').Ref<string|null>} deps.moduleSelectedId
* @param {Set} deps.unsavedModuleIds
* @param {import('vue').Ref} deps.drafts
* @param {import('vue').Ref} deps.originals
* @param {import('vue').Ref<string|null>} deps.loadedModuleId
* @param {{ value: boolean }} deps.skipModuleLoad
* @param {import('vue').ComputedRef<boolean>} deps.busy
* @param {Function} deps.showConfirm
* @param {import('vue').ComputedRef|null} deps.selectedModule
*/
export function useModuleTree(deps) {
const {
moduleTreeData,
moduleTreeLoading,
moduleKeyword,
moduleExpandedKeys,
moduleSelectedId,
unsavedModuleIds,
drafts,
originals,
loadedModuleId,
skipModuleLoad,
busy,
showConfirm,
selectedModule,
} = deps
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(moduleKeyword, (kw) => {
moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : []
})
const filteredModuleTree = computed(() => filterTree(moduleTreeData.value, moduleKeyword.value))
// 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除)
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 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
}
}
// 模块树右键菜单:空白处新增顶级节点,节点上右键可新增子节点或删除。
// 未保存(草稿)节点不可作为父级,仅可编辑/保存/删除
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 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.value = 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
}
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 id2 = 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} = ${id2}`)
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 = ${id2}`)
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: id2 }] })
await saveObjectApi(reqs)
// 本地移除整棵子树(未保存子孙随父一并清理),并清除未保存标记
collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k))
removeModuleNode(moduleTreeData.value, id2)
moduleSelectedId.value = null
Message.success('模块已删除')
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
}
},
})
}
const moduleBreadcrumb = computed(() =>
selectedModule.value
? buildBreadcrumb(selectedModule.value, (id) => findModuleNode(moduleTreeData.value, id))
: [],
)
return {
moduleTreeLoading,
moduleKeyword,
moduleExpandedKeys,
filteredModuleTree,
loadModuleTree,
buildModuleContextMenu,
onModuleContextMenuClick,
addModule,
deleteModule,
moduleBreadcrumb,
}
}
File diff suppressed because it is too large. Load diff