refactor(module-management): 阶段三——index.vue 拆出 useModuleTree/useModuleConfig/useModuleSave/useConfirm
This commit is contained in:
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
Reference in new issue
Block a user