fix(权限): 用户菜单权限“自己没掉”——分区落库时级联清理跨类型误删 + 编辑弹窗空授权提交
- 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 admin/app 全类型)。 此前 admin/app 分区落库把本次提交的 id 当成完整有效集,员工持有的另一类型授权 会整体被判越权删除;两次分区落库互相补刀,最终清空员工全部菜单权限 (生产表现:保存某非超管的权限后,他直建员工的菜单隔三差五自己消失) - AdminUserService 改为两类落库完成后再统一级联一次,新增 cascadeSubordinateOverreach 入口 - 后台「编辑用户」弹窗:授权未加载完成/失败时禁用保存并明确提示, 避免以空 columnIds 整树清空该用户授权(改动密码等操作也会连带触发) - 回归测试:deferredCascadeKeepsSubordinateGrantsOfOtherMenuType、 perTypeReplacementDoesNotCascadeBeforeAllTypesWritten、edit_user_dialog_blocks_save_until_auth_loaded 已知既有红测试(与本次改动无关,干净 HEAD 上同样复现,未新增): ArchitectureBoundaryTest.taskToBusinessDependencyDoesNotGrow(110 > 基线 84)、 HttpClientTimeoutEffectiveTest.connectTimeoutFiresOnUnreachableHost(本机网络环境 60s)
This commit is contained in:
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
|
||||
const errors = reactive<EditUserFormErrors>({})
|
||||
const busy = ref(false)
|
||||
const loadingAuth = ref(false)
|
||||
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
|
||||
const authReady = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -28,6 +30,7 @@ watch(
|
||||
form.columnIds = []
|
||||
form.originalRole = base.originalRole
|
||||
errors.password = undefined
|
||||
authReady.value = false
|
||||
void refreshAuth()
|
||||
},
|
||||
)
|
||||
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
|
||||
try {
|
||||
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
||||
form.columnIds = checkedIds
|
||||
authReady.value = true
|
||||
} catch (error) {
|
||||
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
|
||||
authReady.value = false
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
loadingAuth.value = false
|
||||
@@ -50,6 +56,10 @@ function close(): void {
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!authReady.value) {
|
||||
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
|
||||
return
|
||||
}
|
||||
const { valid, errors: errs } = validateEditUserForm(form)
|
||||
Object.assign(errors, errs)
|
||||
if (!valid) return
|
||||
@@ -103,12 +113,15 @@ async function save(): Promise<void> {
|
||||
<div class="auth-tree-box">
|
||||
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
||||
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
||||
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
|
||||
菜单权限加载失败,保存已禁用;请关闭后重试
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
|
||||
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -126,4 +139,7 @@ async function save(): Promise<void> {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.auth-loading-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
|
||||
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
|
||||
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
|
||||
// 修复:授权未就绪时禁用保存并明确提示。
|
||||
|
||||
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
|
||||
const dialog = readSource('src/pages/account/EditUserDialog.vue')
|
||||
|
||||
assert.match(dialog, /authReady/, '存在授权就绪标记')
|
||||
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
|
||||
|
||||
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
|
||||
const refreshBlock = dialog.slice(
|
||||
dialog.indexOf('async function refreshAuth'),
|
||||
dialog.indexOf('function close'),
|
||||
)
|
||||
assert.match(
|
||||
refreshBlock,
|
||||
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
|
||||
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
|
||||
)
|
||||
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
|
||||
|
||||
// save() 自身兜底拦截,不依赖按钮禁用
|
||||
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
|
||||
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
|
||||
|
||||
// 保存按钮在未就绪时禁用
|
||||
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
|
||||
assert.match(dialog, /加载中/, '加载中有提示')
|
||||
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
|
||||
})
|
||||
Reference in New Issue
Block a user