langStore.languages.map((l) => ({
@@ -103,6 +111,43 @@ const i18nLangOptions = computed(() =>
})),
)
+/** 默认语言 code(与面板语义一致:b_default=1,缺省回退第一个启用语言) */
+const defaultLocaleCode = computed(
+ () => i18nLangOptions.value.find((l) => l.default)?.code || i18nLangOptions.value[0]?.code || '',
+)
+
+/** 当前模块的受管 i18n 键项(模块名/字段名/分组标题),用于 reconcile 补齐与跟名 */
+const managedI18nItems = computed(() =>
+ collectManagedI18nItems({
+ module: drafts.value?.module,
+ fields: drafts.value?.fields || [],
+ groups: drafts.value?.groups || [],
+ }),
+)
+
+/** 按最新受管键 reconcile 草稿透视行(index.vue 统一维护 drafts.i18n) */
+function applyManagedI18n() {
+ const draft = drafts.value
+ if (!draft?.module) return
+ draft.i18n = reconcileI18nRows(
+ draft.i18n || [],
+ collectManagedI18nItems({
+ module: draft.module,
+ fields: draft.fields || [],
+ groups: draft.groups || [],
+ }),
+ { defaultLocale: defaultLocaleCode.value, languages: i18nLangOptions.value },
+ )
+}
+
+// 受管键变化(字段/分组改名、新增、删除,模块名/编码变化)→ 防抖补齐草稿透视行
+watch(managedI18nItems, () => {
+ if (configLoading.value || !drafts.value?.module) return
+ clearTimeout(i18nReconcileTimer)
+ i18nReconcileTimer = setTimeout(applyManagedI18n, 300)
+})
+onBeforeUnmount(() => clearTimeout(i18nReconcileTimer))
+
/* ================= 派生状态(index.vue 组装) ================= */
const selectedModule = computed(() =>
moduleSelectedId.value ? findTreeNode(moduleTreeData.value, moduleSelectedId.value) : null,
@@ -192,122 +237,23 @@ function validateAutoCode() {
return ''
}
-async function saveModule() {
+async function handleSave() {
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')
- 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'],
- ]
- // 收集字段/分组的临时 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)
- }
- }
-
- // i18n 数据独立保存于 s_i18n:由面板按基线 diff 生成请求
- const i18nReq = i18nPanelRef.value?.buildSaveRequest?.()
- if (i18nReq) {
- // 为新增的多语言记录分配真实主键,并回写面板透视行,避免二次保存重复插入
- if (i18nReq.inserts?.length) {
- await allocateTemporaryIds([i18nReq.inserts])
- i18nPanelRef.value?.applyInsertedIds?.(i18nReq.inserts)
- }
- saveReqs.push(i18nReq)
- }
-
- if (saveReqs.length === 0) {
+ if (!(await beforeSave())) return false
+ const saveData = await buildSaveData()
+ if (!saveData) {
Message.info('没有需要保存的变更')
return true
}
- await saveObjectApi(saveReqs)
-
- unsavedModuleIds.delete(moduleId)
- // 更新树节点显示(名称/编码可能变化)
- const node = findTreeNode(moduleTreeData.value, moduleId)
- if (node) Object.assign(node, cloneData(moduleData))
- // 保存后重新加载:以数据库落库结果为准刷新草稿与各面板,
- // 临时 id → 真实 id 也由本次重载自然回灌,无需本地换引用。
- const prevSection = activeSection.value
- await loadModuleConfig(moduleId)
- activeSection.value = prevSection
- Message.success('保存成功')
+ await saveObjectApi(saveData)
+ await afterSave()
return true
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
@@ -317,6 +263,136 @@ async function saveModule() {
}
}
+/** 保存前校验、字段同步与数据准备;返回 false 终止保存 */
+async function beforeSave() {
+ // 基础信息与自动编码的字段级校验:错误已显示在对应 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
+ // 字段同步:受管 i18n 键物化(写回草稿,保存 diff 以物化后的行与基线比较)
+ const moduleCode = String(moduleData.b_code || '').trim()
+ syncModuleItemI18nKeys(drafts.value.fields, 'field', moduleCode, 'b_field')
+ moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleCode)
+ return true
+}
+
+/** 组装保存请求:主表/子表/i18n 均与各自基线 diff,只提交差异;全部无变化返回 null */
+async function buildSaveData() {
+ const moduleId = String(selectedModule.value.b_id)
+ const moduleData = drafts.value?.module
+ if (!moduleData) return null
+ const saveReqs = []
+ const isNew = unsavedModuleIds.has(moduleId)
+ // 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
+ const moduleChanges = diffRow({
+ current: moduleData,
+ original: isNew ? null : originals.value?.module || null,
+ })
+ if (moduleChanges.hasChanges) {
+ const moduleRequest = { table: 's_module', key_field: 'b_id' }
+ if (moduleChanges.inserts.length) moduleRequest.inserts = moduleChanges.inserts
+ if (moduleChanges.updates.length) moduleRequest.updates = moduleChanges.updates
+ 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'],
+ ]
+ // 收集字段/分组的临时 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)
+ }
+ }
+
+ // i18n:仅展示多语言面板的模块(data/query)纳入统一 diff;分类模块无多语言
+ const supportsI18n = moduleData.b_module_type && moduleData.b_module_type !== 'category'
+ if (supportsI18n) {
+ // 保存前先按最新受管键 reconcile 草稿行,再与原始扁平行 diff 生成请求
+ applyManagedI18n()
+ const i18nReq = buildI18nChangeRequest({
+ draftRows: drafts.value.i18n || [],
+ originalFlat: originals.value?.i18n || [],
+ moduleId,
+ languages: i18nLangOptions.value,
+ })
+ if (i18nReq) {
+ // 为新增的多语言记录分配真实主键,避免二次保存重复插入
+ if (i18nReq.inserts?.length) {
+ await allocateTemporaryIds([i18nReq.inserts])
+ }
+ saveReqs.push(i18nReq)
+ }
+ }
+
+ return saveReqs.length ? saveReqs : null
+}
+
+/** 保存成功后的树节点同步、服务端重载与状态更新 */
+async function afterSave() {
+ const moduleId = String(selectedModule.value.b_id)
+ const moduleData = drafts.value?.module
+ unsavedModuleIds.delete(moduleId)
+ // 更新树节点显示(名称/编码可能变化)
+ const node = findTreeNode(moduleTreeData.value, moduleId)
+ if (node) Object.assign(node, cloneData(moduleData))
+ // 保存后重新加载:以数据库落库结果为准刷新草稿与各面板,
+ // 临时 id → 真实 id 也由本次重载自然回灌,无需本地换引用。
+ const prevSection = activeSection.value
+ await loadModuleConfig(moduleId)
+ activeSection.value = prevSection
+ Message.success('保存成功')
+}
+
/* ================= 确认弹窗状态机(仅本页使用,不抽 composable) ================= */
/* 通用确认弹窗(ui/modal 为组件式 API) */
const confirmVisible = ref(false)
@@ -497,8 +573,8 @@ async function addModule(parentId, moduleType) {
editConfig: [],
queryConfig: [],
autoCode: [],
+ i18n: [],
})
- rawI18nRows.value = []
originals.value = cloneData(drafts.value)
loadedModuleId.value = newId
}
@@ -528,7 +604,7 @@ async function deleteModule(id) {
const id2 = String(module.b_id)
// 子表按 b_module_id 条件删(后端 deletes 行内所有列组成 AND 条件),
// 无需先查出各行 id;主表仍按主键删。
- // s_i18n 也按 b_module_id 关联删除(受管键均回填 b_module_id,见 ModuleI18nPanel.buildSaveRequest);
+ // s_i18n 也按 b_module_id 关联删除(受管键均回填 b_module_id,见 utils.js 的 buildI18nChangeRequest);
// 若不清理,删除后残留的孤儿键会被同编码新模块的多语言面板加载,
// 进而产生指向已不存在记录的 UPDATE,导致后端「修改记录数异常: 0」。
const subTables = [
@@ -699,19 +775,19 @@ async function loadModuleConfig(moduleId) {
editConfig: editRes?.data || [],
queryConfig: queryRes?.data || [],
autoCode: autoCodeRes?.data || [],
+ // i18n 原始扁平行(保存 diff 基线;s_i18n 按 b_module_id 关联)
+ i18n: (i18nRes?.data || []).map((row) => ({
+ b_id: row.b_id,
+ b_key: row.b_key,
+ b_locale: row.b_locale,
+ b_value: row.b_value,
+ })),
}
originals.value = original
drafts.value = cloneData(original)
- // i18n 随模块配置一起并行加载(s_i18n 按 b_module_id 关联),
- // 只取原始扁平行传给 ModuleI18nPanel,由面板自持 pivot/reconcile/保存 diff
- rawI18nRows.value = i18nRes
- ? (i18nRes.data || []).map((row) => ({
- b_id: row.b_id,
- b_key: row.b_key,
- b_locale: row.b_locale,
- b_value: row.b_value,
- }))
- : []
+ // i18n 透视草稿行:先由原始扁平行转透视,再由 index.vue 统一 reconcile(受管键补齐/跟名)
+ drafts.value.i18n = buildI18nPivot(original.i18n)
+ applyManagedI18n()
// 列表配置不再自动补全:行存在即展示,删除即真删,由用户通过字段库手动管理(与查询配置一致)。
loadedModuleId.value = id
activeSection.value = 'basic'
@@ -736,7 +812,6 @@ watch(moduleSelectedId, (id) => {
configLoading.value = false
loadedModuleId.value = null
drafts.value = null
- rawI18nRows.value = []
return
}
// 未落库的新模块:临时 id 查库无行,不走 loadModuleConfig(会得到 module 为 null 的草稿)。
@@ -757,8 +832,8 @@ watch(moduleSelectedId, (id) => {
editConfig: [],
queryConfig: [],
autoCode: [],
+ i18n: [],
})
- rawI18nRows.value = []
originals.value = cloneData(drafts.value)
loadedModuleId.value = String(id)
return
@@ -801,9 +876,9 @@ async function onSwitchSave() {
if (!state || switchSaving.value) return
switchSaving.value = true
try {
- const ok = await saveModule()
+ const ok = await handleSave()
if (!ok) {
- // 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
+ // 保存失败(原因已由 handleSave 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
Message.warning('保存未完成,已留在当前对象,请处理后再切换')
return
}
@@ -915,7 +990,11 @@ function applySyncPlan(plan) {
(row) => !plan.removedIds.has(String(row.b_field_id)),
)
}
- i18nPanelRef.value?.removeByManagedIds?.(Array.from(plan.removedI18nIds))
+ // 失效字段的受管 i18n 行(_managedId 为 `field:${key}`)一并移除
+ const removedManaged = new Set(Array.from(plan.removedI18nIds).map(String))
+ drafts.value.i18n = (drafts.value.i18n || []).filter(
+ (row) => !removedManaged.has(String(row._managedId)),
+ )
}
drafts.value.fields = plan.mergedFields
Message.success(`已成功同步 ${plan.mergedFields.length} 个字段`)
@@ -951,7 +1030,7 @@ useHotkeys([
key: 's',
mod: true,
ignoreWhenTyping: false, // 输入框内也能保存
- handler: () => void saveModule(),
+ handler: () => void handleSave(),
},
])
@@ -1058,7 +1137,7 @@ loadModuleTree()
type="primary"
:loading="configSaving"
:disabled="configLoading || !drafts"
- @click="saveModule"
+ @click="handleSave"
>
保存
@@ -1193,12 +1272,7 @@ loadModuleTree()
class="mm-config-pane"
>
diff --git a/code/fms/fms-vue/src/views/module/module-management/utils.js b/code/fms/fms-vue/src/views/module/module-management/utils.js
new file mode 100644
index 00000000..30d13bb0
--- /dev/null
+++ b/code/fms/fms-vue/src/views/module/module-management/utils.js
@@ -0,0 +1,223 @@
+import { createTempId } from '@/utils/common'
+import { diffRows } from '@/utils/dataChanges'
+
+export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
+ const normalizedModuleCode = String(moduleCode || '').trim()
+ const normalizedItemCode = String(itemCode || '').trim()
+ return normalizedModuleCode && normalizedItemCode
+ ? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}`
+ : null
+}
+
+export function buildModuleI18nKey(moduleType, moduleCode) {
+ const normalizedModuleCode = String(moduleCode || '').trim()
+ if (!normalizedModuleCode) return null
+ const prefix = moduleType === 'data' || moduleType === 'query' ? 'module' : 'menu'
+ return `${prefix}.${normalizedModuleCode}`
+}
+
+export function syncModuleItemI18nKeys(rows, prefix, moduleCode, codeField) {
+ let changed = false
+ for (const row of rows || []) {
+ const key = buildModuleItemI18nKey(prefix, moduleCode, row?.[codeField])
+ if (row.b_i18n === key) continue
+ row.b_i18n = key
+ changed = true
+ }
+ return changed
+}
+
+/** s_i18n 扁平行 → 透视行(每 key 一行,每语言一列,_localeIds 记录已持久化 cell 的主键) */
+export function buildI18nPivot(flat) {
+ const byKey = new Map()
+ for (const r of flat || []) {
+ if (!byKey.has(r.b_key)) byKey.set(r.b_key, [])
+ byKey.get(r.b_key).push(r)
+ }
+ const rows = []
+ for (const [key, recs] of byKey) {
+ const row = { _id: createTempId(), b_key: key, _localeIds: {} }
+ for (const r of recs) {
+ row[r.b_locale] = r.b_value
+ row._localeIds[r.b_locale] = r.b_id
+ }
+ rows.push(row)
+ }
+ return rows
+}
+
+/**
+ * 收集模块的受管多语言键项:模块名、字段名、分组标题(b_i18n 手填 key)等系统自动生成的键。
+ * item.id 为稳定标识(module:/field:/group: 前缀),供 reconcile 判失效。
+ */
+export function collectManagedI18nItems({ module, fields, groups }) {
+ 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 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}`,
+ })
+ }
+ }
+ // 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管)
+ for (const group of groups || []) {
+ const key = String(group.b_i18n || '').trim()
+ if (key) {
+ items.push({
+ key,
+ managed: true,
+ defaultValue: group.b_title || '',
+ id: `group:${group.b_id}`,
+ })
+ }
+ }
+ return items
+}
+
+function isEmptyI18nManaged(row, defaultLocale, languages) {
+ if (row._localeIds && Object.keys(row._localeIds).length) return false
+ for (const lang of languages || []) {
+ const value = row[lang.code]
+ if (value == null || value === '') continue
+ if (lang.code === defaultLocale && String(value) === String(row._defaultValue ?? '')) continue
+ return false
+ }
+ return true
+}
+
+/**
+ * 保证模块/字段/分组受管键始终存在:默认语言为空时自动跟随当前名称,人工翻译不被覆盖。
+ * pivotRows 为草稿透视行(只读遍历),返回补齐/合并后的新透视行数组。
+ */
+export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }) {
+ const rows = [...(pivotRows || [])]
+ const rowByManagedId = new Map()
+ const unclaimedByKey = new Map()
+ for (const row of rows) {
+ if (row._managedId) rowByManagedId.set(String(row._managedId), row)
+ else unclaimedByKey.set(String(row.b_key), row)
+ }
+ const currentManagedIds = new Set()
+
+ for (const item of items) {
+ const managedId = String(item.id || item.key)
+ currentManagedIds.add(managedId)
+ let row = rowByManagedId.get(managedId)
+ if (!row) row = unclaimedByKey.get(String(item.key))
+ if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey))
+ if (!row) {
+ row = { _id: createTempId(), b_key: item.key, _localeIds: {}, _syncInjected: true }
+ rows.push(row)
+ }
+ const previousDefault = row._defaultValue
+ const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue)
+ if (
+ defaultLocale &&
+ (row[defaultLocale] == null ||
+ row[defaultLocale] === '' ||
+ row[defaultLocale] === previousDefault)
+ ) {
+ row[defaultLocale] = nextDefault
+ }
+ row.b_key = String(item.key)
+ row._managedId = managedId
+ row._defaultValue = nextDefault
+ row._sortType = String(item.key).split('.', 1)[0]
+ }
+
+ // 键已失效的受管行:自动创建且无内容的直接丢弃,有翻译或来自数据库的解除保护保留
+ const keptRows = []
+ for (const row of rows) {
+ if (row._managedId && !currentManagedIds.has(String(row._managedId))) {
+ if (row._syncInjected && isEmptyI18nManaged(row, defaultLocale, languages)) continue
+ delete row._managedId
+ delete row._defaultValue
+ }
+ keptRows.push(row)
+ }
+
+ // 按 key 合并:优先保留受管行,缺失列从其余同 key 行补齐
+ const byKey = new Map()
+ const mergedRows = []
+ for (const row of keptRows) {
+ const key = String(row.b_key || '')
+ const hit = byKey.get(key)
+ if (!hit) {
+ byKey.set(key, row)
+ mergedRows.push(row)
+ continue
+ }
+ for (const [field, value] of Object.entries(row)) {
+ if (field === '_id' || field === '_localeIds') continue
+ if ((hit[field] == null || hit[field] === '') && value != null) hit[field] = value
+ }
+ Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {})
+ if (row._managedId && !hit._managedId) hit._managedId = row._managedId
+ if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected
+ if (row._defaultValue != null && hit._defaultValue == null)
+ hit._defaultValue = row._defaultValue
+ if (row._sortType && !hit._sortType) hit._sortType = row._sortType
+ }
+ return mergedRows
+}
+
+/**
+ * 生成 s_i18n 变更请求:透视草稿与原始扁平行 diff。
+ * 只返回有变化的 cell;完全一致返回 null(无变化不提交)。
+ */
+export function buildI18nChangeRequest({ draftRows, originalFlat, moduleId, languages }) {
+ // 模块命名空间:这些键属于当前模块元数据,落库写入 b_module_id 便于级联删除;其余保持 NULL
+ const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group']
+ const moduleIdOf = (key) => {
+ const namespace = String(key || '').split('.', 1)[0] || 'common'
+ return MODULE_NAMESPACES.includes(namespace) ? String(moduleId) : null
+ }
+ // diffRows 按 b_id 对「行数组」diff,故先把透视草稿展平成扁平行(每个 key+locale 一行)
+ const current = []
+ for (const row of draftRows || []) {
+ const key = String(row.b_key || '')
+ if (!key) continue
+ for (const lang of languages || []) {
+ const locale = lang.code
+ const value = row[locale] == null ? '' : String(row[locale])
+ const localeId = row._localeIds?.[locale]
+ // 已持久化 cell 保留原主键(值清空仍保留行,交由 update 置空);
+ // 未持久化 cell 仅在有值时登记(临时负数 id,由保存编排统一换真实主键)
+ if (localeId) current.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value })
+ else if (value !== '')
+ current.push({ b_id: createTempId(), b_key: key, b_locale: locale, b_value: value })
+ }
+ }
+ const changes = diffRows(current, originalFlat || [])
+ if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) return null
+ // 落库前补齐命名空间关联列(比较阶段不含它们,避免「打开即脏」)
+ const enrich = (row) => ({
+ b_id: row.b_id,
+ b_key: row.b_key,
+ b_locale: row.b_locale,
+ b_value: row.b_value,
+ b_module_id: moduleIdOf(row.b_key),
+ b_canuse: 1,
+ })
+ return {
+ table: 's_i18n',
+ key_field: 'b_id',
+ inserts: changes.inserts.map(enrich),
+ updates: changes.updates.map(enrich),
+ deletes: changes.deletes,
+ }
+}
diff --git a/code/fms/fms-vue/tests/stores/stores.spec.js b/code/fms/fms-vue/tests/stores/stores.spec.js
index 012b36ee..5c6e59a4 100644
--- a/code/fms/fms-vue/tests/stores/stores.spec.js
+++ b/code/fms/fms-vue/tests/stores/stores.spec.js
@@ -3,6 +3,7 @@ import { nextTick } from 'vue'
import { createTestPinia } from '../helpers/app.js'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
+import { useI18nStore } from '@/stores/i18n'
// app store 的 initThemeSync 依赖 theme 模块操作 documentElement,jest 环境可安全执行
vi.mock('@/theme/index', () => ({
@@ -22,7 +23,6 @@ describe('app store · 主题与布局', () => {
const app = useAppStore()
expect(app.darkMode).toBe(false)
expect(app.sidebarCollapsed).toBe(false)
- expect(app.locale).toBe('zh-CN')
expect(app.visitedTabs).toHaveLength(1)
expect(app.visitedTabs[0]).toMatchObject({ key: '/dashboard', closable: false })
})
@@ -36,7 +36,7 @@ describe('app store · 主题与布局', () => {
expect(app.darkMode).toBe(false)
})
- it('setPrimary / toggleSidebar / setSidebar / toggleLocale', () => {
+ it('setPrimary / toggleSidebar / setSidebar', () => {
createTestPinia()
const app = useAppStore()
app.setPrimary('#123456')
@@ -45,8 +45,15 @@ describe('app store · 主题与布局', () => {
expect(app.sidebarCollapsed).toBe(true)
app.setSidebar(false)
expect(app.sidebarCollapsed).toBe(false)
- app.toggleLocale()
- expect(app.locale).toBe('en-US')
+ })
+
+ it('i18n store 管理界面语言切换', () => {
+ createTestPinia()
+ const i18n = useI18nStore()
+ i18n.setLocale('zh-CN')
+ expect(i18n.locale).toBe('zh-CN')
+ i18n.toggleLocale()
+ expect(i18n.locale).toBe('en-US')
})
})
diff --git a/code/fms/fms-vue/tests/unit/data-diff.spec.js b/code/fms/fms-vue/tests/unit/data-diff.spec.js
new file mode 100644
index 00000000..eaada657
--- /dev/null
+++ b/code/fms/fms-vue/tests/unit/data-diff.spec.js
@@ -0,0 +1,86 @@
+/**
+ * dataChanges.diffRow(单条记录字段级差异比对)测试:
+ * 语义对齐旧系统 diffData 的单表分支 —— 无基线视为新建(整行 insert),
+ * 修改时 updates 只携带「变化字段 + 主键」,内置 null/''/undefined 等价、
+ * 数字型等价与 _/v_/ignoreFields 过滤;主键默认 'b_id',可自定义。
+ */
+import { describe, expect, it } from 'vitest'
+import { diffRow } from '@/utils/dataChanges'
+
+describe('diffRow 单条记录', () => {
+ it('无基线时视为新建,返回整行 insert', () => {
+ const changes = diffRow({
+ current: { b_id: '1', b_code: 'demo', b_name: '演示模块' },
+ original: null,
+ })
+ expect(changes.hasChanges).toBe(true)
+ expect(changes.inserts).toEqual([{ b_id: '1', b_code: 'demo', b_name: '演示模块' }])
+ expect(changes.updates).toHaveLength(0)
+ })
+
+ it('与基线完全一致时 hasChanges 为 false(不产生仅主键的空 update)', () => {
+ const original = { b_id: '1', b_code: 'demo', b_name: '演示模块' }
+ const changes = diffRow({ current: { ...original }, original })
+ expect(changes.hasChanges).toBe(false)
+ expect(changes.updates).toHaveLength(0)
+ })
+
+ it('修改只提交变化字段 + 主键', () => {
+ const original = { b_id: '1', b_code: 'demo', b_name: '演示模块', b_xh: 10 }
+ const changes = diffRow({
+ current: { b_id: '1', b_code: 'demo', b_name: '改名后', b_xh: 10 },
+ original,
+ })
+ expect(changes.updates).toEqual([{ b_id: '1', b_name: '改名后' }])
+ })
+
+ it('null/undefined/空串 与数字型(40 vs "40.00")归一后不误报变更', () => {
+ const original = { b_id: '1', b_name: '演示', b_code: null, b_xh: 40 }
+ const changes = diffRow({
+ current: { b_id: '1', b_name: '演示', b_code: '', b_xh: '40.00' },
+ original,
+ })
+ expect(changes.hasChanges).toBe(false)
+ })
+
+ it('跳过 _ 前缀与 v_ 前缀字段,ignoreFields 不参与比较', () => {
+ const original = {
+ b_id: '1',
+ b_name: '演示',
+ _treeKey: 'x',
+ v_customer_name: 'A',
+ b_currentperiod: '2026-01',
+ }
+ const changes = diffRow({
+ current: {
+ b_id: '1',
+ b_name: '演示',
+ _treeKey: 'y', // 内部字段变化不影响
+ v_customer_name: 'B', // 视图字段变化不影响
+ b_currentperiod: '2026-02', // ignoreFields 内,不影响
+ },
+ original,
+ ignoreFields: ['b_currentperiod'],
+ })
+ expect(changes.hasChanges).toBe(false)
+ })
+
+ it('insert 行剔除 _/v_/ignoreFields 前缀字段', () => {
+ const changes = diffRow({
+ current: { b_id: '1', b_name: '新', _ui: true, v_view: 'x', b_xh: 10 },
+ original: null,
+ ignoreFields: ['b_xh'],
+ })
+ expect(changes.inserts).toEqual([{ b_id: '1', b_name: '新' }])
+ })
+
+ it('支持非默认主键(primaryKey)', () => {
+ const original = { gid: '7', b_name: '旧' }
+ const changes = diffRow({
+ current: { gid: '7', b_name: '新' },
+ original,
+ primaryKey: 'gid',
+ })
+ expect(changes.updates).toEqual([{ gid: '7', b_name: '新' }])
+ })
+})
diff --git a/code/fms/fms-vue/tests/unit/delete-confirm-regression.spec.js b/code/fms/fms-vue/tests/unit/delete-confirm-regression.spec.js
index a32f2e52..6ab9af89 100644
--- a/code/fms/fms-vue/tests/unit/delete-confirm-regression.spec.js
+++ b/code/fms/fms-vue/tests/unit/delete-confirm-regression.spec.js
@@ -11,7 +11,9 @@ registerFeature(useAreaSelection)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
- get() { return 5000 }
+ get() {
+ return 5000
+ },
})
})
@@ -22,9 +24,17 @@ vi.mock('@/services/api', () => ({
describeApi: vi.fn(),
}))
-const fields = [
- { b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_canuse: 1 }
-]
+const fields = [{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_canuse: 1 }]
+
+/** 受控 rows 模型:把最后一次 update:rows 回灌到 props,模拟父级 v-model */
+async function syncRows(wrapper) {
+ const events = wrapper.emitted('update:rows')
+ if (events && events.length) {
+ await wrapper.setProps({ rows: events.at(-1)[0] })
+ }
+ await nextTick()
+ await new Promise((r) => setTimeout(r, 20))
+}
describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
beforeEach(() => setActivePinia(createPinia()))
@@ -63,14 +73,20 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
await settle()
}
+ async function clickToolbarAdd(wrapper, label) {
+ const btn = wrapper.findAll('button').find((b) => b.text().includes(label))
+ expect(btn, `${label} 按钮应存在`).toBeTruthy()
+ await btn.trigger('click')
+ await settle()
+ }
+
it('查询面板:单个删除不弹弹窗且行被移除', async () => {
const wrapper = mount(ModuleQueryConfigPanel, {
props: { moduleId: 'm1', queryConfig: [], fields, loading: false },
attachTo: document.body,
})
await settle()
- await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
- await settle()
+ await clickToolbarAdd(wrapper, '新增查询条件')
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(1)
await clickDelete(wrapper)
await checkNoModal()
@@ -85,8 +101,7 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
})
await settle()
for (let i = 0; i < 2; i++) {
- await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
- await settle()
+ await clickToolbarAdd(wrapper, '新增查询条件')
}
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(2)
await selectRowCheckbox(wrapper, 0)
@@ -97,10 +112,9 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
wrapper.unmount()
})
- it('多语言面板:单个删除不弹弹窗且行被移除', async () => {
+ it('多语言面板:新增/单个删除通过 update:rows 上报且不弹弹窗', async () => {
const wrapper = mount(ModuleI18nPanel, {
props: {
- moduleId: 'm1',
rows: [],
languages: [
{ code: 'zh-CN', name: '简体中文', default: true },
@@ -111,20 +125,22 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
attachTo: document.body,
})
await settle()
- await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
- await settle()
- const before = wrapper.findAll('.stk-tbody-main tr').length
- expect(before).toBeGreaterThan(0)
+ await clickToolbarAdd(wrapper, '新增多语言')
+ await syncRows(wrapper)
+ const rowsAfterAdd = wrapper.props('rows')
+ expect(rowsAfterAdd.length).toBe(1)
+ expect(rowsAfterAdd[0]._managedId, '手动新增行不受管,可删除').toBeUndefined()
+
await clickDelete(wrapper)
+ await syncRows(wrapper)
await checkNoModal()
- expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应减少').toBe(before - 1)
+ expect(wrapper.props('rows').length, '单个删除后行应减少').toBe(0)
wrapper.unmount()
})
- it('多语言面板:批量删除选中多行', async () => {
+ it('多语言面板:批量删除选中多行通过 update:rows 上报且不弹弹窗', async () => {
const wrapper = mount(ModuleI18nPanel, {
props: {
- moduleId: 'm1',
rows: [],
languages: [
{ code: 'zh-CN', name: '简体中文', default: true },
@@ -135,18 +151,17 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
attachTo: document.body,
})
await settle()
- const before = wrapper.findAll('.stk-tbody-main tr').length
for (let i = 0; i < 2; i++) {
- await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
- await settle()
+ await clickToolbarAdd(wrapper, '新增多语言')
+ await syncRows(wrapper)
}
- const total = wrapper.findAll('.stk-tbody-main tr').length
- expect(total).toBeGreaterThanOrEqual(before + 2)
+ expect(wrapper.props('rows').length).toBe(2)
await selectRowCheckbox(wrapper, 0)
await selectRowCheckbox(wrapper, 1)
await clickToolbarBatch(wrapper)
+ await syncRows(wrapper)
await checkNoModal()
- expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应只剩受管行').toBe(before)
+ expect(wrapper.props('rows').length, '批量删除后应无行').toBe(0)
wrapper.unmount()
})
-})
\ No newline at end of file
+})
diff --git a/code/fms/fms-vue/tests/unit/field-participation.spec.js b/code/fms/fms-vue/tests/unit/field-participation.spec.js
deleted file mode 100644
index dce67082..00000000
--- a/code/fms/fms-vue/tests/unit/field-participation.spec.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 字段参与开关(fieldParticipation)测试:
- * - 判定:行存在 + 行内开关(表单/列表 b_visible、查询 b_canuse),b_field_id 与 b_id 类型不一致按字符串比较
- * - 切换:行为空/存在两路;关 = 置行内开关 0(不删行,保留布局/配置);开 = 行不存在时按默认生成且强制参与
- * - 不变性:toggle 返回新数组、非目标行保持原引用
- */
-import { describe, it, expect } from 'vitest'
-import {
- isFormOn,
- isListOn,
- isQueryOn,
- toggleForm,
- toggleList,
- toggleQuery,
-} from '@/views/module/module-management/composables/fieldParticipation'
-
-const MODULE_ID = 'm1'
-
-function makeField(over = {}) {
- return { b_id: '10', b_field: 'amount', b_name: '金额', b_type: 'money', b_canuse: 1, ...over }
-}
-
-describe('参与判定', () => {
- const field = makeField()
-
- it('行存在且开关为 1 即参与;行不存在不参与', () => {
- expect(isFormOn([{ b_field_id: '10', b_visible: 1 }], field)).toBe(true)
- expect(isFormOn([{ b_field_id: '10', b_visible: 0 }], field)).toBe(false)
- expect(isFormOn([], field)).toBe(false)
- expect(isListOn([{ b_field_id: 10, b_visible: 1 }], field)).toBe(true) // 类型不一致按字符串比较
- expect(isQueryOn([{ b_field_id: '10', b_canuse: 1 }], field)).toBe(true)
- expect(isQueryOn([{ b_field_id: '10', b_canuse: 0 }], field)).toBe(false)
- })
-
- it('空配置安全', () => {
- expect(isFormOn(undefined, field)).toBe(false)
- expect(isListOn(null, field)).toBe(false)
- expect(isQueryOn([], field)).toBe(false)
- })
-})
-
-describe('表单参与切换', () => {
- it('行存在:切换 b_visible,不删行(保留布局)', () => {
- const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1, b_colspan: 12, b_group_id: 'g1' }]
- const off = toggleForm(rows, makeField(), MODULE_ID)
- expect(off).toHaveLength(1)
- expect(off[0].b_visible).toBe(0)
- expect(off[0].b_colspan).toBe(12) // 布局属性保留
- expect(off[0].b_group_id).toBe('g1')
- const on = toggleForm(off, makeField(), MODULE_ID)
- expect(on[0].b_visible).toBe(1)
- })
-
- it('行不存在:生成默认行追加到末尾(b_visible=1、b_colspan=24、b_xh 为末尾序号)', () => {
- const rows = [{ b_id: 'e1', b_field_id: '9', b_visible: 1, b_xh: 30 }]
- const next = toggleForm(rows, makeField(), MODULE_ID)
- expect(next).toHaveLength(2)
- const created = next[1]
- expect(created.b_field_id).toBe('10')
- expect(created.b_visible).toBe(1)
- expect(created.b_colspan).toBe(24)
- expect(created.b_module_id).toBe('m1')
- expect(created.b_xh).toBe(40) // 末尾追加,不排到表单最前
- expect(next[0]).toBe(rows[0]) // 非目标行保持原引用
- })
-})
-
-describe('列表参与切换', () => {
- it('行存在:切换 b_visible(与「删除列=隐藏」语义一致)', () => {
- const rows = [{ b_id: 'v1', b_field_id: '10', b_visible: 1, b_width: 110 }]
- const off = toggleList(rows, makeField(), MODULE_ID)
- expect(off[0].b_visible).toBe(0)
- expect(off[0].b_width).toBe(110)
- expect(toggleList(off, makeField(), MODULE_ID)[0].b_visible).toBe(1)
- })
-
- it('行不存在:按默认生成且强制显示(含大文本)', () => {
- const textarea = makeField({ b_type: 'textarea' })
- const next = toggleList([], textarea, MODULE_ID)
- expect(next).toHaveLength(1)
- expect(next[0].b_visible).toBe(1) // 显式意愿覆盖大文本默认不参与
- expect(next[0].b_xh).toBe(10)
- })
-})
-
-describe('查询参与切换', () => {
- it('行存在:切换 b_canuse', () => {
- const rows = [{ b_id: 'q1', b_field_id: '10', b_canuse: 0, b_operator: 'eq' }]
- const on = toggleQuery(rows, makeField(), MODULE_ID)
- expect(on[0].b_canuse).toBe(1)
- expect(on[0].b_operator).toBe('eq') // 既有查询配置保留
- expect(toggleQuery(on, makeField(), MODULE_ID)[0].b_canuse).toBe(0)
- })
-
- it('行不存在:按默认生成(money → number/eq)且强制参与', () => {
- const next = toggleQuery([], makeField(), MODULE_ID)
- expect(next).toHaveLength(1)
- expect(next[0].b_canuse).toBe(1)
- expect(next[0].b_component).toBe('number')
- expect(next[0].b_operator).toBe('eq')
- expect(next[0].b_field_module_id).toBe('m1')
- })
-})
-
-describe('切换的不变性', () => {
- it('返回新数组,不修改原数组', () => {
- const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1 }]
- const next = toggleForm(rows, makeField(), MODULE_ID)
- expect(next).not.toBe(rows)
- expect(rows[0].b_visible).toBe(1) // 原行未被修改
- })
-})
diff --git a/code/fms/fms-vue/tests/unit/module-dirty-sections.spec.js b/code/fms/fms-vue/tests/unit/module-dirty-sections.spec.js
deleted file mode 100644
index 00493dc3..00000000
--- a/code/fms/fms-vue/tests/unit/module-dirty-sections.spec.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * 分 section 脏检查测试:守护 hasModuleChanges 的判定语义。
- * 背景见 docs/module-management-tab-status-plan.md 阶段一:
- * - 加载时自动补全的默认列表行只写 drafts 不写 originals(useModuleConfig.loadModuleConfig),
- * 此时视为「脏」是既有行为,分 section 对比必须原样保留,不得“修复”;
- * - hasModuleChanges 从整树 JSON.stringify 对比改为 dirtySections 派生,
- * 本 spec 以判定结果等价为验收基准。
- */
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { ref } from 'vue'
-import { useModuleConfig } from '@/views/module/module-management/composables/useModuleConfig'
-
-vi.mock('@/services/api', () => ({
- loadDataApi: vi.fn(),
- describeApi: vi.fn(),
-}))
-vi.mock('@/components/ui', () => ({
- Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
-}))
-
-beforeEach(() => {
- vi.clearAllMocks()
-})
-
-/** 完整的 8 段草稿结构(与 loadModuleConfig 构造的 original 同形) */
-function baseConfig(over = {}) {
- const config = {
- module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data' },
- fields: [{ b_id: '10', b_field: 'amount', b_name: '金额', b_canuse: 1 }],
- groups: [],
- listConfig: [],
- editConfig: [],
- queryConfig: [],
- autoCode: [],
- powers: [],
- }
- return Object.assign(config, over)
-}
-
-function setup({ drafts, originals }) {
- const draftsRef = ref(drafts)
- const originalsRef = ref(originals)
- const deps = {
- moduleSelectedId: ref('1'),
- moduleTreeData: ref([]),
- findModuleNode: () => null,
- selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }),
- busy: ref(false),
- configLoading: ref(false),
- configSyncing: ref(false),
- activeSection: ref('basic'),
- loadedModuleId: ref('1'),
- originals: originalsRef,
- drafts: draftsRef,
- skipModuleLoad: { value: false },
- showConfirm: vi.fn(),
- switchConfirm: ref(null),
- i18nPanelRef: ref(null),
- }
- return { ...useModuleConfig(deps), originalsRef, draftsRef }
-}
-
-describe('useModuleConfig 脏检查', () => {
- it('未修改时 hasModuleChanges 为 false', () => {
- const originals = baseConfig()
- const { hasModuleChanges } = setup({ drafts: JSON.parse(JSON.stringify(originals)), originals })
- expect(hasModuleChanges.value).toBe(false)
- })
-
- it('无草稿时 hasModuleChanges 为 false', () => {
- const { hasModuleChanges } = setup({ drafts: null, originals: null })
- expect(hasModuleChanges.value).toBe(false)
- })
-
- it('任一 section 修改即 hasModuleChanges 为 true', () => {
- const originals = baseConfig()
- for (const key of ['module', 'fields', 'groups', 'listConfig', 'editConfig', 'queryConfig', 'autoCode', 'powers']) {
- const drafts = JSON.parse(JSON.stringify(originals))
- if (key === 'module') drafts.module.b_name = '改名'
- else drafts[key] = [...drafts[key], { b_id: 'new' }]
- const { hasModuleChanges } = setup({ drafts, originals })
- expect(hasModuleChanges.value, `修改 ${key} 应判定为脏`).toBe(true)
- }
- })
-
- it('加载时补全的默认列表行(只写 drafts)视为脏——既有行为', () => {
- const originals = baseConfig()
- const drafts = JSON.parse(JSON.stringify(originals))
- drafts.listConfig = [{ b_id: 'tmp1', b_field_id: '10', b_visible: 1, b_xh: 10 }]
- const { hasModuleChanges } = setup({ drafts, originals })
- expect(hasModuleChanges.value).toBe(true)
- })
-
- it('查询模块缺失的 section(undefined)两侧一致时不脏,单侧出现即脏', () => {
- const originals = {
- module: { b_id: '2', b_code: 'q', b_module_type: 'query' },
- fields: [],
- groups: [],
- listConfig: [],
- editConfig: undefined,
- queryConfig: [],
- autoCode: undefined,
- powers: [],
- }
- const same = setup({ drafts: JSON.parse(JSON.stringify(originals)), originals })
- expect(same.hasModuleChanges.value).toBe(false)
-
- const drafts = JSON.parse(JSON.stringify(originals))
- drafts.editConfig = []
- const diff = setup({ drafts, originals })
- expect(diff.hasModuleChanges.value).toBe(true)
- })
-
- it('dirtySections 精确指出被修改的 section', () => {
- const originals = baseConfig()
- const drafts = JSON.parse(JSON.stringify(originals))
- drafts.module.b_name = '改名'
- drafts.queryConfig = [{ b_id: 'q1' }]
- const { dirtySections } = setup({ drafts, originals })
- expect(dirtySections.value.has('module')).toBe(true)
- expect(dirtySections.value.has('queryConfig')).toBe(true)
- expect(dirtySections.value.has('fields')).toBe(false)
- expect(dirtySections.value.size).toBe(2)
- })
-
- it('保存后整体替换 originals/drafts 为同一份深拷贝,脏状态清空', () => {
- const originals = baseConfig()
- const drafts = JSON.parse(JSON.stringify(originals))
- drafts.module.b_name = '改名'
- const api = setup({ drafts, originals })
- expect(api.hasModuleChanges.value).toBe(true)
- // 模拟 useModuleSave 保存后的整体 cloneData 换引用
- const saved = JSON.parse(JSON.stringify(drafts))
- api.originalsRef.value = saved
- api.draftsRef.value = JSON.parse(JSON.stringify(saved))
- expect(api.hasModuleChanges.value).toBe(false)
- })
-})
diff --git a/code/fms/fms-vue/tests/unit/module-i18n-reconcile.spec.js b/code/fms/fms-vue/tests/unit/module-i18n-reconcile.spec.js
new file mode 100644
index 00000000..54b0f8d7
--- /dev/null
+++ b/code/fms/fms-vue/tests/unit/module-i18n-reconcile.spec.js
@@ -0,0 +1,177 @@
+/**
+ * i18n 统一维护纯函数测试:
+ * 覆盖 index.vue 上移后的受管键收集、扁平行→透视、reconcile(补齐/跟名/失效清理)与保存 diff,
+ * 守护「ModuleI18nPanel 受控化」后数据编排逻辑不回退。
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ buildI18nChangeRequest,
+ buildI18nPivot,
+ collectManagedI18nItems,
+ reconcileI18nRows,
+} from '@/views/module/module-management/utils'
+
+const languages = [
+ { code: 'zh-CN', name: '简体中文', default: true },
+ { code: 'en-US', name: 'English', default: false },
+]
+const defaultLocale = 'zh-CN'
+
+function demoModule(over = {}) {
+ return {
+ b_id: '9',
+ b_code: 'demo',
+ b_module_type: 'data',
+ b_name: '演示模块',
+ b_i18n: null,
+ ...over,
+ }
+}
+
+describe('collectManagedI18nItems 受管键收集', () => {
+ it('收集模块名与字段名的受管键', () => {
+ const items = collectManagedI18nItems({
+ module: demoModule(),
+ fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
+ groups: [],
+ })
+ expect(items.map((i) => i.key)).toEqual(['module.demo', 'field.demo.name'])
+ expect(items[0].id).toBe('module:module.demo')
+ expect(items[1].id).toBe('field:field.demo.name')
+ expect(items[0].defaultValue).toBe('演示模块')
+ expect(items[1].defaultValue).toBe('姓名')
+ })
+
+ it('模块编码为空时不产出受管键(新建未填编码)', () => {
+ const items = collectManagedI18nItems({
+ module: demoModule({ b_code: '' }),
+ fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
+ groups: [],
+ })
+ expect(items).toEqual([])
+ })
+})
+
+describe('buildI18nPivot / reconcileI18nRows', () => {
+ it('扁平行转透视后 reconcile 补齐受管行并跟随默认语言', () => {
+ const flat = [{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
+ const pivot = buildI18nPivot(flat)
+ const items = collectManagedI18nItems({
+ module: demoModule(),
+ fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
+ groups: [],
+ })
+ const rows = reconcileI18nRows(pivot, items, { defaultLocale, languages })
+
+ const byKey = new Map(rows.map((r) => [r.b_key, r]))
+ expect(byKey.size).toBe(2)
+ // 手工翻译保留
+ expect(byKey.get('field.demo.name')['en-US']).toBe('Name')
+ // 受管默认语言跟随当前名称
+ expect(byKey.get('field.demo.name')['zh-CN']).toBe('姓名')
+ expect(byKey.get('module.demo')['zh-CN']).toBe('演示模块')
+ expect(byKey.get('field.demo.name')._managedId).toBe('field:field.demo.name')
+ })
+
+ it('名称变化后 reconcile 自动跟名,人工翻译不被覆盖', () => {
+ const fields = [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }]
+ const flat = [{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
+ let rows = reconcileI18nRows(
+ buildI18nPivot(flat),
+ collectManagedI18nItems({ module: demoModule(), fields, groups: [] }),
+ { defaultLocale, languages },
+ )
+ // 改名后再次 reconcile
+ rows = reconcileI18nRows(
+ rows,
+ collectManagedI18nItems({
+ module: demoModule({ b_name: '演示模块V2' }),
+ fields: [{ ...fields[0], b_name: '姓名V2' }],
+ groups: [],
+ }),
+ { defaultLocale, languages },
+ )
+ const byKey = new Map(rows.map((r) => [r.b_key, r]))
+ expect(byKey.get('module.demo')['zh-CN']).toBe('演示模块V2')
+ expect(byKey.get('field.demo.name')['zh-CN']).toBe('姓名V2')
+ expect(byKey.get('field.demo.name')['en-US']).toBe('Name')
+ })
+
+ it('字段删除后:无翻译的自动补齐行被清理,带翻译的保留为普通行', () => {
+ const field = { b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }
+ const items = collectManagedI18nItems({ module: demoModule(), fields: [field], groups: [] })
+ const rows = reconcileI18nRows(buildI18nPivot([]), items, { defaultLocale, languages })
+ expect(rows.some((r) => r._managedId === 'field:field.demo.name')).toBe(true)
+
+ // 字段消失:受管行无持久翻译,应被清理
+ const next = reconcileI18nRows(rows, items.slice(0, 1), { defaultLocale, languages })
+ expect(next.some((r) => r.b_key === 'field.demo.name')).toBe(false)
+ })
+})
+
+describe('buildI18nChangeRequest 保存 diff', () => {
+ const originalFlat = [
+ { b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' },
+ ]
+
+ function draftRows() {
+ const items = collectManagedI18nItems({
+ module: demoModule(),
+ fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
+ groups: [],
+ })
+ return reconcileI18nRows(buildI18nPivot(originalFlat), items, { defaultLocale, languages })
+ }
+
+ it('首存新增受管默认语言 cell,已存在值不重复插入', () => {
+ const req = buildI18nChangeRequest({
+ draftRows: draftRows(),
+ originalFlat,
+ moduleId: '9',
+ languages,
+ })
+ expect(req).not.toBeNull()
+ const keys = (req.inserts || []).map((r) => `${r.b_key}|${r.b_locale}`).sort()
+ expect(keys).toEqual(['field.demo.name|zh-CN', 'module.demo|zh-CN'])
+ // 新增行 b_id 为负数临时 id(保存编排统一换真实主键)
+ expect((req.inserts || []).every((r) => /^-\d+$/.test(String(r.b_id)))).toBe(true)
+ // en-US 有主键且未变化 → 不产生 update
+ expect(req.updates || []).toHaveLength(0)
+ })
+
+ it('修改语言值只产生对应 cell 的 update', () => {
+ const rows = draftRows()
+ rows.find((r) => r.b_key === 'field.demo.name')['en-US'] = 'NewName'
+ const req = buildI18nChangeRequest({
+ draftRows: rows,
+ originalFlat,
+ moduleId: '9',
+ languages,
+ })
+ expect(req.updates.some((r) => r.b_id === '100' && r.b_value === 'NewName')).toBe(true)
+ })
+
+ it('与基线完全一致时不产生请求', () => {
+ const originalAll = [
+ ...originalFlat,
+ { b_id: '101', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '演示模块' },
+ { b_id: '102', b_key: 'field.demo.name', b_locale: 'zh-CN', b_value: '姓名' },
+ ]
+ const items = collectManagedI18nItems({
+ module: demoModule(),
+ fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
+ groups: [],
+ })
+ const rows = reconcileI18nRows(buildI18nPivot(originalAll), items, {
+ defaultLocale,
+ languages,
+ })
+ const req = buildI18nChangeRequest({
+ draftRows: rows,
+ originalFlat: originalAll,
+ moduleId: '9',
+ languages,
+ })
+ expect(req).toBeNull()
+ })
+})
diff --git a/code/fms/fms-vue/tests/unit/module-list-utils.spec.js b/code/fms/fms-vue/tests/unit/module-list-utils.spec.js
index ea479839..b697e853 100644
--- a/code/fms/fms-vue/tests/unit/module-list-utils.spec.js
+++ b/code/fms/fms-vue/tests/unit/module-list-utils.spec.js
@@ -5,7 +5,7 @@ import {
formatDateValue,
parseOptions,
} from '@/components/fms-module-list/moduleListUtils'
-import { setMessages, translate } from '@/utils/translate'
+import { setMessages, translate } from '@/stores/i18n'
function item(over = {}) {
return {
diff --git a/code/fms/fms-vue/tests/unit/module-save-orchestration.spec.js b/code/fms/fms-vue/tests/unit/module-save-orchestration.spec.js
deleted file mode 100644
index efdb9eb4..00000000
--- a/code/fms/fms-vue/tests/unit/module-save-orchestration.spec.js
+++ /dev/null
@@ -1,213 +0,0 @@
-/**
- * useModuleSave 保存编排测试:守护跨表保存的关键不变量。
- * 这些顺序约束此前只存在于注释中,被改动时不会有任何报警:
- * 1. 校验失败时不产生任何保存请求,且定位到对应分节;
- * 2. s_module 有修改走 updates、新建走 inserts;
- * 3. 临时 id 先分配真实主键,再跨表重映射 b_field_id(req 行与草稿行同引用);
- * 4. s_autocode 的 updates 剥离服务端维护的 b_currentperiod / b_currentvalue;
- * 5. i18n inserts 分配真实主键并回写面板,避免二次保存重复插入。
- */
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { ref } from 'vue'
-import { useModuleSave } from '@/views/module/module-management/composables/useModuleSave'
-import { saveObjectApi, nextIdApi } from '@/services/api'
-import { Message } from '@/components/ui'
-
-vi.mock('@/services/api', () => ({
- saveObjectApi: vi.fn(),
- nextIdApi: vi.fn(),
-}))
-vi.mock('@/components/ui', () => ({
- Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
-}))
-
-let idSeq
-beforeEach(() => {
- vi.clearAllMocks()
- idSeq = 9000
- nextIdApi.mockImplementation(async (count) => {
- const ids = []
- for (let i = 0; i < count; i++) ids.push(String(++idSeq))
- return { data: ids }
- })
-})
-
-function baseDrafts(over = {}) {
- const drafts = {
- // b_i18n 与 buildModuleI18nKey('data', 'demo') 派生值一致:saveModule 每次保存都会物化该键
- module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo' },
- fields: [],
- groups: [],
- listConfig: [],
- editConfig: [],
- queryConfig: [],
- autoCode: [],
- powers: [],
- }
- return Object.assign(drafts, over)
-}
-
-function setup({ drafts, originals, basicPanel = null, autoCodePanel = null, i18nPanel = null }) {
- const unsavedModuleIds = new Set()
- const deps = {
- selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }),
- drafts: ref(drafts),
- originals: ref(originals),
- loadedModuleId: ref('1'),
- moduleSelectedId: ref('1'),
- unsavedModuleIds,
- configSaving: ref(false),
- busy: ref(false),
- activeSection: ref('basic'),
- moduleTreeData: ref([]),
- findModuleNode: () => null,
- basicPanelRef: ref(basicPanel),
- autoCodePanelRef: ref(autoCodePanel),
- i18nPanelRef: ref(i18nPanel),
- getFinalI18nItems: () => [{ key: 'module.demo', managed: true, id: 'module:demo' }],
- }
- return { saveModule: useModuleSave(deps).saveModule, unsavedModuleIds, activeSection: deps.activeSection }
-}
-
-function reqOf(table) {
- return saveObjectApi.mock.calls.at(-1)[0].find((req) => req.table === table)
-}
-
-describe('useModuleSave 保存编排', () => {
- it('无变更时不发请求并提示', async () => {
- const drafts = baseDrafts()
- const { saveModule } = setup({ drafts, originals: JSON.parse(JSON.stringify(drafts)) })
-
- const ok = await saveModule()
-
- expect(ok).toBe(true)
- expect(saveObjectApi).not.toHaveBeenCalled()
- expect(Message.info).toHaveBeenCalledWith('没有需要保存的变更')
- })
-
- it('基础面板校验失败:不发请求,定位到 basic 分节', async () => {
- const drafts = baseDrafts()
- const { saveModule, activeSection } = setup({
- drafts,
- originals: JSON.parse(JSON.stringify(drafts)),
- basicPanel: { validate: async () => ['编码不能为空'] },
- })
-
- const ok = await saveModule()
-
- expect(ok).toBe(false)
- expect(saveObjectApi).not.toHaveBeenCalled()
- expect(activeSection.value).toBe('basic')
- expect(Message.warning).toHaveBeenCalled()
- })
-
- it('s_module 修改走 updates,新建走 inserts', async () => {
- const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '改名后' } })
- const originals = baseDrafts()
- const { saveModule } = setup({ drafts, originals })
- await saveModule()
- expect(reqOf('s_module').updates[0].b_name).toBe('改名后')
-
- const { saveModule: saveNew, unsavedModuleIds } = setup({
- drafts: baseDrafts(),
- originals: baseDrafts(),
- })
- unsavedModuleIds.add('1')
- await saveNew()
- expect(reqOf('s_module').inserts[0].b_code).toBe('demo')
- })
-
- it('临时 id 分配真实主键并跨表重映射 b_field_id', async () => {
- // 新字段(临时 id -1)+ 引用它的列表列:编辑行同引用,重映射必须同时落到请求与草稿
- const listRow = { b_id: '100', b_module_id: '1', b_field_id: '-1', b_visible: 1, b_xh: 10 }
- const drafts = baseDrafts({
- fields: [{ b_id: '-1', b_module_id: '1', b_field: 'name', b_name: '姓名' }],
- listConfig: [listRow],
- })
- const originals = baseDrafts({
- listConfig: [{ b_id: '100', b_module_id: '1', b_field_id: '9999', b_visible: 1, b_xh: 10 }],
- })
- const { saveModule } = setup({ drafts, originals })
-
- const ok = await saveModule()
-
- expect(ok).toBe(true)
- expect(nextIdApi).toHaveBeenCalled()
- const fieldReq = reqOf('s_field')
- expect(fieldReq.inserts[0].b_id).toBe('9001')
- // 列表列由旧字段改挂新字段:update 行内的临时外键必须已换成真实 id
- const viewReq = reqOf('s_field_view')
- expect(viewReq.updates[0].b_field_id).toBe('9001')
- // 草稿行与请求行同引用:重映射后草稿也持有真实 id
- expect(drafts.listConfig[0].b_field_id).toBe('9001')
- })
-
- it('s_autocode updates 剥离服务端维护的期间/当前值', async () => {
- const rule = {
- b_id: '200',
- b_module_id: '1',
- b_field_id: '100',
- b_canuse: 1,
- b_currentperiod: 2026,
- b_currentvalue: 5,
- }
- const drafts = baseDrafts({
- autoCode: [rule],
- module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo', b_savetable: 't_demo' },
- })
- const { saveModule } = setup({
- drafts,
- // 原件行与草稿行存在差异(当前值不同)才会产生 update,从而走到剥离逻辑
- originals: baseDrafts({ autoCode: [{ ...rule, b_currentvalue: 4 }] }),
- })
-
- await saveModule()
-
- const update = reqOf('s_autocode').updates[0]
- expect(update).not.toHaveProperty('b_currentperiod')
- expect(update).not.toHaveProperty('b_currentvalue')
- expect(update.b_field_id).toBe('100')
- })
-
- it('i18n inserts 分配真实主键并回写面板,事务内提交', async () => {
- const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '新名称' } })
- const originals = baseDrafts()
- const i18nPanel = {
- reconcileManagedRows: vi.fn(),
- buildSaveRequest: vi.fn(() => ({
- table: 's_i18n',
- key_field: 'b_id',
- inserts: [{ b_id: '-9', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '新名称' }],
- })),
- applyInsertedIds: vi.fn(),
- markSaved: vi.fn(),
- }
- const { saveModule } = setup({ drafts, originals, i18nPanel })
-
- const ok = await saveModule()
-
- expect(ok).toBe(true)
- // 受管键 reconcile 在构建请求前执行
- expect(i18nPanel.reconcileManagedRows).toHaveBeenCalledWith([
- { key: 'module.demo', managed: true, id: 'module:demo' },
- ])
- // 临时主键分配后回写面板,避免二次保存重复插入
- expect(i18nPanel.applyInsertedIds).toHaveBeenCalledWith([
- expect.objectContaining({ b_id: '9001', b_key: 'module.demo' }),
- ])
- expect(reqOf('s_i18n').inserts[0].b_id).toBe('9001')
- expect(i18nPanel.markSaved).toHaveBeenCalled()
- })
-
- it('跨面板校验前置:启用自动编码但未配置保存表则拦截', async () => {
- const rule = { b_id: '200', b_module_id: '1', b_field_id: '100', b_canuse: 1 }
- const drafts = baseDrafts({ autoCode: [rule] })
- const { saveModule } = setup({ drafts, originals: baseDrafts() })
-
- const ok = await saveModule()
-
- expect(ok).toBe(false)
- expect(saveObjectApi).not.toHaveBeenCalled()
- expect(Message.warning).toHaveBeenCalledWith('启用自动编码前请先配置保存表')
- })
-})
diff --git a/code/fms/开发规范.md b/code/fms/开发规范.md
index 265a906b..c1e719ce 100644
--- a/code/fms/开发规范.md
+++ b/code/fms/开发规范.md
@@ -6,27 +6,131 @@
2. **后端接口优先通用** — 优先使用或设计通用接口,尽量不写特殊接口(为单一业务场景定制的接口)。如需编写特殊接口,必须提前询问用户并获得确认。
+3. **先读现有代码再改** — 修改前先确认已有组件、store、接口和测试的职责边界,优先复用现有模式,不因局部需求引入新的架构分支。
+
+4. **修改范围最小化** — 只修改完成当前需求所必需的文件;发现无关的脏改动、临时文件或历史问题时,不擅自回滚或顺手重构。
+
## 前端代码风格
1. **少用语法糖** — 优先使用直白、易读的写法,避免为了炫技而使用语法糖,可读性优先。
2. **避免无意义的封装** — 没有必要的抽取就不抽取,不为一层简单调用再包一层方法。例如 `emit('xxx')` 这类直接调用即可,无需再封装成自定义方法。
+3. **按目录归属组织公共代码** — 只服务于某个业务目录的函数,放在该目录下的 `utils.js`;只服务于布局、页面或组件的函数,放在对应目录,不放入全局 `src/utils`。
+
+4. **公共函数抽取条件** — 满足以下任一条件才抽取:
+ - 至少有两个独立调用点;
+ - 逻辑本身较复杂,内联会明显影响可读性;
+ - 需要保证多个调用点使用同一规则(例如缓存名称、字段 key 生成规则)。
+
+ 只有一个调用点且逻辑简单的代码直接写在使用处;没有调用点的代码必须删除。
+
+5. **公共工具文件数量控制** — 一个业务目录原则上只保留一个公共 `utils.js`。只有职责明显独立、体量较大或确实需要单独测试的特殊工具,才允许单独成文件。禁止为了一个函数创建一个文件。
+
+6. **避免重复实现同一规则** — 即使函数很短,只要多个位置必须保持完全一致,就集中维护一个实现,不在各处复制字符串拼接、编码或判断逻辑。
+
+7. **命名反映职责** — 目录已经表达业务上下文时,文件名不再重复目录名;优先使用 `utils.js`、`constants.js` 等稳定名称,避免创建 `xxxHelper.js`、`xxxCommon.js` 等含义模糊的文件。
+
+### Store 设计
+
+1. **统一使用 Setup Store** — 新增或改造 Pinia store 统一使用 `defineStore('name', () => {}, options)`,状态用 `ref`,派生状态用 `computed`,操作用普通函数。
+
+2. **一个 store 一个领域** — store 只管理一个明确领域。`app.js` 只保存应用壳层状态(主题、侧栏、页签、路由刷新等);权限、会话、i18n、业务配置分别由各自 store 管理。
+
+3. **避免重复状态源** — 同一状态只能有一个真实来源,禁止同时在两个 store 或 store 与工具模块中各维护一份。跨 store 使用时直接调用所属领域的 store,不通过兼容门面转发。
+
+4. **持久化显式声明** — 只有确实需要跨刷新保留的字段才配置 `persist`,并明确 `pick` 范围;临时请求状态、页签状态和加载状态不持久化。
+
+5. **异步职责收敛** — 请求、加载状态、错误状态和刷新方法放在对应领域 store 中;页面只负责触发加载并消费结果,不在多个页面复制同一套请求缓存逻辑。
+
+6. **不为旧接口保留高兼容层** — 当调用方已可控且用户未要求兼容时,直接迁移调用方并删除旧文件、旧导出和重复状态,不保留只转发一层的兼容文件。
+
### 查询列表页面设计
(待补充)
### 详细表单页面设计
-保存流程方法的命名与顺序参考旧系统风格(仅约定骨架,实现自行设计):
+页面加载流程也统一使用以下命名,方便从入口快速定位新建和编辑逻辑:
+
+1. `load()` — 页面加载入口,判断当前是新建还是编辑模式
+2. `loadNew()` — 新建模式初始化:加载默认值、生成临时 ID、准备初始配置
+3. `loadEdit()` — 编辑模式加载:按主键加载已有主表和子表数据
+
+如果页面不区分新建和编辑,直接使用语义明确的 `loadXxx()`,例如 `loadModuleConfig()`、`loadMenuTree()`。不使用 `loadMod()` 这类含义不明确的缩写。
+
+`initNew()` 只在“清空当前草稿后重新初始化”确实是独立流程时保留;否则直接合并到 `loadNew()`,避免同一套新建逻辑分散在多个方法中。
+
+### 表单数据与变更检测
+
+1. **保留 origin 基线** — 编辑页面加载完成后保留一份不可直接编辑的 `origin` 数据,页面修改只作用于 `draft` 数据。不要为每个字段单独维护一份旧值。
+
+2. **保存使用 diff** — 保存前将 `origin` 与 `draft` 做差异对比,只提交新增、修改和删除的数据;没有变化时不调用保存接口。
+
+3. **origin 不提前覆盖** — 保存请求成功前不得把 `draft` 覆盖到 `origin`。保存成功后优先重新从服务端加载,拿到后端补充的主键、默认值和规范化数据,再更新 `origin`。
+
+4. **新建数据单独处理** — 新建模式没有真实的 `origin`,可以使用 `null` 或空对象表示基线;临时 ID 只存在于 `draft`,提交成功后使用后端返回的真实 ID 更新或重新加载。
+
+5. **多表单统一基线** — 主表、子表和配置表分别保留原始数据,但由同一个保存入口统一计算 diff 和提交,避免每个面板自行维护一套保存状态。
+
+标准表单页面统一采用以下保存流程和方法名称,便于快速定位代码。方法可以包含页面自身的业务逻辑,但不要随意改名或调换主流程顺序:
1. `handleSave()` — 保存入口
-2. `beforeSave()` — 保存前校验与数据准备
-3. `buildSaveData()` — 构建保存请求数据
-4. `afterSave()` — 保存成功后的处理
+2. `beforeSave()` — 保存前校验、字段同步和数据准备;返回 `false` 时终止保存
+3. `buildSaveData()` — 基于当前草稿构建保存数据;可以返回单个请求、请求数组或 `null`
+4. 调用通用保存接口
+5. `afterSave()` — 保存成功后的重新加载、状态更新、草稿清理和页面刷新
+
+推荐主流程保持如下结构:
+
+```js
+async function handleSave() {
+ saving.value = true
+ try {
+ if (!(await beforeSave())) return false
+ const saveData = buildSaveData()
+ if (!saveData) return true
+ await saveObjectApi(saveData)
+ await afterSave()
+ return true
+ } finally {
+ saving.value = false
+ }
+}
+```
+
+说明:
+
+- `handleSave()` 是页面或表单组件的统一保存入口,不按业务对象改成多个不同名称。
+- `beforeSave()` 只做保存前工作,不直接调用保存接口。
+- `buildSaveData()` 只负责组装请求,不修改页面流程状态。
+- `afterSave()` 必须在保存请求成功后执行;如果包含异步刷新,应使用 `await`。
+- 页面上的内联切换、批量操作等独立动作,可以使用 `saveToggle()`、`saveBatch()` 等专用名称,不套用主表单保存流程。
+
+### 布局与页签缓存
+
+1. **缓存规则归布局目录** — keep-alive、页签名称、路由包装组件等规则属于布局层,相关函数放在 `src/layouts` 下,不放入全局工具目录。
+
+2. **区分组件 key 与组件 name** — `:key` 用于区分组件实例;`keep-alive :include` 按组件 name 匹配。需要按路由参数独立缓存时,必须为每个 `fullPath` 生成稳定且唯一的 name,并在记录页签和渲染包装组件时使用同一规则。
+
+3. **不为了抽象而抽象** — 如果缓存逻辑只在一个组件中使用,直接放在该组件;只有页签和布局都需要同一缓存名称规则时,才抽成布局目录公共函数。
## 多语言(i18n)约定
1. **module 目录下的页面不做多语言翻译** — 模块管理、菜单管理、多语言类型等内部管理页面仅面向 IT 人员,界面文案固定使用中文,不接入 s_i18n 翻译体系、不提供多语言编辑入口。
2. 面向客户使用的业务页面才需要做多语言翻译(界面文案、s_i18n 键管理、多语言编辑入口)。
+3. **区分界面语言与业务语言** — UI 当前语言、UI 翻译目录、`s_i18n_type` 业务语言清单统一由 `stores/i18n.js` 管理;页面不得自行维护第二份语言状态。
+
+4. **多语言 key 规则就近维护** — 只服务于模块管理的 key 生成和同步逻辑放在 `module-management/utils.js`;不得放入全局 `src/utils`。单页面使用一次的校验、透视合并或字段转换逻辑直接写在页面中。
+
+5. **默认语言必须按标记判断** — 使用 `b_default` / `default` 字段确定默认语言,不得依赖语言数组第一项;语言列表为空时不得静默伪造默认语言掩盖接口或配置错误。
+
+### 变更验收
+
+1. **先做引用清理** — 删除或移动文件后,用全局搜索确认没有旧路径、旧导出和旧方法名残留。
+
+2. **按影响范围测试** — 至少运行受影响模块的单元测试;涉及公共 store、布局缓存或跨页面工具时,补充相关页面和状态测试。
+
+3. **区分新问题与历史问题** — 构建、lint 或全量测试若被改动前已存在的缺失文件、旧测试或环境问题阻断,应在结果中明确记录,不为了通过检查修改无关代码。
+