From d965732106dc12157f48a6522a8f1751789904ac Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 04:17:29 +0000 Subject: [PATCH] agent-exec transport 5be3a44afded25495039416fcd4551c0/39cdc052739e --- src/api.ts | 41 +++++ src/components/AccountUsersPanel.tsx | 224 ++++++++++++++++++++++-- src/components/RolePermissionsPanel.tsx | 210 +++++++++++++++++++++- src/roleMembers.ts | 141 +++++++++++++++ src/styles.css | 17 ++ test/account-users-panel.test.ts | 28 +++ test/role-members.test.ts | 128 ++++++++++++++ test/role-permissions-ui.test.ts | 10 +- 8 files changed, 777 insertions(+), 22 deletions(-) create mode 100644 src/roleMembers.ts create mode 100644 test/role-members.test.ts diff --git a/src/api.ts b/src/api.ts index f48e967..10536ce 100644 --- a/src/api.ts +++ b/src/api.ts @@ -63,12 +63,34 @@ export interface AccountRecord { enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean initializationState?: string; initializationGeneration?: number; createdAt?: string } +/** 用户目录角色投影:actor 具备 system.roles.view 时返回(无绑定为 null);无查看权时字段缺省。 */ +export interface AccountRoleSummary { roleId: string; name: string; enabled: boolean } /** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */ export interface ManagedAccountRecord extends AccountRecord { enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean initializationState: string; initializationGeneration: number + role?: AccountRoleSummary | null } interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string } +/** 批量角色分配条目:一切以 userId/roleId 为稳定标识。 */ +export interface UserRoleAssignmentInput { userId: string; roleId: string; expectedStateVersion: number } +/** error 为服务端中文原因(停用/版本变化/角色无效);任何一项有 error 或 stateMatches=false 都不得提交。 */ +export interface UserRoleAssignmentPreview { + userId: string; owner: string; name: string; currentRoleId: string; currentRoleName: string + targetRoleId: string; targetRoleName: string; stateMatches: boolean; error?: string +} +export interface UserRoleAssignmentResult { userId: string; bindingId: string; roleId: string; previousRoleId: string; userStateVersion: number } +export interface UserRoleAssignmentBatch { receiptId?: string; authorityEpoch?: number; bindings: UserRoleAssignmentResult[] } +export interface RoleMember { + userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; assignedAt: string; bindingVersion: number +} +interface RoleMemberPage { members: RoleMember[]; nextCursor?: string } +/** 复制角色并分配:新角色尚不存在,分配条目只带 userId 与状态版本(服务端严格拒绝未知字段)。 */ +export interface RoleCopyAssignRequest { + idempotencyKey: string; source: { roleId: string; expectedRoleVersion: number }; name: string; description: string + overrides?: RolePermissionChangeInput[]; assignments: { userId: string; expectedStateVersion: number }[] +} +export interface RoleCopyAssignResult { receiptId?: string; authorityEpoch?: number; role: RoleRecord; bindings: UserRoleAssignmentResult[] } export interface ExecEnvView { configured: boolean @@ -123,6 +145,25 @@ export const api = { assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json(r)), + previewUserRoleAssignments: (assignments: UserRoleAssignmentInput[]) => + auth.fetch(`${BASE}/api/admin/users/role-assignments/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignments }) }).then(r => json<{ previews: UserRoleAssignmentPreview[] }>(r)).then(d => d.previews ?? []), + assignUserRolesBatch: (assignments: UserRoleAssignmentInput[], idempotencyKey: string) => + auth.fetch(`${BASE}/api/admin/users/role-assignments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idempotencyKey, assignments }) }).then(r => json(r)), + listRoleMembers: (roleId: string, request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; cursor?: string; limit?: number } = {}) => { + const params = new URLSearchParams() + if (request.query) params.set('query', request.query) + if (request.mode) params.set('mode', request.mode) + if (request.status) params.set('status', request.status) + if (request.cursor) params.set('cursor', request.cursor) + if (request.limit) params.set('limit', String(request.limit)) + const query = params.toString() + return auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/members${query ? `?${query}` : ''}`).then(r => json(r)) + }, + createRoleCopyAndAssign: (request: RoleCopyAssignRequest) => + auth.fetch(`${BASE}/api/admin/roles/copy-assign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request) }).then(r => json(r)), getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)), replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 67fe108..4e38c4b 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -1,10 +1,19 @@ import { useEffect, useRef, useState } from 'react' import { - api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView, + api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView, } from '../api' import { useStore } from '../store' import { Collapsible, Icon } from '@ui' -import { hasSystemAction } from './RolePermissionsPanel' +import { cellKind, hasSystemAction } from './RolePermissionsPanel' +import { + ROLE_PERMISSION_STATES, categoryActions, permissionCategories, stateToValue, valueToState, + type RolePermissionStateName, type RolePermissionValue, +} from '../rolePermissions' +import { + accountLabel, accountName, clearSelection, copyAssignRequest, createSelection, duplicateNames, isSelected, memberAssignments, + pageSelectionState, previewAllowsAssign, previewTransition, refreshStateVersions, roleColumnLabel, roleSourceValues, sameNameHint, + selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne, type MemberSelection, +} from '../roleMembers' const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' }) const fields: { key: keyof AccountProfile; label: string }[] = [ @@ -30,7 +39,16 @@ export function AccountUsersPanel() { const canAssignRoles = canViewRoles && allowed('system.roles.assign') const canViewGrants = allowed('system.permissions.view') const canManageGrants = canViewGrants && allowed('system.permissions.manage') + const canCopyRole = canAssignRoles && canManageGrants && allowed('system.roles.manage') const [accounts, setAccounts] = useState(null) + const [selection, setSelection] = useState(createSelection) + const [known, setKnown] = useState>({}) + const [batchOpen, setBatchOpen] = useState(false) + const [batchRoleId, setBatchRoleId] = useState('') + const [batchPreview, setBatchPreview] = useState(null) + const [copyName, setCopyName] = useState('') + const [copyDescription, setCopyDescription] = useState('') + const [copyValues, setCopyValues] = useState>({}) const [selected, setSelected] = useState(null) const [roleView, setRoleView] = useState(null) const [roles, setRoles] = useState([]) @@ -58,6 +76,13 @@ export function AccountUsersPanel() { const [err, setErr] = useState('') const sequence = useRef(0) const roleSequence = useRef(0) + const batchSequence = useRef(0) + const pageIds = (accounts ?? []).map(account => account.userId) + const pageState = pageSelectionState(selection, pageIds) + const counts = selectionCounts(selection, pageIds) + const duplicates = duplicateNames(accounts ?? []) + const batchReady = !!batchPreview && previewAllowsAssign(batchPreview, selection.ids) + const batchStale = batchPreview ? staleUserIds(batchPreview) : [] const search = async (pageCursor = cursor, afterWrite = false) => { const request = ++sequence.current @@ -66,6 +91,7 @@ export function AccountUsersPanel() { fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 }) if (request !== sequence.current) return setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '') + setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) })) } catch (error) { if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error)) } @@ -82,6 +108,7 @@ export function AccountUsersPanel() { roleSequence.current++ setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false) setSelected(null); setRoleView(null); setErr(''); setNotice('') + setCopyName(''); setCopyDescription(''); setCopyValues({}) const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)]) if (request !== sequence.current) return if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return } @@ -105,7 +132,7 @@ export function AccountUsersPanel() { } useEffect(() => { - if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return } + if ((!selected && !batchOpen) || !canViewRoles) { setRoles([]); setRoleLoading(false); return } const request = ++roleSequence.current setRoleLoading(true); setNextRoleCursor('') const timer = window.setTimeout(() => { @@ -117,7 +144,7 @@ export function AccountUsersPanel() { .catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } }) }, 180) return () => { window.clearTimeout(timer) } - }, [selected?.userId, roleQuery, roleCursor, canViewRoles]) + }, [selected?.userId, batchOpen, roleQuery, roleCursor, canViewRoles]) // 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。 const refreshFirst = async () => { @@ -182,6 +209,156 @@ export function AccountUsersPanel() { finally { setBusy(false) } } + // 选择集合一旦变化,旧预览不再对应提交集合,必须重新预览。 + const changeSelection = (next: MemberSelection) => { + batchSequence.current++ + setSelection(next); setBatchPreview(null) + } + + const openBatch = () => { + roleSequence.current++ + setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor('') + setBatchRoleId(''); setBatchPreview(null); setBatchOpen(true); setErr(''); setNotice('') + } + + const closeBatch = () => { + batchSequence.current++ + setBatchOpen(false); setBatchRoleId(''); setBatchPreview(null) + } + + const previewBatch = async (roleId: string, versions = known) => { + const request = ++batchSequence.current + setBatchRoleId(roleId); setBatchPreview(null); setErr(''); setNotice('') + if (!roleId) return + setBusy(true) + try { + const assignments = memberAssignments(selection.ids, roleId, + Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion]))) + const previews = await api.previewUserRoleAssignments(assignments) + if (request === batchSequence.current) setBatchPreview(previews) + } catch (error) { if (request === batchSequence.current) setErr(error instanceof Error ? error.message : String(error)) } + finally { setBusy(false) } + } + + const refreshBatchVersions = async () => { + if (!batchPreview) return + setBusy(true); setErr('') + try { + const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(batchPreview)) } + setKnown(fresh) + await previewBatch(batchRoleId, fresh) + } catch (error) { setErr(String(error)); setBusy(false) } + } + + const confirmBatch = async () => { + if (!batchPreview || !previewAllowsAssign(batchPreview, selection.ids)) return + const roleName = batchPreview[0]?.targetRoleName ?? '' + setBusy(true); setErr(''); setNotice('') + try { + const assignments = memberAssignments(selection.ids, batchRoleId, + Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion]))) + const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID()) + changeSelection(clearSelection()); closeBatch() + await refreshFirst() + setNotice(`已为 ${result.bindings?.length ?? assignments.length} 人分配角色「${roleName}」`) + } catch (error) { + setBatchPreview(null) + setErr(String(error).includes('409') ? '部分用户状态或目标角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`) + } finally { setBusy(false) } + } + + const copyAndAssign = async () => { + const source = roleView?.role + if (!selected || !source) return + setErr(''); setNotice('') + let request + try { + request = copyAssignRequest({ idempotencyKey: crypto.randomUUID(), source, name: copyName, description: copyDescription, catalog, + values: { ...roleSourceValues(source, catalog), ...copyValues }, users: [{ userId: selected.userId, stateVersion: selected.stateVersion }] }) + } catch (error) { setErr(error instanceof Error ? error.message : String(error)); return } + setBusy(true) + try { + const result = await api.createRoleCopyAndAssign(request) + await open(selected); await refreshFirst() + setNotice(`已创建新角色「${result.role.name}」并分配给 ${accountLabel(selected)};新角色独立维护,不再随原角色「${source.name}」更新`) + } catch (error) { + setErr(String(error).includes('409') ? '新角色名称已存在,或源角色/用户状态已变化;草稿已保留,请重新打开用户核对后重试' : String(error)) + } finally { setBusy(false) } + } + + const roleSearch = () =>
+ { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> + + +
+ + const copyForm = (source: RoleRecord) => { + const baseline = roleSourceValues(source, catalog) + const valueOf = (actionId: string) => copyValues[actionId] ?? baseline[actionId] + const adjusted = Object.keys(baseline).filter(actionId => { + const value = valueOf(actionId) + return value.allowed !== baseline[actionId].allowed || value.capAllowed !== baseline[actionId].capAllowed + }).length + const setValue = (actionId: string, value: RolePermissionValue) => setCopyValues(current => ({ ...current, [actionId]: value })) + return
+

以「{source.name}」的当前权限为起点创建新角色,并把 {accountLabel(selected!)} 换绑到新角色。新角色独立维护,不再随原角色更新。

+
+ setCopyName(event.target.value)} /> + setCopyDescription(event.target.value)} /> +
+ {permissionCategories(catalog).map(category =>
+ {category} + {categoryActions(catalog, category).map(item => { + const value = valueOf(item.actionId) + const changed = value.allowed !== baseline[item.actionId].allowed || value.capAllowed !== baseline[item.actionId].capAllowed + const label = `新角色 ${item.label}` + return
+ {item.label} + {cellKind(item) === 'toggle' + ? + : } + {changed && 已调整} +
+ })} +
)} +
+ 相对「{source.name}」调整 {adjusted} 项 + +
+
+ } + + const batchPanel = () =>
+
+ 为选中的 {counts.count} 人分配角色 + +
+ {roleSearch()} + + {batchPreview &&
    {batchPreview.map(item =>
  • + {accountLabel(item)}{previewTransition(item)} + {item.error && {item.error}} + {!item.error && !item.stateMatches && 用户状态已变化} +
  • )}
} + {batchPreview && !batchReady &&

存在停用、版本变化或角色无效的用户,处理后才能确认分配

} +
+ {batchStale.length > 0 && } + {batchRoleId && !batchPreview && } + +
+
+ if (!allowed('system.users.view')) return

当前账号没有查看用户的系统权限

return
{allowed('system.users.create') &&
@@ -208,12 +385,10 @@ export function AccountUsersPanel() { } {allowed('system.users.disable') && }
- {canAssignRoles &&
- { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> - - -
} + {canAssignRoles && roleSearch()} + {canCopyRole && roleView?.role && catalog.length > 0 && + {copyForm(roleView.role)} + } {canViewGrants &&
{catalog.filter(item => item.scope === 'system').map(item =>
- - {(accounts ?? []).map(account => - + {sameNameHint(accounts ?? []) &&

{sameNameHint(accounts ?? [])}

} +
登录名姓名工号系统身份状态首次凭据
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.isAdmin ? '系统管理员' : '普通账号'}{account.enabled ? '有效' : '停用'}
+ + + {(accounts ?? []).map(account => + + + + )} - {accounts !== null && accounts.length === 0 && } - {accounts === null && }
{ if (element) element.indeterminate = pageState === 'some' }} + onChange={event => changeSelection(selectPage(selection, pageIds, event.target.checked))} />姓名与账号工号当前角色系统身份状态首次凭据
changeSelection(toggleOne(selection, account.userId, event.target.checked))} />
{accountName(account)} + {account.profile.name.trim() && {account.owner}} + {duplicates.has(account.profile.name.trim()) && 同名 · 按账号辨识}
{account.profile.staffNo || '—'}{roleColumnLabel(canViewRoles ? account.role : undefined)}{account.isAdmin ? '系统管理员' : '普通账号'}{account.enabled ? '有效' : '停用'} {credentialLabel(account)}
没有匹配用户
加载中…
+ {accounts !== null && accounts.length === 0 && 没有匹配用户} + {accounts === null && 加载中…}
+ 0} onToggle={value => { if (!value) closeBatch() }}> + {batchPanel()} + + {counts.count > 0 &&
+ {selectionSummary(counts)} + + {canAssignRoles && } +
} }
} diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index 72f87a5..31b0a0e 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { - api, type PermissionDescriptor, type RolePermissionBatchResult, type RolePermissionPatchEntry, type RolePermissionPreview, type RoleRecord, + api, type ManagedAccountRecord, type PermissionDescriptor, type RoleMember, type RolePermissionBatchResult, type RolePermissionPatchEntry, + type RolePermissionPreview, type RoleRecord, type UserRoleAssignmentPreview, } from '../api' +import { + accountLabel, accountName, assignedToRole, clearSelection, createSelection, isSelected, memberAssignments, pageSelectionState, + previewAllowsAssign, previewTransition, refreshStateVersions, sameNameHint, selectPage, selectionCounts, selectionSummary, staleUserIds, + toggleOne, type MemberSelection, +} from '../roleMembers' import { PROJECT_CREATE_ACTION, PROJECT_CREATE_LINKED_ACTIONS, ROLE_PERMISSION_STATES, categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, permissionCategories, rebaseRoleDraft, rolePatchEntries, stateLabel, stateToValue, updateRolePermission, @@ -133,12 +139,200 @@ function mergeRoles(current: RoleRecord[] | null, fresh: RoleRecord[]): RoleReco return [...fresh.filter(role => !merged.some(item => item.roleId === role.roleId)), ...merged] } +export const roleMembersLabel = (role: Pick) => `查看角色 ${role.name} 的成员` + +export function memberStatusLabel(member: Pick): string { + return member.enabled ? '有效' : '停用(绑定保留,不授予权限)' +} + +const formatAssignedAt = (value: string) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) +} + +/** 角色成员子视图:成员分页列表 + 「添加用户到角色」(预览后一次批量事务提交)。 */ +function RoleMembersView({ role, canAssign, backLabel, onBack, onAssigned }: { + role: RoleRecord; canAssign: boolean; backLabel: string; onBack: () => void; onAssigned: () => void +}) { + const [members, setMembers] = useState(null) + const [query, setQuery] = useState('') + const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all') + const [cursor, setCursor] = useState('') + const [trail, setTrail] = useState([]) + const [nextCursor, setNextCursor] = useState('') + const [reload, setReload] = useState(0) + const [pickerOpen, setPickerOpen] = useState(false) + const [userQuery, setUserQuery] = useState('') + const [userCursor, setUserCursor] = useState('') + const [userTrail, setUserTrail] = useState([]) + const [nextUserCursor, setNextUserCursor] = useState('') + const [candidates, setCandidates] = useState(null) + const [known, setKnown] = useState>({}) + const [selection, setSelection] = useState(createSelection) + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState('') + const [err, setErr] = useState('') + const memberSequence = useRef(0) + const userSequence = useRef(0) + const previewSequence = useRef(0) + const selectableIds = (candidates ?? []).filter(account => !assignedToRole(account, role.roleId)).map(account => account.userId) + const pageState = pageSelectionState(selection, selectableIds) + const counts = selectionCounts(selection, selectableIds) + const ready = !!preview && previewAllowsAssign(preview, selection.ids) + const stale = preview ? staleUserIds(preview) : [] + + useEffect(() => { + const request = ++memberSequence.current + const timer = window.setTimeout(() => { + void api.listRoleMembers(role.roleId, { query: query.trim(), mode: 'fuzzy', status, cursor, limit: 20 }) + .then(page => { if (request === memberSequence.current) { setMembers(page.members ?? []); setNextCursor(page.nextCursor ?? '') } }) + .catch(error => { if (request === memberSequence.current) setErr(String(error)) }) + }, 180) + return () => { window.clearTimeout(timer); memberSequence.current++ } + }, [role.roleId, query, status, cursor, reload]) + + useEffect(() => { + if (!pickerOpen) return + const request = ++userSequence.current + const timer = window.setTimeout(() => { + void api.searchAdminUsers({ query: userQuery.trim(), mode: 'fuzzy', status: 'all', fields: ['owner', 'name', 'userId'], cursor: userCursor, limit: 20 }) + .then(page => { + if (request !== userSequence.current) return + setCandidates(page.accounts ?? []); setNextUserCursor(page.nextCursor ?? '') + setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) })) + }) + .catch(error => { if (request === userSequence.current) setErr(String(error)) }) + }, 180) + return () => { window.clearTimeout(timer); userSequence.current++ } + }, [pickerOpen, userQuery, userCursor, reload]) + + const changeSelection = (next: MemberSelection) => { + previewSequence.current++ + setSelection(next); setPreview(null) + } + + const requestPreview = async (versions = known) => { + const request = ++previewSequence.current + setPreview(null); setErr(''); setNotice(''); setBusy(true) + try { + const assignments = memberAssignments(selection.ids, role.roleId, + Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion]))) + const previews = await api.previewUserRoleAssignments(assignments) + if (request === previewSequence.current) setPreview(previews) + } catch (error) { if (request === previewSequence.current) setErr(error instanceof Error ? error.message : String(error)) } + finally { setBusy(false) } + } + + const refreshVersions = async () => { + if (!preview) return + setBusy(true); setErr('') + try { + const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(preview)) } + setKnown(fresh) + await requestPreview(fresh) + } catch (error) { setErr(String(error)); setBusy(false) } + } + + const confirm = async () => { + if (!preview || !previewAllowsAssign(preview, selection.ids)) return + setBusy(true); setErr(''); setNotice('') + try { + const assignments = memberAssignments(selection.ids, role.roleId, + Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion]))) + const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID()) + changeSelection(clearSelection()) + setCursor(''); setTrail([]); setUserCursor(''); setUserTrail([]); setReload(value => value + 1) + onAssigned() + setNotice(`已将 ${result.bindings?.length ?? assignments.length} 人加入角色「${role.name}」`) + } catch (error) { + setPreview(null) + setErr(String(error).includes('409') ? '部分用户状态或角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`) + } finally { setBusy(false) } + } + + return
+
+ + 角色「{role.name}」的成员 · {role.bindingCount} 人 +
+ {!role.enabled &&

角色已停用:现有绑定不再授予权限,也不能向其添加用户

} +
+ {notice &&
{notice}
} + {err &&
{err}
} +
+
+ { setQuery(event.target.value); setCursor(''); setTrail([]) }} /> + +
+ {sameNameHint(members ?? []) &&

{sameNameHint(members ?? [])}

} + + {(members ?? []).map(member => + + + )} + {members !== null && members.length === 0 && } + {members === null && }
姓名与账号状态分配时间
{accountName(member)}{member.name.trim() && {member.owner}}
{memberStatusLabel(member)}{formatAssignedAt(member.assignedAt)}
该角色暂无匹配成员
加载中…
+
+ + +
+ {canAssign && role.enabled && +
+
+ { setUserQuery(event.target.value); setUserCursor(''); setUserTrail([]) }} /> + + +
+ {sameNameHint(candidates ?? []) &&

{sameNameHint(candidates ?? [])}

} + + + + {(candidates ?? []).map(account => { + const assigned = assignedToRole(account, role.roleId) + return + + + + + + })} + {candidates !== null && candidates.length === 0 && } + {candidates === null && }
{ if (element) element.indeterminate = pageState === 'some' }} + onChange={event => changeSelection(selectPage(selection, selectableIds, event.target.checked))} />姓名与账号当前角色状态
changeSelection(toggleOne(selection, account.userId, event.target.checked))} />
{accountName(account)}{account.profile.name.trim() && {account.owner}}
{assigned ? 已分配 : account.role === null ? '未分配' : account.role?.name ?? '—'}{account.enabled ? '有效' : '停用'}
没有匹配用户
加载中…
+ {preview &&
    {preview.map(item =>
  • + {accountLabel(item)}{previewTransition(item)} + {item.error && {item.error}} + {!item.error && !item.stateMatches && 用户状态已变化} +
  • )}
} + {preview && !ready &&

存在停用、版本变化或角色无效的用户,处理后才能确认添加

} +
+ {selectionSummary(counts)} + + {stale.length > 0 && } + + +
+
+
} +
+} + export function RolePermissionsPanel() { const me = useStore(state => state.me) const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) const canManageRoles = allowed('system.roles.manage') const canViewPermissions = allowed('system.permissions.view') const canManagePermissions = canViewPermissions && allowed('system.permissions.manage') + const canViewMembers = allowed('system.users.view') && allowed('system.roles.view') + const canAssignMembers = canViewMembers && allowed('system.roles.assign') + const [membersRole, setMembersRole] = useState(null) const [roles, setRoles] = useState(null) const [catalog, setCatalog] = useState([]) const [catalogFailed, setCatalogFailed] = useState(false) @@ -411,7 +605,9 @@ export function RolePermissionsPanel() {
{role.description && {role.description}} - {role.bindingCount} 人 · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''} + {canViewMembers + ? + : `${role.bindingCount} 人`} · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''} {!role.enabled && 停用角色不可编辑权限,请先启用}
{columns.map(item => {cell(role, item)})} @@ -435,6 +631,7 @@ export function RolePermissionsPanel() { {canManageRoles && (selected.enabled ? : )} + {canViewMembers && } {confirmDisable && selected.enabled &&
停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。 @@ -512,8 +709,8 @@ export function RolePermissionsPanel() { {categories.map(value => )}
} - {canViewPermissions && } - + {canViewPermissions && } + @@ -525,7 +722,10 @@ export function RolePermissionsPanel() { {notice &&
{notice}
} {err &&
{err}
} - {roles === null && roleLoading ?
加载中…
: effectiveView === 'matrix' ? matrix() : focus()} + {membersRole && canViewMembers + ? role.roleId === membersRole.roleId) ?? membersRole} canAssign={canAssignMembers} + backLabel={effectiveView === 'matrix' ? '返回角色矩阵' : '返回单角色配置'} onBack={() => setMembersRole(null)} onAssigned={() => void load()} /> + : roles === null && roleLoading ?
加载中…
: effectiveView === 'matrix' ? matrix() : focus()} {preview && previewPanel(preview)} {canManagePermissions && dirty &&
已修改 {dirtyList.length} 个角色、{changeCount} 项设置 diff --git a/src/roleMembers.ts b/src/roleMembers.ts new file mode 100644 index 0000000..ad9e48a --- /dev/null +++ b/src/roleMembers.ts @@ -0,0 +1,141 @@ +import type { + AccountRoleSummary, PermissionDescriptor, RoleCopyAssignRequest, RolePermissionChangeInput, RoleRecord, + UserRoleAssignmentInput, UserRoleAssignmentPreview, +} from './api' +import type { RolePermissionValue } from './rolePermissions' + +/** 显式选择集合:只含逐个或整页勾选过的 userId;筛选与翻页既不清空也不扩大它。 */ +export interface MemberSelection { readonly ids: readonly string[] } + +export const createSelection = (ids: readonly string[] = []): MemberSelection => ({ ids: [...new Set(ids)] }) + +export const clearSelection = (): MemberSelection => createSelection() + +export const isSelected = (selection: MemberSelection, userId: string) => selection.ids.includes(userId) + +export function toggleOne(selection: MemberSelection, userId: string, checked = !isSelected(selection, userId)): MemberSelection { + if (checked) return isSelected(selection, userId) ? selection : { ids: [...selection.ids, userId] } + return isSelected(selection, userId) ? { ids: selection.ids.filter(id => id !== userId) } : selection +} + +/** 表头全选只作用于当前页可选行;其他页已选的人保持不变。 */ +export function selectPage(selection: MemberSelection, pageIds: readonly string[], checked: boolean): MemberSelection { + if (checked) return createSelection([...selection.ids, ...pageIds]) + const page = new Set(pageIds) + return { ids: selection.ids.filter(id => !page.has(id)) } +} + +export function pageSelectionState(selection: MemberSelection, pageIds: readonly string[]): 'none' | 'some' | 'all' { + const picked = pageIds.filter(id => isSelected(selection, id)).length + if (!pageIds.length || picked === 0) return 'none' + return picked === pageIds.length ? 'all' : 'some' +} + +export function selectionCounts(selection: MemberSelection, pageIds: readonly string[]): { count: number; beyondPage: number } { + const page = new Set(pageIds) + return { count: selection.ids.length, beyondPage: selection.ids.filter(id => !page.has(id)).length } +} + +export function selectionSummary(counts: { count: number; beyondPage: number }): string { + return counts.beyondPage > 0 ? `已选 ${counts.count} 人(包含其他页的 ${counts.beyondPage} 人)` : `已选 ${counts.count} 人` +} + +type NamedAccount = { userId: string; owner: string; name?: string; profile?: { name: string } } + +const rawName = (account: NamedAccount) => (account.profile?.name ?? account.name ?? '').trim() + +/** 姓名缺失时以账号(登录名)展示;它只是显示文本,提交永远用 userId。 */ +export const accountName = (account: NamedAccount) => rawName(account) || account.owner + +export const accountLabel = (account: NamedAccount) => rawName(account) ? `${rawName(account)}(${account.owner})` : account.owner + +export function duplicateNames(accounts: readonly NamedAccount[]): Set { + const owners = new Map>() + for (const account of accounts) { + const name = rawName(account) + if (!name) continue + owners.set(name, (owners.get(name) ?? new Set()).add(account.userId)) + } + return new Set([...owners].filter(([, ids]) => ids.size > 1).map(([name]) => name)) +} + +export function sameNameHint(accounts: readonly NamedAccount[]): string { + const duplicates = duplicateNames(accounts) + if (!duplicates.size) return '' + const groups = [...duplicates].map(name => `「${name}」:${accounts.filter(account => rawName(account) === name).map(account => account.owner).join('、')}`) + return `存在同名用户${groups.join(';')},请按账号辨识` +} + +/** undefined = 无角色查看权(服务端不返回投影);null = 未分配。 */ +export function roleColumnLabel(role: AccountRoleSummary | null | undefined): string { + if (role === undefined) return '—' + if (role === null) return '未分配' + return role.enabled ? role.name : `${role.name}(已停用)` +} + +export const assignedToRole = (account: { role?: AccountRoleSummary | null }, roleId: string) => !!roleId && account.role?.roleId === roleId + +export function memberAssignments( + selected: readonly string[], roleId: string, stateVersions: Readonly>, +): UserRoleAssignmentInput[] { + if (!roleId) throw new Error('请先选择目标角色') + return [...new Set(selected)].map(userId => { + const expectedStateVersion = stateVersions[userId] + if (!expectedStateVersion) throw new Error('缺少用户状态版本,请刷新列表后重试') + return { userId, roleId, expectedStateVersion } + }) +} + +/** 预览显示版本不一致时,只读重取这些用户的详情以获得最新 stateVersion(不涉及任何写接口)。 */ +export async function refreshStateVersions( + client: { adminGetUser: (userId: string) => Promise }, userIds: readonly string[], +): Promise> { + const fresh = await Promise.all([...new Set(userIds)].map(userId => client.adminGetUser(userId))) + return Object.fromEntries(fresh.map(account => [account.userId, account])) +} + +export const staleUserIds = (previews: readonly UserRoleAssignmentPreview[]) => + previews.filter(preview => !preview.stateMatches).map(preview => preview.userId) + +export const previewTransition = (preview: Pick) => + `${preview.currentRoleName || '未分配'} → ${preview.targetRoleName}` + +/** 只有每个提交对象都有预览、且全部无错误且状态版本一致时才允许确认。 */ +export function previewAllowsAssign(previews: readonly UserRoleAssignmentPreview[], userIds: readonly string[]): boolean { + if (!userIds.length) return false + const byUser = new Map(previews.map(preview => [preview.userId, preview])) + return userIds.every(userId => { + const preview = byUser.get(userId) + return !!preview && !preview.error && preview.stateMatches + }) +} + +export function roleSourceValues(role: RoleRecord, catalog: readonly PermissionDescriptor[]): Record { + const existing = new Map(role.permissions.map(item => [item.actionId, item])) + return Object.fromEntries(catalog.filter(item => !item.protected).map(item => { + const value = existing.get(item.actionId) + return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }] + })) +} + +export function copyAssignRequest(input: { + idempotencyKey: string; source: RoleRecord; name: string; description: string; catalog: readonly PermissionDescriptor[] + values: Readonly>; users: readonly { userId: string; stateVersion: number }[] +}): RoleCopyAssignRequest { + const name = input.name.trim() + if (!name) throw new Error('请为新角色填写名称') + if (!input.users.length) throw new Error('请至少选择一位用户') + const baseline = roleSourceValues(input.source, input.catalog) + const overrides: RolePermissionChangeInput[] = [] + for (const [actionId, before] of Object.entries(baseline)) { + const after = input.values[actionId] + if (after && (after.allowed !== before.allowed || after.capAllowed !== before.capAllowed)) overrides.push({ actionId, ...after }) + } + return { + idempotencyKey: input.idempotencyKey, + source: { roleId: input.source.roleId, expectedRoleVersion: input.source.roleVersion }, + name, description: input.description.trim(), + ...(overrides.length ? { overrides } : {}), + assignments: input.users.map(user => ({ userId: user.userId, expectedStateVersion: user.stateVersion })), + } +} diff --git a/src/styles.css b/src/styles.css index 5b668bc..0e88649 100644 --- a/src/styles.css +++ b/src/styles.css @@ -566,6 +566,23 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; } .rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); } .rp-grants .ad-mono { margin-left: auto; } +.rp-members { display: flex; flex-direction: column; gap: 10px; } +.rp-members-link { border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; text-decoration: underline; } +.rp-members-link:disabled { color: var(--text-dim); cursor: default; text-decoration: none; } +.rp-members-link:focus-visible { outline: none; border-radius: var(--radius-xs); box-shadow: var(--ring); } +.au-select-col { width: 32px; } +.au-select-col input:focus-visible { outline: none; box-shadow: var(--ring); } +.au-name-cell { display: flex; flex-direction: column; gap: 2px; } +.au-same-name { align-self: flex-start; font-size: var(--fs-ui-xs); color: var(--warn-text); background: var(--warn-soft); border-radius: var(--radius-xs); padding: 1px 6px; } +.au-grow { flex: 1; min-width: 0; } +.au-batch .lg-input { height: 34px; } +.au-preview-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; } +.au-preview-list li { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; padding-top: 6px; border-top: 1px solid var(--border-soft); } +.au-copy { display: flex; flex-direction: column; gap: 10px; } +.au-copy-group { margin: 0; padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius); display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px 12px; } +.au-copy-group legend { padding: 0 4px; font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text-dim); } +.au-copy-row { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text); } +.au-copy-row > span:first-child { flex: 1; min-width: 0; } @media (max-width: 900px) { .rp-single { grid-template-columns: 1fr; } .rp-role-list { max-height: 220px; overflow-y: auto; } diff --git a/test/account-users-panel.test.ts b/test/account-users-panel.test.ts index 4085e92..6f19001 100644 --- a/test/account-users-panel.test.ts +++ b/test/account-users-panel.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { credentialLabel } from '../src/components/AccountUsersPanel' +import { accountLabel, accountName, duplicateNames, roleColumnLabel, sameNameHint } from '../src/roleMembers' + +const account = (userId: string, owner: string, name: string) => ({ userId, owner, profile: { name } }) describe('account directory credential state', () => { it('shows pending trusted-owner initialization before password state', () => { @@ -8,3 +11,28 @@ describe('account directory credential state', () => { expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码') }) }) + +describe('account directory names and roles', () => { + it('falls back to the login account when the name is missing', () => { + expect(accountName(account('u1', 'zhang.san', ''))).toBe('zhang.san') + expect(accountName(account('u1', 'zhang.san', ' '))).toBe('zhang.san') + expect(accountName(account('u1', 'zhang.san', '张三'))).toBe('张三') + expect(accountLabel(account('u1', 'zhang.san', '张三'))).toBe('张三(zhang.san)') + expect(accountLabel(account('u1', 'zhang.san', ''))).toBe('zhang.san') + expect(accountName({ userId: 'u2', owner: 'li.si', name: '李四' })).toBe('李四') + }) + + it('asks to tell same-name users apart by account', () => { + const accounts = [account('u1', 'zhang.san', '张三'), account('u2', 'zhang.san2', '张三'), account('u3', 'li.si', '李四'), account('u4', 'anon', '')] + expect([...duplicateNames(accounts)]).toEqual(['张三']) + expect(sameNameHint(accounts)).toBe('存在同名用户「张三」:zhang.san、zhang.san2,请按账号辨识') + expect(sameNameHint(accounts.slice(2))).toBe('') + }) + + it('labels the current role projection', () => { + expect(roleColumnLabel(undefined)).toBe('—') + expect(roleColumnLabel(null)).toBe('未分配') + expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: true })).toBe('软件工程师') + expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: false })).toBe('软件工程师(已停用)') + }) +}) diff --git a/test/role-members.test.ts b/test/role-members.test.ts new file mode 100644 index 0000000..2cc3208 --- /dev/null +++ b/test/role-members.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PermissionDescriptor, RoleRecord, UserRoleAssignmentPreview } from '../src/api' +import { + assignedToRole, clearSelection, copyAssignRequest, createSelection, memberAssignments, pageSelectionState, previewAllowsAssign, + previewTransition, refreshStateVersions, selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne, +} from '../src/roleMembers' +import { stateToValue } from '../src/rolePermissions' + +const catalog: PermissionDescriptor[] = [ + { actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true }, + { actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false }, + { actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false }, + { actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false }, +] +const source: RoleRecord = { + roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 7, bindingCount: 3, + permissions: [{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 }], +} +const preview = (userId: string, patch: Partial = {}): UserRoleAssignmentPreview => ({ + userId, owner: userId, name: '', currentRoleId: '', currentRoleName: '', targetRoleId: 'role_engineer', targetRoleName: '软件工程师', + stateMatches: true, ...patch, +}) + +describe('cross-page member selection', () => { + it('accumulates explicit picks across pages and counts the ones beyond the current page', () => { + let selection = toggleOne(createSelection(), 'u1') + selection = toggleOne(selection, 'u2') + const pageTwo = ['u3', 'u4'] + selection = selectPage(selection, pageTwo, true) + expect(selection.ids).toEqual(['u1', 'u2', 'u3', 'u4']) + expect(selectionCounts(selection, pageTwo)).toEqual({ count: 4, beyondPage: 2 }) + expect(selectionSummary(selectionCounts(selection, pageTwo))).toBe('已选 4 人(包含其他页的 2 人)') + expect(selectionSummary(selectionCounts(selection, ['u1', 'u2', 'u3', 'u4']))).toBe('已选 4 人') + }) + + it('page select-all only touches the current page and reports a partial page', () => { + const selection = selectPage(createSelection(['u9']), ['u1', 'u2'], true) + expect(pageSelectionState(selection, ['u1', 'u2'])).toBe('all') + const unpicked = selectPage(selection, ['u1', 'u2'], false) + expect(unpicked.ids).toEqual(['u9']) + expect(pageSelectionState(toggleOne(selection, 'u2', false), ['u1', 'u2'])).toBe('some') + expect(pageSelectionState(unpicked, [])).toBe('none') + }) + + it('keeps the selection when filters change and never widens it to unselected matches', () => { + const selection = toggleOne(createSelection(), 'u1') + const filteredPage = ['u5', 'u6'] + expect(selectionCounts(selection, filteredPage)).toEqual({ count: 1, beyondPage: 1 }) + expect(selection.ids).toEqual(['u1']) + expect(toggleOne(selection, 'u1', true)).toBe(selection) + expect(toggleOne(selection, 'u7', false)).toBe(selection) + }) + + it('clears to an empty selection', () => { + expect(clearSelection().ids).toEqual([]) + expect(selectionCounts(clearSelection(), ['u1'])).toEqual({ count: 0, beyondPage: 0 }) + }) +}) + +describe('member assignment entries', () => { + it('submits stable userId/roleId pairs with each user\'s own state version, once per user', () => { + expect(memberAssignments(['u1', 'u2', 'u1'], 'role_engineer', { u1: 3, u2: 8 })).toEqual([ + { userId: 'u1', roleId: 'role_engineer', expectedStateVersion: 3 }, + { userId: 'u2', roleId: 'role_engineer', expectedStateVersion: 8 }, + ]) + }) + + it('refuses to build entries without a target role or a known state version', () => { + expect(() => memberAssignments(['u1'], '', { u1: 3 })).toThrow('目标角色') + expect(() => memberAssignments(['u1', 'u2'], 'role_engineer', { u1: 3 })).toThrow('状态版本') + }) + + it('allows confirmation only when every submitted user previews cleanly', () => { + expect(previewAllowsAssign([preview('u1'), preview('u2')], ['u1', 'u2'])).toBe(true) + expect(previewAllowsAssign([preview('u1')], ['u1', 'u2'])).toBe(false) + expect(previewAllowsAssign([preview('u1', { error: '用户已停用' })], ['u1'])).toBe(false) + expect(previewAllowsAssign([preview('u1', { stateMatches: false })], ['u1'])).toBe(false) + expect(previewAllowsAssign([], [])).toBe(false) + expect(staleUserIds([preview('u1', { stateMatches: false }), preview('u2')])).toEqual(['u1']) + expect(previewTransition(preview('u1'))).toBe('未分配 → 软件工程师') + expect(previewTransition(preview('u1', { currentRoleName: '审核工程师' }))).toBe('审核工程师 → 软件工程师') + }) + + it('re-reads only stale users through the read-only detail endpoint', async () => { + const adminGetUser = vi.fn(async (userId: string) => ({ userId, stateVersion: 12 })) + expect(await refreshStateVersions({ adminGetUser }, ['u1', 'u1'])).toEqual({ u1: { userId: 'u1', stateVersion: 12 } }) + expect(adminGetUser).toHaveBeenCalledTimes(1) + }) + + it('marks directory results already bound to the role', () => { + expect(assignedToRole({ role: { roleId: 'role_engineer', name: '软件工程师', enabled: true } }, 'role_engineer')).toBe(true) + expect(assignedToRole({ role: null }, 'role_engineer')).toBe(false) + expect(assignedToRole({}, 'role_engineer')).toBe(false) + }) +}) + +describe('copy role and assign request', () => { + const base = { + idempotencyKey: 'key-1', source, description: ' 独立维护 ', catalog, + users: [{ userId: 'u1', stateVersion: 4 }], + } + + it('requires an explicit non-empty new role name', () => { + expect(() => copyAssignRequest({ ...base, name: ' ', values: {} })).toThrow('名称') + expect(() => copyAssignRequest({ ...base, name: '新角色', values: {}, users: [] })).toThrow('用户') + }) + + it('sends only actions whose value differs from the source role', () => { + const request = copyAssignRequest({ ...base, name: ' 软件工程师(外包) ', values: { + 'document.read': stateToValue('default'), + 'document.author': stateToValue('project'), + 'project.create': stateToValue('denied'), + } }) + expect(request).toEqual({ + idempotencyKey: 'key-1', + source: { roleId: 'role_engineer', expectedRoleVersion: 7 }, + name: '软件工程师(外包)', description: '独立维护', + overrides: [{ actionId: 'document.author', allowed: false, capAllowed: true }], + assignments: [{ userId: 'u1', expectedStateVersion: 4 }], + }) + }) + + it('omits overrides entirely when nothing was adjusted', () => { + const request = copyAssignRequest({ ...base, name: '副本', values: { 'document.read': stateToValue('default') } }) + expect(request).not.toHaveProperty('overrides') + expect(request.assignments[0]).not.toHaveProperty('roleId') + }) +}) diff --git a/test/role-permissions-ui.test.ts b/test/role-permissions-ui.test.ts index f65504e..c3f5780 100644 --- a/test/role-permissions-ui.test.ts +++ b/test/role-permissions-ui.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api' import { - NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, previewRoleBatch, - rebaseRoleDrafts, roleCellValue, roleLeaveMessage, shouldAcceptRoleResponse, + NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch, + rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, shouldAcceptRoleResponse, } from '../src/components/RolePermissionsPanel' import { categoryActions, dirtyRoles, rolePatchEntries, stateToValue } from '../src/rolePermissions' @@ -108,6 +108,12 @@ describe('role permission UI helpers', () => { expect(message).toContain('「审核工程师」') }) + it('names the role member entry and member status for assistive technology', () => { + expect(roleMembersLabel(roles[0])).toBe('查看角色 软件工程师 的成员') + expect(memberStatusLabel({ enabled: true })).toBe('有效') + expect(memberStatusLabel({ enabled: false })).toContain('停用') + }) + it('defaults to single-role focus on narrow screens and to the matrix otherwise', () => { expect(initialRoleView(query => query === NARROW_ROLE_QUERY)).toBe('single') expect(initialRoleView(() => false)).toBe('matrix')