Show an honest rejected state on the RDM roles panel

Why: when rdm.role.list is rejected (e.g. the operator lost the RDM
domain-admin ledger to an acceptance bootstrap), the roles table spun on
'加载中…' forever and the banner dumped the raw 400 receipt JSON.
Evidence: vitest test/rdmroles-reject.test.ts (SELF, run below); manual
repro on 8788 dev 2026-10-13 before the ledger fix.
Impact: placeholder row comes from rolesListPlaceholder (failure row when
a fetch rejected), domain-admin denials map via describeRoleListError to
a readable message naming the rdm.perm.transfer channel; other errors
pass through. No style tokens touched (design-guard clean).
Refs: Dev task feature/rdm-admin-lockout-recovery (R3)
This commit is contained in:
2026-10-13 01:29:01 +00:00
parent 43d1933b8c
commit 0159f009fa
3 changed files with 65 additions and 4 deletions
+9 -4
View File
@@ -13,6 +13,7 @@ import {
memberAddPayload, memberRemovePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
@@ -44,19 +45,23 @@ export function RdmRolesPanel() {
const refresh = async () => {
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setRoles(out.roles ?? [])
} catch (e) { setErr(String(e)) }
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
@@ -92,7 +97,7 @@ export function RdmRolesPanel() {
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{roles === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
</tbody>
</table>
+17
View File
@@ -105,6 +105,23 @@ export function describeSource(source: string): string {
}
}
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
if (roles !== null) return roles.length === 0 ? '暂无专业角色' : ''
return err ? '角色清单未加载(见下方提示)' : '加载中…'
}
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
export function describeRoleListError(raw: string): string {
if (raw.includes('不是系统管理员')) {
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
}
return raw
}
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
export function parseReviewers(csv: string): string[] {
const seen = new Set<string>()
+39
View File
@@ -0,0 +1,39 @@
// RDM 角色权限面板拒绝态(2026-10-13 8788 dev 锁死复现):rdm.role.list 被拒后
// 表格停留「加载中…」且错误为原始 400 JSON——测纯函数两件事:占位行不再假装
// 加载中;域管理员权限拒绝映射为人话 + 恢复通道。UI 不重算权限(KTD5),文案
// 只描述拒绝事实,不判权限。
import { describe, expect, it } from 'vitest'
import { rolesListPlaceholder, describeRoleListError } from '../src/rdmManagement'
describe('rolesListPlaceholder', () => {
it('keeps 加载中 only while loading without error', () => {
expect(rolesListPlaceholder(null, '')).toBe('加载中…')
})
it('replaces the loading row with a failure row once the fetch rejected', () => {
expect(rolesListPlaceholder(null, 'Error: 400: {"receipt":…}')).toBe('角色清单未加载(见下方提示)')
})
it('returns the empty label for an empty list and nothing otherwise', () => {
expect(rolesListPlaceholder([], '')).toBe('暂无专业角色')
expect(rolesListPlaceholder([{ roleId: 'r1' }], '')).toBe('')
// 已有数据时即便残留 err 也不再渲染占位行(表格有内容即事实来源)
expect(rolesListPlaceholder([{ roleId: 'r1' }], 'stale')).toBe('')
})
})
describe('describeRoleListError', () => {
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
const got = describeRoleListError(denied)
expect(got).toContain('不是 RDM 域管理员')
expect(got).toContain('rdm.perm.transfer')
expect(got).not.toContain('"receipt"')
})
it('passes other errors through untouched', () => {
expect(describeRoleListError('Error: 500: boom')).toBe('Error: 500: boom')
})
})