Files
workspace/code/fms/fms-vue/src/views/module/module-management/utils.js
T
2026-09-11 17:31:34 +08:00

441 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Database, Folder, FolderPlus, Search } from '@lucide/vue'
import { createTempId } from '@/utils/common'
import { diffRows } from '@/utils/dataChanges'
/** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */
export const MODULE_TYPE_ICON_MAP = {
module: Folder,
data: Database,
virtual: Search,
}
export const MODULE_TYPE_LABELS = {
module: '模块',
data: '数据模块',
virtual: '虚拟模块',
}
/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */
export const MODULE_ADD_OPTIONS = [
{ key: 'module', label: '新增模块', icon: FolderPlus },
{ key: 'data', label: '新增数据模块', icon: Database },
{ key: 'virtual', label: '新增虚拟模块', icon: Search },
]
/** s_module 主表列(模块树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
const MODULE_ROW_FIELDS = [
'b_id',
'b_parent_id',
'b_depth',
'b_path',
'b_name',
'b_i18n',
'b_module_type',
'b_view_table',
'b_save_table',
'b_key_field',
'b_order_sql',
'b_query_sql',
'b_config_json',
'b_canuse',
'b_xh',
'b_bz',
]
/** 界面配置 JSON 的解析/序列化/默认结构(配置链路与运行时渲染链路共用,见 @/utils/schemaJson) */
export {
SCHEMA_TYPES,
createDefaultSchema,
parseSchema,
stableStringify,
schemaToJsonText,
} from '@/utils/schemaJson'
/**
* 模块类型装饰:module 类型默认可展开(显示折叠箭头,即使暂无子模块),并附加类型图标。
* 就地修改并返回原数组。
*/
export function decorateModuleTree(nodes) {
for (const node of nodes) {
if (node.b_module_type === 'module') node.isLeaf = false
const icon = MODULE_TYPE_ICON_MAP[node.b_module_type]
if (icon) node.icon = icon
if (node.children) decorateModuleTree(node.children)
}
return nodes
}
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),供未落库模块切回时重建主表草稿 */
export function moduleRowOf(node) {
const row = {}
for (const field of MODULE_ROW_FIELDS) row[field] = node[field] ?? null
return row
}
/**
* 在树中查找节点所在的父数组及索引。
* @returns {{ list: Array, index: number }|null}
*/
export function findNodePosition(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
if (nodes[i].children) {
const found = findNodePosition(nodes[i].children, id)
if (found) return found
}
}
return null
}
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
function addRowUpdate(updatedRows, row) {
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
if (existing) Object.assign(existing, row)
else updatedRows.push({ ...row })
}
/**
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
* @param {Array} updatedRows 变化行收集器
*/
export function renumberSiblings(list, updatedRows) {
list.forEach((sibling, index) => {
const nextXh = (index + 1) * 10
if (sibling.b_xh !== nextXh) {
sibling.b_xh = nextXh
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
}
})
}
/**
* 递归重建模块树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /sea/container/(节点编码前后均带 /)。
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
*/
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
const path = `${parentPath}${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
const normalizedModuleCode = String(moduleCode || '').trim()
const normalizedItemCode = String(itemCode || '').trim()
return normalizedModuleCode && normalizedItemCode
? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}`
: null
}
/** 模块名称资源键:module.{模块编码} */
export function buildModuleI18nKey(moduleCode) {
const normalizedModuleCode = String(moduleCode || '').trim()
return normalizedModuleCode ? `module.${normalizedModuleCode}` : null
}
/* ================= 权限点(s_power,见设计文档第 14 节) ================= */
/** 标准模块操作:动作属于这些值时归入模块使用权限,其余一律按业务动作 action */
export const MODULE_ACTIONS = ['read', 'create', 'update', 'delete', 'export']
/** 模块使用权限的固定动作(s_power.b_action):整模块一个使用权限点,不再按标准操作拆分 */
export const MODULE_ACCESS_ACTION = 'access'
/** 权限点性质由动作派生:模块使用(access / 标准操作)→ module,其它业务动作 → action */
export function typeOfAction(action) {
const value = String(action || '').trim()
if (value === MODULE_ACCESS_ACTION || MODULE_ACTIONS.includes(value)) return 'module'
return 'action'
}
/**
* 标准动作的共用动作动词资源键(按钮 + 权限共用,见设计文档第 11 节)。
* 「新增」与权限名称共用 action.insert(SQL 语义);未登记动作回退 action.{动作}。
*/
export const ACTION_I18N_KEYS = {
read: 'action.read',
create: 'action.insert',
update: 'action.update',
delete: 'action.delete',
export: 'action.export',
audit: 'action.audit',
unaudit: 'action.unaudit',
}
/** 动作 → 共用动作动词资源键:通用词不分模块,同一动词只保存一份翻译 */
export function buildActionI18nKey(action) {
const normalizedAction = String(action || '').trim()
if (!normalizedAction) return null
return ACTION_I18N_KEYS[normalizedAction] || `action.${normalizedAction}`
}
/** 权限点多语言键派生:模块使用权限复用模块名称键 module.{模块编码};业务动作复用共用动词键 action.{动词} */
export function buildPowerI18nKey(action, moduleCode) {
const normalizedAction = String(action || '').trim()
if (!normalizedAction) return null
return typeOfAction(normalizedAction) === 'module'
? buildModuleI18nKey(moduleCode)
: buildActionI18nKey(normalizedAction)
}
/** 字段行 b_i18n 同步为 field.{模块编码}.{字段名} */
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
}
/** 遍历界面配置节点树,访问所有分组节点 */
function walkGroupNodes(node, visit) {
if (!node || typeof node !== 'object') return
if (node.type === 'group') visit(node)
const children = node.children || node.columns
if (Array.isArray(children)) for (const child of children) walkGroupNodes(child, visit)
}
/** 收集界面配置中的分组标题多语言键(分组节点手填 i18n,空则用标题原文,不纳入受管) */
function collectSchemaGroupItems(schemas) {
const items = []
const seen = new Set()
const visit = (node) => {
const key = String(node.i18n || '').trim()
if (!key || seen.has(key)) return
seen.add(key)
items.push({ key, managed: true, defaultValue: node.title || '', id: `group:${key}` })
}
for (const schema of schemas) {
if (!schema) continue
const roots = schema.columns || schema.children
if (Array.isArray(roots)) for (const node of roots) walkGroupNodes(node, visit)
}
return items
}
/**
* 收集模块的受管多语言键项:模块名、字段名、界面分组标题、权限点引用的共用动作动词键。
* item.id 为稳定标识(module:/field:/group:/power: 前缀),供 reconcile 判失效。
* action.* 键跨模块公用:只负责登记缺失,不随单个模块删除清理。
*/
export function collectManagedI18nItems({ module, fields, schemas, powers }) {
if (!module) return []
const items = []
const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_id)
if (moduleKey) {
items.push({
key: moduleKey,
managed: true,
defaultValue: module.b_name || '',
id: `module:${moduleKey}`,
})
}
for (const field of fields || []) {
const key = field.b_i18n || buildModuleItemI18nKey('field', module.b_id, field.b_field)
if (key) {
items.push({
key,
managed: true,
defaultValue: field.b_name || '',
id: `field:${key}`,
})
}
}
const seenActionKeys = new Set(items.map((item) => item.key))
for (const power of powers || []) {
const key = String(power?.b_i18n || '').trim()
if (!key || !key.startsWith('action.') || seenActionKeys.has(key)) continue
seenActionKeys.add(key)
items.push({
key,
managed: true,
defaultValue: power.b_name || '',
id: `power:${key}`,
})
}
return [...items, ...collectSchemaGroupItems(schemas || [])]
}
function isEmptyI18nManaged(row, defaultLocale, languages) {
if (row._persistedLocales && row._persistedLocales.size) 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) || unclaimedByKey.get(String(item.key))
if (!row) {
row = {
_rowKey: createTempId(),
b_key: item.key,
_persistedLocales: new Set(),
_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
}
// 键已失效的受管行:自动创建且无内容的直接丢弃,有翻译或来自数据库的解除保护保留
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 === '_rowKey' || field === '_persistedLocales') continue
if ((hit[field] == null || hit[field] === '') && value != null) hit[field] = value
}
for (const locale of row._persistedLocales || []) hit._persistedLocales?.add(locale)
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
}
return mergedRows
}
/** s_i18n 扁平行 → 透视行(每 key 一行,每语言一列,_persistedLocales 记录已持久化的语言) */
export function buildI18nPivot(flat) {
const byKey = new Map()
for (const record of flat || []) {
const key = String(record.b_key)
if (!byKey.has(key)) {
byKey.set(key, {
_rowKey: createTempId(),
b_key: key,
_persistedLocales: new Set(),
})
}
const row = byKey.get(key)
row[record.b_locale] = record.b_value
row._persistedLocales.add(String(record.b_locale))
}
return [...byKey.values()]
}
/**
* 生成 s_i18n 变更请求:透视草稿与原始扁平行按 (b_key, b_locale) 复合键 diff。
* 只返回有变化的 cell;完全一致返回 null。
*/
export function buildI18nChangeRequest({ draftRows, originalFlat, languages }) {
const current = []
for (const row of draftRows || []) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of languages || []) {
const locale = String(lang.code)
const value = row[locale] == null ? '' : String(row[locale])
const persisted = row._persistedLocales?.has(locale)
// 已持久化 cell 保留行(值清空仍保留,交由 update 置空);未持久化 cell 仅在有值时登记
if (persisted || value !== '') {
current.push({ b_key: key, b_locale: locale, b_value: value })
}
}
}
const changes = diffRows(current, originalFlat || [], { keyFields: ['b_key', 'b_locale'] })
if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) return null
return {
table: 's_i18n',
key_field: 'b_key,b_locale',
inserts: changes.inserts.map(enrichI18nRow),
updates: changes.updates.map(enrichI18nRow),
deletes: changes.deletes,
}
}
/** 落库前补齐 s_i18n 的启用列(比较阶段不含它,避免「打开即脏」) */
function enrichI18nRow(row) {
return { b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value, b_canuse: 1 }
}
/** SQL 字符串字面量(转义单引号);用于拼接只读查询条件 */
function sqlStringLiteral(value) {
return `N'${String(value ?? '').replace(/'/g, "''")}'`
}
/**
* 模块多语言资源键匹配条件(s_i18n 无 b_module_id,按已知资源键精确匹配):
* 模块名键 + 字段资源键 + 界面配置分组键。使用精确 IN 而非 LIKE,
* 避免模块编码中 `_` 被当作通配符导致跨模块误匹配。
*/
export function buildModuleI18nCondition(moduleId, keys = []) {
const conditions = [`b_key = ${sqlStringLiteral(buildModuleI18nKey(moduleId))}`]
const list = [...new Set((keys || []).map((key) => String(key || '').trim()).filter(Boolean))]
if (list.length) conditions.push(`b_key IN (${list.map(sqlStringLiteral).join(', ')})`)
return conditions.join(' OR ')
}
/** 从界面配置中收集全部分组资源键(删除模块时按这些键清理 s_i18n) */
export function collectSchemaGroupKeys(schemas) {
const keys = new Set()
const visit = (node) => {
if (!node || typeof node !== 'object') return
const key = String(node.i18n || '').trim()
if (node.type === 'group' && key) keys.add(key)
const children = node.children || node.columns
if (Array.isArray(children)) for (const child of children) visit(child)
}
for (const schema of schemas) {
if (!schema) continue
const roots = schema.columns || schema.children
if (Array.isArray(roots)) for (const node of roots) visit(node)
}
return [...keys]
}