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