20260911173133

This commit is contained in:
oneao committed 2026-09-11 17:31:34 +08:00
1 parent 3838ed74b1
commit f3ad727f1c
60 files changed
+4971 -6621

No files matched your search

@@ -1,17 +1,18 @@
<script setup>
/**
* 菜单管理:管理 s_menu 导航树。
* 布局:左侧菜单树 + 右侧属性编辑面板(菜单配置 / 当前菜单权限 / 权限库)。
* 布局:左侧菜单树 + 右侧属性编辑面板(菜单配置 / 多语言)。
* 菜单类型:directory(目录)、page(页面)、external(外链)。
*
* 本文件是编排层,只保留三类职责:
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序,以及树节点启用停用的落库;
* 2. 菜单配置(主表 / 多语言 / 权限点)的加载与保存编排
* 2. 菜单配置(主表 / 多语言)的加载与保存编排
* (校验 → diff → 分配真实主键 → 落库 → 重载);
* 3. 跨面板联动:菜单类型决定是否展示权限面板、菜单编码派生多语言键、
* 未保存切换确认与统一确认弹窗。
* 面板各自的字段编辑、表格交互、权限库加载与导入均在对应面板组件内部,
* 3. 跨面板联动:菜单编码派生多语言键、未保存切换确认与统一确认弹窗。
* 面板各自的字段编辑与表格交互均在对应面板组件内部,
* 数据以 v-model 直接读写本文件持有的草稿,不存在第二份状态源。
*
* 权限点(s_power)统一在「模块管理 → 权限」维护,菜单保存不再派生 menu.* 权限点。
*/
import { computed, nextTick, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
@@ -32,8 +33,6 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
import I18nNameModal from '@/components/I18nNameModal.vue'
import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import MenuPowersPanel from './MenuPowersPanel.vue'
import MenuPowerLibraryPanel from './MenuPowerLibraryPanel.vue'
import { findNodePosition, markDirectoryNonLeaf, menuRowOf, renumberSiblings } from './utils'
import { useI18nStore } from '@/stores/i18n'
@@ -96,22 +95,6 @@ const i18n_org = ref([])
/** 多语言名称编辑弹窗 */
const i18nModalVisible = ref(false)
/** 操作 / 按钮权限点草稿(b_power_type 统一存 action,按钮以 b_name 区分) */
const powers = ref([])
/** 权限点基线快照(JSON 字符串),用于未保存判断 */
const powers_org = ref('')
const powersLoading = ref(false)
/**
* 清空权限点草稿与基线:切换菜单 / 新建 / 取消选中都必须走这里,
* 否则上个菜单的行会残留到新菜单,「删除(N)」会显示对不上当前数据的计数。
*/
function resetPowers() {
powers.value = []
powers_org.value = '[]'
powersLoading.value = false
}
/** 菜单基础信息面板(保存前校验入口) */
const basicPanelRef = ref(null)
@@ -122,15 +105,13 @@ const selectedMenu = computed(() =>
const busy = computed(() => configLoading.value || configSaving.value)
// 菜单类型以草稿为准:新建/编辑时切换类型,右侧字段(路由/绑定模块/权限面板)
// 菜单类型以草稿为准:新建/编辑时切换类型,右侧字段(路由 / 绑定模块)
// 必须立即联动;若只读树节点,切换类型后字段区不动,
// 改成 page 却拿不到路由输入框 → 保存被「必须填写路由地址」拦下且无从修改(死循环)。
// 草稿未就绪(加载中/未选中)时回退树节点。
const menuType = computed(
() => maindata.value?.b_menu_type || selectedMenu.value?.b_menu_type || null,
)
// 目录是纯导航容器:无访问权限、无操作/按钮权限点,右侧不展示权限面板
const isDirectory = computed(() => menuType.value === 'directory')
const menuTypeLocked = computed(
() => !!selectedMenu.value && !unsavedMenuIds.has(String(selectedMenu.value.b_id)),
)
@@ -353,7 +334,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
i18n_values.value = { [defaultLocale.value]: '' }
i18n_values_org.value = { [defaultLocale.value]: '' }
i18n_org.value = []
resetPowers()
loadedMenuId.value = newId
}
@@ -379,21 +359,7 @@ async function deleteMenu(id) {
okType: 'danger',
onOk: async () => {
try {
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) {
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'menu', b_resource: menuPowerCode }],
})
}
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
})
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
await saveObjectApi(reqs)
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
@@ -440,29 +406,6 @@ async function loadMenuConfig(menuId) {
i18n_values.value = i18nInitValues
i18n_values_org.value = { ...i18nInitValues }
i18n_org.value = i18n_org_rows
// 操作/按钮权限点:仅页面/外链才有(目录是导航容器,无按钮权限)
powersLoading.value = true
try {
const powerRes =
menu && menu.b_menu_type !== 'directory'
? await loadDataApi(
's_power',
`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_name: row.b_name,
b_power_type: 'action',
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
}))
// 基线只快照刚加载出的行:随后用户在面板里的增删改都算未保存改动
powers_org.value = JSON.stringify(powers.value)
} finally {
powersLoading.value = false
}
loadedMenuId.value = id
} catch (e) {
if (String(menuSelectedId.value) === String(menuId)) {
@@ -485,7 +428,6 @@ watch(menuSelectedId, (id) => {
configLoading.value = false
loadedMenuId.value = null
maindata.value = null
resetPowers()
return
}
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
@@ -504,7 +446,6 @@ watch(menuSelectedId, (id) => {
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
i18n_values_org.value = { ...i18n_values.value }
i18n_org.value = []
resetPowers()
loadedMenuId.value = String(id)
return
}
@@ -518,31 +459,6 @@ watch(menuSelectedId, (id) => {
const switchConfirm = ref(null)
const switchSaving = ref(false)
/** 解析权限点基线(JSON 字符串);解析失败按空数组处理 */
function parsePowersBaseline() {
try {
return JSON.parse(powers_org.value) || []
} catch {
return []
}
}
/**
* 权限点归一化后序列化,供未保存比较使用:
* - b_id:基线来自数据库(number),新增行是临时 id(string),统一按字符串比
* - b_canuse:表格编辑器写入布尔值,基线是 0/1,统一归一为 0/1
* 否则「勾选后取消」这类回到原语义的操作会被永久判为未保存。
*/
function serializePowers(rows) {
return JSON.stringify(
(rows || []).map((r) => ({
b_id: r.b_id == null ? null : String(r.b_id),
b_name: String(r.b_name || ''),
b_canuse: Number(r.b_canuse) === 1 ? 1 : 0,
})),
)
}
function hasUnsavedChanges() {
if (!maindata.value) return false
if (JSON.stringify(maindata.value) !== JSON.stringify(maindata_org.value)) return true
@@ -553,8 +469,6 @@ function hasUnsavedChanges() {
)
)
return true
// 操作/按钮权限点变化视为未保存
if (serializePowers(powers.value) !== serializePowers(parsePowersBaseline())) return true
return false
}
@@ -662,7 +576,7 @@ async function beforeSave() {
return true
}
/** 组装保存请求:s_menu / s_i18n / s_power 均与各自基线对比,只提交差异;全部无变化返回 null */
/** 组装保存请求:s_menu / s_i18n 均与各自基线对比,只提交差异;全部无变化返回 null */
async function buildSaveData() {
const treeId = String(selectedMenu.value.b_id)
const menuData = maindata.value
@@ -674,10 +588,6 @@ async function buildSaveData() {
const menuId = String(menuData.b_id || '').trim()
if (!menuId) return null
const menuI18nKey = `menu.${menuId}`
// 目录是纯导航容器:可见性由子菜单决定,自身不生成菜单访问权限点。
// 页面类型菜单的访问权限统一由菜单编码派生。
const menuIsDirectory = menuData.b_menu_type === 'directory'
const powerCode = `menu.${menuId}`
const reqs = []
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
@@ -726,101 +636,6 @@ async function buildSaveData() {
reqs.push(i18nReq)
}
// 目录是纯导航容器:自身不需要菜单访问权限与按钮权限点。
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
// - 访问权限(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_id || '').trim()
const prevPowerCode = previousCode ? `menu.${previousCode}` : ''
if (prevPowerCode) {
reqs.push({
table: 's_power',
key_field: 'b_id',
// 权限主键即权限编码(menu.<菜单编码>)
deletes: [{ b_id: prevPowerCode }],
})
}
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
})
}
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
// 目录必须整块跳过:上方分支刚把该菜单的访问权限点整条 deletes 掉,
// 这里若继续按「库里查得到」把它塞进 updates,就会去更新一个本次已被删除的行
// (同一事务内先删后改 → 0 行 → 后端抛「修改记录数异常: 0」→ 整个保存回滚,连图标也存不进去);
// 查不到时走 inserts 更糟:把本该清掉的目录权限又写回去。
if (!menuIsDirectory && powerCode) {
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_id: previousPowerCode }],
})
}
// 查是否有已存在的访问权限(主键 = 权限编码 menu.<菜单编码>)
const accessPowerRes = menuData.b_id
? 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: [accessPayload] })
} else {
// 新增访问权限行:主键即业务键,无需雪花 id
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [accessPayload] })
}
}
// 操作/按钮权限点(s_power.b_power_type='action',diff 保存)
// 目录是导航容器,不保存操作/按钮权限点。
// 基线行的删除必须能独立触发:只看 powers.value.length 会让「全部删光后保存」静默失效
// (一行不剩时整个 diff 被跳过,基线里的行删不掉,重新加载后又冒出来)。
const baselineIds = new Set()
for (const r of parsePowersBaseline()) {
if (r.b_id) baselineIds.add(String(r.b_id))
}
if (!menuIsDirectory && (baselineIds.size || powers.value.length)) {
const currentIds = new Set(powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)))
// 被删除的行
for (const id of baselineIds) {
if (!currentIds.has(id)) {
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
}
}
// 新增/更新的行:权限主键即编码(业务键),无需雪花 id
for (const row of powers.value) {
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 {
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [payload] })
}
}
}
return reqs.length ? reqs : null
}
@@ -936,7 +751,7 @@ async function loadModuleOptions() {
try {
const res = await loadDataApi(
's_module',
"b_canuse = 1 AND b_module_type IN ('data', 'query')",
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
'b_xh ASC, b_id ASC',
)
moduleOptions.value = (res.data || []).map((m) => ({
@@ -1041,20 +856,6 @@ loadModuleOptions()
/>
</div>
</SplitterPanel>
<!-- 权限面板:仅页面/外链(目录无权限) -->
<SplitterPanel v-if="!isDirectory">
<MenuPowersPanel
v-model="powers"
:loading="powersLoading"
:menu-id="String(selectedMenu.b_id)"
/>
</SplitterPanel>
<!-- 权限来源库:全库聚合,快速导入 -->
<SplitterPanel v-if="!isDirectory" :default-size="260">
<MenuPowerLibraryPanel v-model="powers" />
</SplitterPanel>
</Splitter>
</div>
<div v-else-if="configLoading" class="mm-config-state">
@@ -1122,8 +923,7 @@ loadModuleOptions()
<style lang="scss" scoped>
/* ============================================================
* 菜单管理页面样式(仅页面布局)
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel /
* MenuPowersPanel / MenuPowerLibraryPanel)一起维护
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel)一起维护
* ============================================================ */
/* ---- 页面根 ---- */
@@ -1215,7 +1015,7 @@ loadModuleOptions()
padding: 0 3px;
}
/* 第一个面板(菜单配置)去左边距;最后一个面板(权限库)去右边距 */
/* 第一个面板(菜单配置)去左边距;最后一个面板去右边距 */
.mm-edit-splitter :deep(> .fms-splitter-panel:first-child) {
padding-left: 0;
}