20260905172534
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user