From 0159f009fad74a637d66ce9350ea2c0cd2a8e444 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:29:01 +0000 Subject: [PATCH] Show an honest rejected state on the RDM roles panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/adminRdmSections.tsx | 13 +++++++--- src/rdmManagement.ts | 17 +++++++++++++ test/rdmroles-reject.test.ts | 39 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 test/rdmroles-reject.test.ts diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 5c823fb..a0cab8d 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -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) => { 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 仅平台管理员可管理专业角色 + // 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见 + // 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() { ))} {roles !== null && roles.length === 0 && 暂无专业角色} - {roles === null && 加载中…} + {placeholder && {placeholder}} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 5635cce..220b3c0 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -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() diff --git a/test/rdmroles-reject.test.ts b/test/rdmroles-reject.test.ts new file mode 100644 index 0000000..e8b75e4 --- /dev/null +++ b/test/rdmroles-reject.test.ts @@ -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') + }) +})