diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useConfirm.js b/code/fms/fms-vue/src/views/system/module-management/composables/useConfirm.js new file mode 100644 index 00000000..22f0917a --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/composables/useConfirm.js @@ -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, + } +} \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useModuleConfig.js b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleConfig.js new file mode 100644 index 00000000..1b693f68 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleConfig.js @@ -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, + } +} \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useModuleSave.js b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleSave.js new file mode 100644 index 00000000..dc74ba6f --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleSave.js @@ -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 } +} \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useModuleTree.js b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleTree.js new file mode 100644 index 00000000..fd8d5a06 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/composables/useModuleTree.js @@ -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} deps.moduleTreeData + * @param {import('vue').Ref} deps.moduleTreeLoading + * @param {import('vue').Ref} deps.moduleKeyword + * @param {import('vue').Ref} deps.moduleExpandedKeys + * @param {import('vue').Ref} deps.moduleSelectedId + * @param {Set} deps.unsavedModuleIds + * @param {import('vue').Ref} deps.drafts + * @param {import('vue').Ref} deps.originals + * @param {import('vue').Ref} deps.loadedModuleId + * @param {{ value: boolean }} deps.skipModuleLoad + * @param {import('vue').ComputedRef} 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, + } +} \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/index.vue b/code/fms/fms-vue/src/views/system/module-management/index.vue index 2c55f7f5..4d090ed2 100644 --- a/code/fms/fms-vue/src/views/system/module-management/index.vue +++ b/code/fms/fms-vue/src/views/system/module-management/index.vue @@ -3,16 +3,16 @@ * 模块管理:模块配置(单分区)。 * 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。 * 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。 + * 树/草稿/保存/确认弹窗逻辑分别抽到 composables/useModuleTree、useModuleConfig、 + * useModuleSave、useConfirm;本文件只保留模板、状态接线与面板编排。 */ -import { computed, nextTick, ref, watch } from 'vue' +import { computed, ref } from 'vue' import { useHotkeys } from '@/composables/useHotkeys' import { Plus, FolderPlus, - Folder, Database, Search, - Trash2, Save, Table2, List, @@ -25,8 +25,6 @@ import { FolderTree, } from '@lucide/vue' import { - Message, - Modal, Button, Input, Tree, @@ -34,19 +32,13 @@ import { Splitter, SplitterPanel, Tabs, + Modal, } from '@/components/ui' -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 { buildModuleI18nKey, buildModuleItemI18nKey } from '@/utils/i18n' +import { useModuleTree, findModuleNode } from './composables/useModuleTree' +import { useModuleConfig } from './composables/useModuleConfig' +import { useModuleSave } from './composables/useModuleSave' +import { useConfirm } from './composables/useConfirm' import ModuleBasicPanel from './components/ModuleBasicPanel.vue' import ModuleFieldsPanel from './components/ModuleFieldsPanel.vue' import ModuleListConfigPanel from './components/ModuleListConfigPanel.vue' @@ -57,18 +49,7 @@ import ModuleAutoCodePanel from './components/ModuleAutoCodePanel.vue' import ModulePowersPanel from './components/ModulePowersPanel.vue' import ModuleI18nPanel from './components/ModuleI18nPanel.vue' -/* ================= 快捷键 ================= */ -// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys) -useHotkeys([ - { - key: 's', - mod: true, - ignoreWhenTyping: false, // 输入框内也能保存 - handler: () => void saveModule(), - }, -]) - -/* ================= 模块树 ================= */ +/* ================= 共享状态(index.vue 组装后传入各 composable) ================= */ const moduleTreeData = ref([]) const moduleTreeLoading = ref(false) const moduleKeyword = ref('') @@ -76,114 +57,6 @@ const moduleSelectedId = ref(null) const moduleExpandedKeys = ref([]) const unsavedModuleIds = new Set() -// 搜索时展开全部(保证命中节点可见),清空时收起 -watch(moduleKeyword, (kw) => { - moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : [] -}) - -// 递归收集所有节点 key(搜索时展开全部用) -function collectAllKeys(nodes) { - const keys = [] - const walk = (list) => { - for (const node of list) { - keys.push(String(node.b_id)) - if (node.children) walk(node.children) - } - } - walk(nodes) - return keys -} - -// 从根到目标节点的祖先路径(新建节点时展开,保证可见) -function expandAncestors(nodes, id) { - const keys = [] - const walk = (list, ancestors) => { - for (const node of list) { - const key = String(node.b_id) - if (key === id) { keys.push(...ancestors); return true } - if (node.children && walk(node.children, [...ancestors, key])) return true - } - return false - } - walk(nodes, []) - return keys -} - -// 模块树右键菜单:空白处新增顶级节点,节点上右键可新增子节点或删除。 -// 未保存(草稿)节点不可作为父级,仅可编辑/保存/删除 -function buildModuleContextMenu(node) { - const isUnsaved = node != null && unsavedModuleIds.has(String(node.b_id)) - const items = [ - { key: 'add-category', label: '新增分类', icon: FolderPlus, disabled: isUnsaved }, - { key: 'add-data', label: '新增数据模块', icon: Database, disabled: isUnsaved }, - { key: 'add-query', label: '新增查询模块', icon: Search, disabled: isUnsaved }, - ] - if (node) { - items.push({ divider: true }) - items.push({ key: 'delete', label: '删除模块', icon: Trash2, danger: true }) - } - return items -} - -function onModuleContextMenuClick({ item, node }) { - const parentId = node ? String(node.b_id) : null - if (item.key === 'add-category') void addModule(parentId, 'category') - else if (item.key === 'add-data') void addModule(parentId, 'data') - else if (item.key === 'add-query') void addModule(parentId, 'query') - else if (item.key === 'delete') void deleteModule(parentId) -} - -async function loadModuleTree() { - moduleTreeLoading.value = true - try { - // 加载全部模块(含停用):停用节点在树中变灰显示,便于重新启用 - const res = await loadDataApi('s_module', '1 = 1', 'b_xh ASC, b_id ASC') - moduleTreeData.value = markCategoryNonLeaf(sortTree(buildTree(res.data || []))) - unsavedModuleIds.clear() - } catch (e) { - Message.error('加载模块树失败:' + (e?.message || e)) - } finally { - moduleTreeLoading.value = false - } -} - -// 分类节点标记为非叶子:即使暂无子模块也显示折叠箭头(提示可挂子模块)。 -// Tree 组件依据 node.isLeaf 判定叶子;isLeaf === false 强制非叶子。 -// 同时按模块类型附加 lucide 图标(icon 字段),供 Tree 组件渲染在标题前。 -const moduleTypeIconMap = { - category: Folder, - data: Database, - query: Search, -} -function markCategoryNonLeaf(nodes) { - for (const node of nodes) { - if (node.b_module_type === 'category') node.isLeaf = false - const icon = moduleTypeIconMap[node.b_module_type] - if (icon) node.icon = icon - if (node.children) markCategoryNonLeaf(node.children) - } - return nodes -} - -const filteredModuleTree = computed(() => filterTree(moduleTreeData.value, moduleKeyword.value)) - -function filterTree(nodes, keyword) { - const kw = String(keyword || '').trim().toLowerCase() - if (!kw) return nodes - const result = [] - for (const node of nodes) { - const hit = - String(node.b_name || '').toLowerCase().includes(kw) || - String(node.b_code || '').toLowerCase().includes(kw) - const children = node.children ? filterTree(node.children, kw) : undefined - if (hit || children?.length) { - result.push({ ...node, children }) - } - } - return result -} - -/* ================= 模块配置加载 ================= */ const configLoading = ref(false) const configSaving = ref(false) const configSyncing = ref(false) @@ -194,22 +67,16 @@ const activeSection = ref('basic') const basicPanelRef = ref(null) const autoCodePanelRef = ref(null) const i18nPanelRef = ref(null) +// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享 +const skipModuleLoad = { value: false } -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 -} - +/* ================= 派生状态(index.vue 组装) ================= */ const selectedModule = computed(() => moduleSelectedId.value ? findModuleNode(moduleTreeData.value, moduleSelectedId.value) : null, ) +const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value) + const moduleTypeLabel = computed(() => { const labels = { category: '分类', data: '数据模块', query: '查询模块' } return labels[selectedModule.value?.b_module_type] || '' @@ -220,459 +87,40 @@ const parentModuleName = computed(() => { return findModuleNode(moduleTreeData.value, selectedModule.value.b_parent_id)?.b_name || '' }) -const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value) - -// 是否有未保存的修改(用于「重载」按钮的确认提示) -const hasModuleChanges = computed(() => { - if (!drafts.value || !originals.value) return false - return JSON.stringify(drafts.value) !== JSON.stringify(originals.value) -}) - -// 重载当前模块:重新从后端加载数据;若有未保存修改,先让使用者确认放弃 -function reloadModule() { - if (!selectedModule.value || busy.value) return - const name = selectedModule.value.b_name || selectedModule.value.b_code - const doReload = () => { - loadModuleConfig(selectedModule.value.b_id) - } - if (hasModuleChanges.value) { - showConfirm({ - title: '重载模块', - content: `「${name}」有未保存的修改,确定放弃修改并重新加载数据?`, - okText: '重载', - onOk: doReload, - }) - } else { - doReload() - } -} - const moduleType = computed(() => selectedModule.value?.b_module_type || null) const isDataModule = computed(() => moduleType.value === 'data') const isQueryModule = computed(() => moduleType.value === 'query') -async function loadModuleConfig(moduleId) { - const id = String(moduleId) - const type = findModuleNode(moduleTreeData.value, id)?.b_module_type - configLoading.value = true - try { - let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes - if (type === 'data') { - const responses = await Promise.all([ - loadDataApi('s_module', `b_id = ${id}`), - loadDataApi('s_field', `b_module_id = ${id}`, 'b_id ASC'), - loadDataApi('s_field_group', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_field_edit', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'), - loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'), - ]) - ;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes] = responses - } else if (type === 'query') { - const responses = await Promise.all([ - loadDataApi('s_module', `b_id = ${id}`), - loadDataApi('s_field', `b_module_id = ${id}`, 'b_id ASC'), - loadDataApi('s_field_group', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'), - loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'), - ]) - ;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, powersRes] = responses - } else { - moduleRes = await loadDataApi('s_module', `b_id = ${id}`) - } - if (String(moduleSelectedId.value) !== id) return - const original = { - module: moduleRes.data?.[0] || null, - fields: fieldsRes?.data || [], - groups: groupsRes?.data || [], - listConfig: listRes?.data || [], - editConfig: editRes?.data || [], - queryConfig: queryRes?.data || [], - autoCode: autoCodeRes?.data || [], - powers: powersRes?.data || [], - } - originals.value = original - drafts.value = cloneData(original) - loadedModuleId.value = id - activeSection.value = 'basic' - } catch (e) { - if (String(moduleSelectedId.value) === String(moduleId)) { - loadedModuleId.value = null - drafts.value = null - Message.error('模块配置加载失败:' + (e?.message || e)) - } - } finally { - if (String(moduleSelectedId.value) === String(moduleId)) configLoading.value = false - } -} +/* ================= 编辑方式 / 导航 / i18n 展示(面板编排) ================= */ +const editMode = computed(() => drafts.value?.module?.b_edit_mode || 'form') +const editNavLabel = computed(() => (editMode.value === 'table' ? '表格设计' : '表单设计')) -let skipModuleLoad = false - -watch(moduleSelectedId, (id) => { - if (skipModuleLoad) { - skipModuleLoad = false - configLoading.value = false - return +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}`, + }) } - if (!id) { - configLoading.value = false - loadedModuleId.value = null - drafts.value = null - return + 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}` }) } - // 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading) - loadedModuleId.value = null - drafts.value = null - void loadModuleConfig(id) + 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 }) -function onModuleSelect(keys) { - if (!keys.length) return - const nextId = String(keys[0]) - if (nextId === String(moduleSelectedId.value)) return - // 有未保存修改时先询问,避免用户困惑「刚才改的内容去哪了」 - if (loadedModuleId.value && hasModuleChanges.value) { - switchConfirm.value = { - type: 'module', - pendingId: nextId, - name: selectedModule.value?.b_name || selectedModule.value?.b_code || '', - } - return - } - moduleSelectedId.value = nextId -} - -/* ================= 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) ================= */ -const switchConfirm = ref(null) // { pendingId, name } -const switchSaving = ref(false) - -function onSwitchCancel() { - switchConfirm.value = null - switchSaving.value = false -} - -async function onSwitchDiscard() { - const state = switchConfirm.value - if (!state) return - moduleSelectedId.value = state.pendingId - switchConfirm.value = null -} - -async function onSwitchSave() { - const state = switchConfirm.value - if (!state || switchSaving.value) return - switchSaving.value = true - try { - const ok = await saveModule() - if (!ok) { - // 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃 - Message.warning('保存未完成,已留在当前对象,请处理后再切换') - return - } - moduleSelectedId.value = state.pendingId - switchConfirm.value = null - } finally { - switchSaving.value = false - } -} - -/* ================= 通用确认弹窗(ui/modal 为组件式 API) ================= */ -const confirmVisible = ref(false) -const confirmState = ref(null) -// 确认操作进行中:按钮转圈、弹窗锁定,等待 onOk 完成后再关闭 -const confirmSaving = ref(false) - -function showConfirm({ title, content, okText, okType, onOk }) { - confirmState.value = { title, content, okText: okText || '确定', okType, onOk } - confirmVisible.value = true -} - -async function onConfirmOk() { - const state = confirmState.value - if (!state?.onOk || confirmSaving.value) return - confirmSaving.value = true - try { - await state.onOk() - } catch (e) { - // onOk 内部已处理错误提示,这里兜底避免未捕获异常 - Message.error('操作失败:' + (e?.message || e)) - } finally { - confirmSaving.value = false - confirmVisible.value = false - } -} - -/* ================= 模块:新增 / 删除 ================= */ -async function addModule(parentId, moduleType) { - const res = await nextIdApi() - const newId = String(res.data) - const newModule = { - b_id: newId, - b_code: '', - b_parent_id: parentId ? String(parentId) : null, - b_name: '', - b_i18n: null, - b_module_type: moduleType, - b_viewtable: null, - b_savetable: null, - b_keyfield: null, - b_orderfield: null, - b_delete_policy: moduleType === 'data' ? 'restrict' : null, - b_edit_mode: moduleType === 'data' ? 'form' : null, - b_canuse: 1, - b_xh: 10, - b_bz: null, - // 分类节点强制非叶子,即使暂无子模块也显示折叠箭头 - isLeaf: moduleType === 'category' ? false : undefined, - } - // 插入树并选中(跳过 watch 的自动加载,直接用空草稿) - insertModuleNode(moduleTreeData.value, newModule) - // 展开祖先路径,保证新建节点在收起的树中可见 - const ancestors = expandAncestors(moduleTreeData.value, newId) - if (ancestors.length) { - moduleExpandedKeys.value = [...new Set([...moduleExpandedKeys.value, ...ancestors])] - } - // 单独设置树节点图标(不进入 drafts,避免污染保存数据) - const treeNode = findModuleNode(moduleTreeData.value, newId) - if (treeNode) treeNode.icon = moduleTypeIconMap[moduleType] || null - unsavedModuleIds.add(newId) - skipModuleLoad = true - moduleSelectedId.value = newId - await nextTick() - // 新建时直接给空草稿 - drafts.value = cloneData({ - module: newModule, - fields: [], - groups: [], - listConfig: [], - editConfig: [], - queryConfig: [], - autoCode: [], - powers: [], - }) - originals.value = cloneData(drafts.value) - loadedModuleId.value = newId -} - -function insertModuleNode(nodes, node) { - if (node.b_parent_id == null) { - nodes.push(node) - sortTree(nodes) - return - } - for (const n of nodes) { - if (String(n.b_id) === String(node.b_parent_id)) { - n.children = n.children || [] - n.children.push(node) - sortTree(n.children) - return - } - if (n.children) insertModuleNode(n.children, node) - } -} - -// 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除) -function hasSavedModuleDescendant(node) { - for (const child of node.children || []) { - if (!unsavedModuleIds.has(String(child.b_id))) return true - if (hasSavedModuleDescendant(child)) return true - } - return false -} - -// 收集节点子树内全部未保存的模块 ID(删除时清理标记) -function collectUnsavedModuleIds(node, set = new Set()) { - set.add(String(node.b_id)) - for (const child of node.children || []) collectUnsavedModuleIds(child, set) - return set -} - -async function deleteModule(id) { - const module = id != null ? findModuleNode(moduleTreeData.value, id) : selectedModule.value - if (!module || busy.value) return - const moduleId = String(module.b_id) - if (unsavedModuleIds.has(moduleId)) { - // 未保存草稿:本地递归移除子树并清理标记(草稿下不应有已保存子孙) - collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k)) - removeModuleNode(moduleTreeData.value, moduleId) - moduleSelectedId.value = null - return - } - if (hasSavedModuleDescendant(module)) { - Message.warning('该模块下还有已保存的子模块,请先删除子模块') - return - } - showConfirm({ - title: '删除模块', - content: `确定删除模块「${module.b_name || module.b_code}」?将同时删除该模块的字段、列表/编辑/查询配置、自动编码、权限等配置数据。`, - okText: '删除', - okType: 'danger', - onOk: async () => { - try { - const id = String(module.b_id) - const reqs = [] - // 收集子表行 - const subTables = [ - ['s_field', 'b_module_id'], - ['s_field_group', 'b_module_id'], - ['s_field_view', 'b_module_id'], - ['s_field_edit', 'b_module_id'], - ['s_field_query', 'b_module_id'], - ['s_autocode', 'b_module_id'], - ] - for (const [table, field] of subTables) { - const res = await loadDataApi(table, `${field} = ${id}`) - const rows = res.data || [] - if (rows.length) { - reqs.push({ table, key_field: 'b_id', deletes: rows.map((r) => ({ b_id: r.b_id })) }) - } - } - const powerRes = await loadDataApi('s_power', `b_module_id = ${id}`) - const powers = powerRes.data || [] - if (powers.length) { - reqs.push({ table: 's_power', key_field: 'b_id', deletes: powers.map((r) => ({ b_id: r.b_id })) }) - } - // 多语言(s_i18n 无 b_module_id 字段,需按模块编码匹配 b_key 前缀)。 - // 若不清理,删除后残留的孤儿键会被同编码新模块的多语言面板加载, - // 进而产生指向已不存在记录的 UPDATE,导致后端「修改记录数异常: 0」。 - const moduleCode = String(module.b_code || '').trim() - if (moduleCode) { - const prefix = module.b_module_type === 'category' ? 'menu' : 'module' - const i18nRes = await loadDataApi( - 's_i18n', - `b_key = '${prefix}.${moduleCode}' OR b_key LIKE '${prefix}.${moduleCode}.%' ` + - `OR b_key LIKE 'field.${moduleCode}.%' OR b_key LIKE 'power.${moduleCode}.%'`, - ) - const i18nRows = i18nRes.data || [] - if (i18nRows.length) { - reqs.push({ - table: 's_i18n', - key_field: 'b_id', - deletes: i18nRows.map((r) => ({ b_id: r.b_id })), - }) - } - } - reqs.push({ table: 's_module', key_field: 'b_id', deletes: [{ b_id: id }] }) - await saveObjectApi(reqs) - // 本地移除整棵子树(未保存子孙随父一并清理),并清除未保存标记 - collectUnsavedModuleIds(module).forEach((k) => unsavedModuleIds.delete(k)) - removeModuleNode(moduleTreeData.value, id) - moduleSelectedId.value = null - Message.success('模块已删除') - } catch (e) { - Message.error('删除失败:' + (e?.message || e)) - } - }, - }) -} - -function removeModuleNode(nodes, id) { - for (let i = 0; i < nodes.length; i++) { - if (String(nodes[i].b_id) === String(id)) { - nodes.splice(i, 1) - return - } - if (nodes[i].children) removeModuleNode(nodes[i].children, id) - } -} - -/* ================= 模块:面板回调 ================= */ -function onModuleUpdate(section, newData) { - if (drafts.value) drafts.value[section] = newData -} - -function onFieldsUpdate(newFields) { - if (!drafts.value) return - applyDisabledFieldDefaults(drafts.value.fields, newFields) - drafts.value.fields = newFields -} - -function applyDisabledFieldDefaults(previousFields, nextFields) { - const previousStates = new Map() - for (const field of previousFields || []) { - if (field.b_id != null) previousStates.set(String(field.b_id), Number(field.b_canuse) === 1) - } - const disabledIds = new Set() - for (const field of nextFields || []) { - if (field.b_id == null) continue - if (previousStates.get(String(field.b_id)) && Number(field.b_canuse) !== 1) { - disabledIds.add(String(field.b_id)) - } - } - if (!disabledIds.size) return - for (const row of drafts.value.listConfig || []) { - if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0 - } - for (const row of drafts.value.editConfig || []) { - if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0 - } - for (const row of drafts.value.queryConfig || []) { - if (disabledIds.has(String(row.b_field_id))) row.b_canuse = 0 - } -} - -function onAutoCodeUpdate(rows) { - if (!drafts.value) return - const previousRule = drafts.value.autoCode?.[0] - const previousFieldId = - previousRule && Number(previousRule.b_canuse) === 1 ? previousRule.b_field_id : null - drafts.value.autoCode = rows - const rule = rows && rows.length ? rows[0] : null - const nextFieldId = rule && Number(rule.b_canuse) === 1 ? rule.b_field_id : null - if (previousFieldId != null && String(previousFieldId) !== String(nextFieldId)) { - for (const config of drafts.value.editConfig || []) { - if (String(config.b_field_id) === String(previousFieldId)) config.b_readonly = 0 - } - } - if (nextFieldId == null) return - for (const config of drafts.value.editConfig || []) { - if (String(config.b_field_id) === String(nextFieldId)) config.b_readonly = 1 - } -} - -function onModuleI18nSaved() { - i18nPanelRef.value?.refreshKey?.() -} - -/* ================= 树节点元信息(面包屑) ================= */ -// 从根到当前节点的名称路径(面包屑用) -function buildBreadcrumb(node, finder) { - const names = [] - let cur = node - const seen = new Set() - while (cur && !seen.has(String(cur.b_id))) { - seen.add(String(cur.b_id)) - names.unshift(cur.b_name || cur.b_code) - if (cur.b_parent_id == null) break - cur = finder(String(cur.b_parent_id)) - } - return names -} - -const moduleBreadcrumb = computed(() => - selectedModule.value - ? buildBreadcrumb(selectedModule.value, (id) => findModuleNode(moduleTreeData.value, id)) - : [], -) - -/* ================= 左树顶部工具栏:新建 / 刷新 ================= */ -const moduleNewItems = [ - { key: 'category', label: '新增分类', icon: FolderPlus }, - { key: 'data', label: '新增数据模块', icon: Database }, - { key: 'query', label: '新增查询模块', icon: Search }, -] - -// 工具栏新增:挂到当前选中节点下(无选中则顶级);右键菜单仍保留 -function onModuleNewClick({ key }) { - const parentId = moduleSelectedId.value ? String(moduleSelectedId.value) : null - void addModule(parentId, key) -} - -/* ================= 右侧分组配置导航 ================= */ -// 配置导航分组:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。 -// 每个导航项唯一激活(activeSection)。 +/* 右侧分组配置导航:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。 + 每个导航项唯一激活(activeSection)。 */ const configGroups = computed(() => { const groups = [] groups.push({ @@ -746,227 +194,115 @@ const configNavItems = computed(() => configGroups.value.flatMap((group) => group.items.map((item) => ({ ...item }))), ) -// 编辑方式(form 主表表单 / table 子表表格)决定「表单设计」面板的形态与导航标签 -const editMode = computed(() => drafts.value?.module?.b_edit_mode || 'form') -const editNavLabel = computed(() => (editMode.value === 'table' ? '表格设计' : '表单设计')) - -const finalI18nItems = computed(() => { - const module = drafts.value?.module - if (!module) return [] - const items = [] - const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_module_type, module.b_code) - if (moduleKey) { - items.push({ - key: moduleKey, - managed: true, - defaultValue: module.b_name || '', - id: `module:${moduleKey}`, - }) - } - for (const field of drafts.value?.fields || []) { - const key = buildModuleItemI18nKey('field', module.b_code, field.b_field) - if (key) items.push({ key, managed: true, defaultValue: field.b_name || '', id: `field:${key}` }) - } - for (const power of drafts.value?.powers || []) { - const key = buildModuleItemI18nKey('power', module.b_code, power.b_code) - if (key) items.push({ key, managed: true, defaultValue: power.b_name || '', id: `power:${key}` }) - } - return items +/* ================= composables ================= */ +const { saveModule } = useModuleSave({ + selectedModule, + drafts, + originals, + loadedModuleId, + moduleSelectedId, + unsavedModuleIds, + configSaving, + busy, + activeSection, + moduleTreeData, + findModuleNode, + basicPanelRef, + autoCodePanelRef, + i18nPanelRef, + getFinalI18nItems: () => finalI18nItems.value, }) -/* ================= 模块:同步字段 ================= */ -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 - } +const { + confirmVisible, + confirmState, + confirmSaving, + showConfirm, + onConfirmOk, + switchConfirm, + switchSaving, + onSwitchCancel, + onSwitchDiscard, + onSwitchSave, +} = useConfirm({ saveModule, moduleSelectedId }) + +const { + filteredModuleTree, + loadModuleTree, + buildModuleContextMenu, + onModuleContextMenuClick, + addModule, +} = useModuleTree({ + moduleTreeData, + moduleTreeLoading, + moduleKeyword, + moduleExpandedKeys, + moduleSelectedId, + unsavedModuleIds, + drafts, + originals, + loadedModuleId, + skipModuleLoad, + busy, + showConfirm, + selectedModule, +}) + +const { + reloadModule, + onModuleSelect, + onFieldsUpdate, + onAutoCodeUpdate, + syncFields, +} = useModuleConfig({ + moduleSelectedId, + moduleTreeData, + findModuleNode, + selectedModule, + busy, + configLoading, + configSyncing, + activeSection, + loadedModuleId, + originals, + drafts, + skipModuleLoad, + showConfirm, + switchConfirm, + i18nPanelRef, +}) + +/* ================= 快捷键 ================= */ +// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys) +useHotkeys([ + { + key: 's', + mod: true, + ignoreWhenTyping: false, // 输入框内也能保存 + handler: () => void saveModule(), + }, +]) + +/* ================= 面板回调 ================= */ +function onModuleUpdate(section, newData) { + if (drafts.value) drafts.value[section] = newData } -/* ================= 模块:保存 ================= */ -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 '' +function onModuleI18nSaved() { + i18nPanelRef.value?.refreshKey?.() } -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 moduleNewItems = [ + { key: 'category', label: '新增分类', icon: FolderPlus }, + { key: 'data', label: '新增数据模块', icon: Database }, + { key: 'query', label: '新增查询模块', icon: Search }, +] - const isNew = unsavedModuleIds.has(moduleId) - if (moduleData && (isNew || JSON.stringify(moduleData) !== JSON.stringify(originals.value?.module))) { - const moduleRequest = { table: 's_module', key_field: 'b_id' } - if (isNew) moduleRequest.inserts = [moduleData] - else moduleRequest.updates = [moduleData] - saveReqs.push(moduleRequest) - } - - const subTables = [ - ['s_field', 'fields'], - ['s_field_group', 'groups'], - ['s_field_view', 'listConfig'], - ['s_field_edit', 'editConfig'], - ['s_field_query', 'queryConfig'], - ['s_autocode', 'autoCode'], - ['s_power', 'powers'], - ] - // 收集字段/分组的临时 id → 真实 id 映射,用于跨表外键重映射 - const fieldIdMap = new Map() - const groupIdMap = new Map() - for (const [table, section] of subTables) { - const current = drafts.value[section] || [] - const previous = originals.value?.[section] || [] - const changes = diffRows(current, previous) - if (table === 's_autocode') { - changes.updates = changes.updates.map((row) => { - const { b_currentperiod: _p, b_currentvalue: _v, ...config } = row - return config - }) - } - const req = tableChange(table, changes) - if (hasChanges(req)) { - const mapping = await allocateTemporaryIds([req.inserts || [], req.updates || []]) - if (table === 's_field') mapping.forEach((v, k) => fieldIdMap.set(k, v)) - if (table === 's_field_group') mapping.forEach((v, k) => groupIdMap.set(k, v)) - saveReqs.push(req) - } - } - - // 统一重映射跨表外键:列表/编辑/查询配置中引用字段、分组的临时 id 换成真实 id - if (fieldIdMap.size) { - for (const section of ['listConfig', 'editConfig', 'queryConfig']) { - remapForeignKeys(drafts.value[section] || [], ['b_field_id'], fieldIdMap) - } - } - if (groupIdMap.size) { - for (const section of ['listConfig', 'editConfig', 'queryConfig']) { - remapForeignKeys(drafts.value[section] || [], ['b_group_id'], groupIdMap) - } - } - - const i18nPanel = i18nPanelRef.value - if (i18nPanel && typeof i18nPanel.reconcileManagedRows === 'function') { - i18nPanel.reconcileManagedRows(finalI18nItems.value) - } - if (i18nPanel && typeof i18nPanel.buildSaveRequest === 'function') { - const i18nReq = i18nPanel.buildSaveRequest() - if (i18nReq) { - // 为新增的多语言记录分配真实主键,并回写内部状态,避免二次保存重复插入 - if (i18nReq.inserts?.length) { - await allocateTemporaryIds([i18nReq.inserts]) - i18nPanel.applyInsertedIds?.(i18nReq.inserts) - } - saveReqs.push(i18nReq) - } - } - - if (saveReqs.length === 0) { - Message.info('没有需要保存的变更') - return true - } - await saveObjectApi(saveReqs) - - originals.value = cloneData(drafts.value) - i18nPanel?.markSaved?.() - unsavedModuleIds.delete(moduleId) - // 更新树节点显示(名称/编码可能变化) - const node = findModuleNode(moduleTreeData.value, moduleId) - if (node) Object.assign(node, cloneData(moduleData)) - Message.success('保存成功') - return true - } catch (e) { - Message.error('保存失败:' + (e?.message || e)) - return false - } finally { - configSaving.value = false - } +// 工具栏新增:挂到当前选中节点下(无选中则顶级);右键菜单仍保留 +function onModuleNewClick({ key }) { + const parentId = moduleSelectedId.value ? String(moduleSelectedId.value) : null + void addModule(parentId, key) } /* ================= 初始化 ================= */