441 lines
16 KiB
JavaScript
441 lines
16 KiB
JavaScript
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]
|
||
}
|