20260905172534

This commit is contained in:
oneao committed 2026-09-05 17:25:35 +08:00
1 parent 1b01a31252
commit 7b00eeb88c
711 files changed
+2849 -959

No files matched your search

@@ -17,7 +17,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
import { FolderTree, Save, RefreshCw } from '@lucide/vue'
import { Button, Splitter, SplitterPanel, Modal, Message } from '@/components/ui'
import { loadDataApi, saveObjectApi, nextIdApi } from '@/services/api'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
buildTree,
sortTree,
@@ -26,7 +26,7 @@ import {
insertTreeNode,
removeTreeNode,
} from '@/utils/tree'
import { cloneData, diffRow, diffRows, allocateTemporaryIds } from '@/utils/dataChanges'
import { cloneData, diffRow, diffRows } from '@/utils/dataChanges'
import { createTempId } from '@/utils/common'
import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
import I18nNameModal from '@/components/I18nNameModal.vue'
@@ -37,6 +37,11 @@ import MenuPowerLibraryPanel from './MenuPowerLibraryPanel.vue'
import { findNodePosition, markDirectoryNonLeaf, menuRowOf, renumberSiblings } from './utils'
import { useI18nStore } from '@/stores/i18n'
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
function sqlText(value) {
return `N'${String(value ?? '').replace(/'/g, "''")}'`
}
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
// 语言清单来自数据库(stores/i18n),不再写死 zh-CN/en-US。
@@ -44,15 +49,15 @@ const langStore = useI18nStore()
/** 默认语言 code(b_default=1;数据异常时回退第一个启用语言,均为数据库配置) */
const defaultLocale = computed(() => {
const def = langStore.defaultLanguage?.b_code
const def = langStore.defaultLanguage?.b_id
if (def) return def
return langStore.languages[0]?.b_code || ''
return langStore.languages[0]?.b_id || ''
})
/** 给 I18nNameModal 的语言列表({ code, name, default }) */
const I18N_LANGUAGES = computed(() =>
langStore.languages.map((l) => ({
code: l.b_code,
code: l.b_id,
name: l.b_name,
default: Number(l.b_default) === 1,
})),
@@ -60,7 +65,7 @@ const I18N_LANGUAGES = computed(() =>
/** 非默认语言(走 s_i18n 表,{ code, name }) */
const nonDefaultLanguages = computed(() =>
langStore.nonDefaultLanguages.map((l) => ({ code: l.b_code, name: l.b_name })),
langStore.nonDefaultLanguages.map((l) => ({ code: l.b_id, name: l.b_name })),
)
/** 默认语言名称(hint 文案用,动态取自语言配置) */
@@ -314,11 +319,11 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
/* ================= 新增菜单 ================= */
async function addMenu(parentId, menuTypeValue = 'page') {
const res = await nextIdApi()
const newId = String(res.data)
// 字符串业务键方案:持久化主键 = 菜单编码(基本信息面板录入)。
// 未落库节点先用本地临时负数 id 占位,仅用于树 key / 未保存判定,不写入数据库。
const newId = createTempId()
const newMenu = {
b_id: newId,
b_code: '',
b_parent_id: parentId ? String(parentId) : null,
b_name: '',
b_i18n: null,
@@ -369,12 +374,12 @@ async function deleteMenu(id) {
}
showConfirm({
title: '删除菜单',
content: `确定删除菜单「${menu.b_name || menu.b_code}」?`,
content: `确定删除菜单「${menu.b_name || menu.b_id}」?`,
okText: '删除',
okType: 'danger',
onOk: async () => {
try {
const menuPowerCode = menu.b_code ? `menu.${String(menu.b_code).trim()}` : ''
const menuPowerCode = menu.b_id ? `menu.${String(menu.b_id).trim()}` : ''
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
// 清理权限点:访问(type=menu 按 b_resource 关联)+ 操作/按钮(type=action 按 b_module_id 关联)
if (menuPowerCode) {
@@ -407,7 +412,7 @@ async function loadMenuConfig(menuId) {
const id = String(menuId)
configLoading.value = true
try {
const res = await loadDataApi('s_menu', `b_id = ${id}`)
const res = await loadDataApi('s_menu', `b_id = ${sqlText(id)}`)
if (String(menuSelectedId.value) !== id) return
const menu = res.data?.[0] || null
maindata_org.value = menu ? { ...menu } : null
@@ -416,7 +421,11 @@ async function loadMenuConfig(menuId) {
const i18nInitValues = { [defaultLocale.value]: menu?.b_name || '' }
const i18n_org_rows = []
if (menu) {
const i18nRes = await loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC')
const i18nRes = await loadDataApi(
's_i18n',
`b_module_id = ${sqlText(id)}`,
'b_key ASC, b_locale ASC',
)
for (const row of i18nRes.data || []) {
if (!row.b_locale || row.b_locale === defaultLocale.value) continue
i18nInitValues[row.b_locale] = row.b_value
@@ -438,13 +447,13 @@ async function loadMenuConfig(menuId) {
menu && menu.b_menu_type !== 'directory'
? await loadDataApi(
's_power',
`b_power_type = 'action' AND b_module_id = ${id}`,
`b_power_type = 'action' AND b_module_id = ${sqlText(id)}`,
'b_xh ASC, b_id ASC',
)
: { data: [] }
// 字符串业务键:s_power.b_id 即权限编码,行不再有独立 b_code
powers.value = (powerRes.data || []).map((row) => ({
b_id: row.b_id,
b_code: row.b_code,
b_name: row.b_name,
b_power_type: 'action',
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
@@ -528,7 +537,6 @@ function serializePowers(rows) {
return JSON.stringify(
(rows || []).map((r) => ({
b_id: r.b_id == null ? null : String(r.b_id),
b_code: String(r.b_code || ''),
b_name: String(r.b_name || ''),
b_canuse: Number(r.b_canuse) === 1 ? 1 : 0,
})),
@@ -558,7 +566,7 @@ function hasUnsavedChanges() {
function confirmIfUnsaved(action) {
if (loadedMenuId.value && hasUnsavedChanges()) {
switchConfirm.value = {
name: selectedMenu.value?.b_name || selectedMenu.value?.b_code || '',
name: selectedMenu.value?.b_name || selectedMenu.value?.b_id || '',
proceed: action,
}
return
@@ -638,26 +646,38 @@ async function beforeSave() {
if (!typeRule.route && menuData.b_route) menuData.b_route = null
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
// 菜单编码由用户填写,保存时统一去除首尾空格,作为稳定标识使用
menuData.b_code = String(menuData.b_code || '').trim()
// 字符串业务键:菜单 b_id 即编码,创建后不可修改;保存时统一去除首尾空格
const menuCode = String(menuData.b_id || '').trim()
if (isNew && (!menuCode || /^-\d+$/.test(menuCode))) {
Message.warning('新建菜单必须先填写菜单编码')
return false
}
if (!menuCode) {
Message.warning('菜单编码不能为空')
return false
}
menuData.b_id = menuCode
// 受管 i18n 键:菜单统一 menu. 前缀
menuData.b_i18n = `menu.${menuData.b_code}`
menuData.b_i18n = `menu.${menuCode}`
return true
}
/** 组装保存请求:s_menu / s_i18n / s_power 均与各自基线对比,只提交差异;全部无变化返回 null */
async function buildSaveData() {
const menuId = String(selectedMenu.value.b_id)
const treeId = String(selectedMenu.value.b_id)
const menuData = maindata.value
if (!menuData) return null
const isNew = unsavedMenuIds.has(menuId)
const menuCode = menuData.b_code
const menuI18nKey = `menu.${menuCode}`
// 树节点身份(未保存新建时为本地临时负数 id)仅用于 isNew 判定;
// 持久化身份一律使用菜单编码 b_id(子数据引用 / i18n 归属均用它)
const isNew = unsavedMenuIds.has(treeId)
const menuId = String(menuData.b_id || '').trim()
if (!menuId) return null
const menuI18nKey = `menu.${menuId}`
// 目录是纯导航容器:可见性由子菜单决定,自身不生成菜单访问权限点。
// 页面类型菜单的访问权限统一由菜单编码派生。
const menuIsDirectory = menuData.b_menu_type === 'directory'
const powerCode = menuCode ? `menu.${menuCode}` : ''
const powerCode = `menu.${menuId}`
const reqs = []
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
@@ -679,10 +699,9 @@ async function buildSaveData() {
for (const lang of nonDefaultLanguages.value) {
const value = String(i18n_values.value[lang.code] || '').trim()
if (!value) continue // 清空的语言直接不进 current → 基线行走 deletes
const originRow = originByLocale.get(lang.code)
// 字符串业务键:s_i18n 主键 b_id = b_key + ':' + b_locale,行内已有值即可稳定定位
currentI18nRows.push({
// 已有语言沿用其 b_id;新增语言用临时 id,由 allocateTemporaryIds 统一换真实主键
b_id: originRow ? originRow.b_id : createTempId(),
b_id: `${menuI18nKey}:${lang.code}`,
b_key: menuI18nKey,
b_locale: lang.code,
b_value: value,
@@ -699,7 +718,6 @@ async function buildSaveData() {
const i18nReq = { table: 's_i18n', key_field: 'b_id' }
if (i18nChanges.inserts.length) {
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
await allocateTemporaryIds([i18nReq.inserts])
}
if (i18nChanges.updates.length) {
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
@@ -713,13 +731,14 @@ async function buildSaveData() {
// - 访问权限(s_power.b_power_type='menu',按菜单编码派生的权限字符关联)
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
if (menuIsDirectory) {
const previousCode = String(maindata_org.value?.b_code || '').trim()
const previousCode = String(maindata_org.value?.b_id || '').trim()
const prevPowerCode = previousCode ? `menu.${previousCode}` : ''
if (prevPowerCode) {
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'menu', b_resource: prevPowerCode }],
// 权限主键即权限编码(menu.<菜单编码>)
deletes: [{ b_id: prevPowerCode }],
})
}
reqs.push({
@@ -729,63 +748,39 @@ async function buildSaveData() {
})
}
// 权限新行收集器:访问/操作权限的新增行先以负数临时 id 占位,
// 全部收集完成后在函数尾部统一 nextIdApi 批量换取真实主键
const newPowerRows = []
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
// 目录必须整块跳过:上方分支刚把该菜单的访问权限点整条 deletes 掉,
// 这里若继续按「库里查得到」把它塞进 updates,就会去更新一个本次已被删除的行
// (同一事务内先删后改 → 0 行 → 后端抛「修改记录数异常: 0」→ 整个保存回滚,连图标也存不进去);
// 查不到时走 inserts 更糟:把本该清掉的目录权限又写回去。
if (!menuIsDirectory && powerCode) {
const previousCode = String(maindata_org.value?.b_code || '').trim()
const previousCode = String(maindata_org.value?.b_id || '').trim()
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
if (previousPowerCode && previousPowerCode !== powerCode) {
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'menu', b_resource: previousPowerCode }],
deletes: [{ b_id: previousPowerCode }],
})
}
// 查是否有已存在的访问权限(按 b_resource = 菜单权限编码)
// 查是否有已存在的访问权限(主键 = 权限编码 menu.<菜单编码>)
const accessPowerRes = menuData.b_id
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${powerCode}'`)
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_id = ${sqlText(powerCode)}`)
: { data: [] }
const existingAccess = accessPowerRes.data?.[0]
const accessPayload = {
b_id: powerCode,
b_power_type: 'menu',
b_name: menuData.b_name,
b_resource: powerCode,
b_operation: 'access',
b_canuse: menuData.b_canuse ?? 1,
}
if (existingAccess) {
reqs.push({
table: 's_power',
key_field: 'b_id',
updates: [
{
b_id: existingAccess.b_id,
b_power_type: 'menu',
b_code: powerCode,
b_name: menuData.b_name,
b_resource: powerCode,
b_operation: 'access',
b_module_id: null,
b_field_id: null,
b_canuse: menuData.b_canuse ?? 1,
},
],
})
reqs.push({ table: 's_power', key_field: 'b_id', updates: [accessPayload] })
} else {
// 新增访问权限行:负数临时 id 占位,函数尾部统一批量换真实主键
const accessNewRow = {
b_id: createTempId(),
b_power_type: 'menu',
b_code: powerCode,
b_name: menuData.b_name,
b_resource: powerCode,
b_operation: 'access',
b_module_id: null,
b_field_id: null,
b_canuse: menuData.b_canuse ?? 1,
}
newPowerRows.push(accessNewRow)
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [accessNewRow] })
// 新增访问权限行:主键即业务键,无需雪花 id
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [accessPayload] })
}
}
@@ -805,69 +800,44 @@ async function buildSaveData() {
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
}
}
// 新增/更新的行
// 新增/更新的行:权限主键即编码(业务键),无需雪花 id
for (const row of powers.value) {
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
if (row.b_id && baselineIds.has(String(row.b_id))) {
reqs.push({
table: 's_power',
key_field: 'b_id',
updates: [
{
b_id: row.b_id,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
},
],
})
const code = String(row.b_id || '').trim()
if (!code) continue // 未填编码的行不保存
const payload = {
b_id: code,
b_power_type: 'action',
b_name: row.b_name,
b_resource: code,
b_operation: 'execute',
b_module_id: menuId,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
}
if (baselineIds.has(String(row.b_id))) {
reqs.push({ table: 's_power', key_field: 'b_id', updates: [payload] })
} else {
// 新增行:沿用草稿行的负数临时 id(createTempId),函数尾部统一批量换真实主键
const newRow = {
b_id: row.b_id,
b_power_type: 'action',
b_code: row.b_code,
b_name: row.b_name,
b_resource: row.b_code,
b_operation: 'execute',
b_module_id: menuId,
b_field_id: null,
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
}
newPowerRows.push(newRow)
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [newRow] })
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [payload] })
}
}
}
// 权限新增行统一批量换真实主键:一次 nextIdApi(N) 换取 N 个雪花 id,
// allocateTemporaryIds 就地改写行 b_id,reqs 中引用的同一批行对象随之同步
if (newPowerRows.length) await allocateTemporaryIds([newPowerRows])
return reqs.length ? reqs : null
}
/** 保存成功后的树同步、服务端重载与状态更新 */
async function afterSave() {
const menuId = String(selectedMenu.value.b_id)
const treeId = String(selectedMenu.value?.b_id ?? '')
const menuData = maindata.value
unsavedMenuIds.delete(menuId)
// 更新树节点(类型变化时 isLeaf / 图标需重新标记)
const node = findTreeNode(menuTreeData.value, menuId)
if (node) {
Object.assign(node, cloneData(menuData))
delete node.isLeaf
delete node.icon
markDirectoryNonLeaf([node])
const savedKey = String(menuData?.b_id || treeId).trim()
unsavedMenuIds.delete(treeId)
// 保存后重新加载:以数据库落库结果为准重建树(新建菜单临时 id → 编码自然生效),
// 再切到持久化编码加载配置;无需本地做「临时 → 正式」键引用替换。
await loadMenuTree()
if (savedKey === String(menuSelectedId.value)) {
void loadMenuConfig(savedKey)
} else {
menuSelectedId.value = savedKey
}
// 重新从服务端加载:以落库结果为准刷新草稿与 origin 基线
await loadMenuConfig(menuId)
Message.success('保存成功')
}
@@ -970,7 +940,7 @@ async function loadModuleOptions() {
'b_xh ASC, b_id ASC',
)
moduleOptions.value = (res.data || []).map((m) => ({
label: `[${m.b_code}] ${m.b_name}`,
label: `[${m.b_id}] ${m.b_name}`,
value: String(m.b_id),
}))
moduleOptionsLoaded = true