agent-exec transport 5be3a44afded25495039416fcd4551c0/39cdc052739e

This commit is contained in:
agent-exec-transport
2026-10-19 04:17:29 +00:00
parent 4be7362842
commit d965732106
8 changed files with 777 additions and 22 deletions
+41
View File
@@ -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<UserRoleBinding>(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<UserRoleAssignmentBatch>(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<RoleMemberPage>(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<RoleCopyAssignResult>(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' },
+209 -15
View File
@@ -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<ManagedAccountRecord[] | null>(null)
const [selection, setSelection] = useState<MemberSelection>(createSelection)
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
const [batchOpen, setBatchOpen] = useState(false)
const [batchRoleId, setBatchRoleId] = useState('')
const [batchPreview, setBatchPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
const [copyName, setCopyName] = useState('')
const [copyDescription, setCopyDescription] = useState('')
const [copyValues, setCopyValues] = useState<Record<string, RolePermissionValue>>({})
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
@@ -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 = () => <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>
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 <div className="au-copy">
<p className="ad-hint">以「{source.name}」的当前权限为起点创建新角色,并把 {accountLabel(selected!)} 换绑到新角色。新角色独立维护,不再随原角色更新。</p>
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称(必填)" placeholder="新角色名称(必填)" value={copyName} disabled={busy} onChange={event => setCopyName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={copyDescription} disabled={busy} onChange={event => setCopyDescription(event.target.value)} />
</div>
{permissionCategories(catalog).map(category => <fieldset className="au-copy-group" key={category}>
<legend>{category}</legend>
{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 <div className="au-copy-row" key={item.actionId}>
<span title={item.description}>{item.label}</span>
{cellKind(item) === 'toggle'
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={busy}
onChange={event => setValue(item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
<option value="off">不允许</option><option value="on">允许</option>
</select>
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={busy}
onChange={event => setValue(item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
</select>}
{changed && <span className="rp-cell-flag">已调整</span>}
</div>
})}
</fieldset>)}
<div className="ad-row">
<span className="ad-hint au-grow">相对「{source.name}」调整 {adjusted} 项</span>
<button className="lg-submit ad-create-btn" disabled={busy || !copyName.trim()} onClick={() => void copyAndAssign()}>确认复制并分配</button>
</div>
</div>
}
const batchPanel = () => <section className="rp-preview au-batch" aria-label={`为选中的 ${counts.count} 人分配角色`}>
<div className="rp-preview-head">
<strong>为选中的 {counts.count} 人分配角色</strong>
<button className="ad-mini" disabled={busy} onClick={closeBatch}>收起</button>
</div>
{roleSearch()}
<select className="lg-input" aria-label="批量分配的目标角色" value={batchRoleId} disabled={busy || roleLoading} onChange={event => void previewBatch(event.target.value)}>
<option value="">请选择目标角色</option>
{batchRoleId && !roles.some(role => role.roleId === batchRoleId) && <option value={batchRoleId}>{batchPreview?.[0]?.targetRoleName ?? '已选角色'}</option>}
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select>
{batchPreview && <ul className="au-preview-list">{batchPreview.map(item => <li key={item.userId}>
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
</li>)}</ul>}
{batchPreview && !batchReady && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认分配</p>}
<div className="ad-row">
{batchStale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshBatchVersions()}>刷新 {batchStale.length} 人的状态并重新预览</button>}
{batchRoleId && !batchPreview && <button className="ad-mini" disabled={busy} onClick={() => void previewBatch(batchRoleId)}>重新预览</button>}
<button className="lg-submit ad-create-btn" disabled={busy || !batchReady} onClick={() => void confirmBatch()}>确认分配</button>
</div>
</section>
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
@@ -208,12 +385,10 @@ export function AccountUsersPanel() {
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
</div>
{canAssignRoles && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{canAssignRoles && roleSearch()}
{canCopyRole && roleView?.role && catalog.length > 0 && <Collapsible id={`copy-role-${selected.userId}`} trigger="复制角色并分配…">
{copyForm(roleView.role)}
</Collapsible>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
@@ -232,14 +407,33 @@ export function AccountUsersPanel() {
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
{sameNameHint(accounts ?? []) && <p className="ad-hint">{sameNameHint(accounts ?? [])}</p>}
<table className="ad-table"><thead><tr>
<th className="au-select-col"><input type="checkbox" aria-label="全选本页用户" checked={pageState === 'all'} disabled={busy || !pageIds.length}
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
onChange={event => changeSelection(selectPage(selection, pageIds, event.target.checked))} /></th>
<th>姓名与账号</th><th>工号</th><th>当前角色</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}>
<td className="au-select-col"><input type="checkbox" aria-label={`选择 ${accountLabel(account)}`} checked={isSelected(selection, account.userId)} disabled={busy}
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
<td><div className="au-name-cell"><span>{accountName(account)}</span>
{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}
{duplicates.has(account.profile.name.trim()) && <span className="au-same-name">同名 · 按账号辨识</span>}</div></td>
<td>{account.profile.staffNo || '—'}</td><td>{roleColumnLabel(canViewRoles ? account.role : undefined)}</td>
<td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
{accounts !== null && accounts.length === 0 && <tr><td colSpan={8} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={8} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
<Collapsible open={batchOpen && canAssignRoles && counts.count > 0} onToggle={value => { if (!value) closeBatch() }}>
{batchPanel()}
</Collapsible>
{counts.count > 0 && <div className="rp-savebar" role="region" aria-label="已选用户">
<span className="rp-savebar-summary" aria-live="polite">{selectionSummary(counts)}</span>
<button className="ad-mini" disabled={busy} onClick={() => { changeSelection(clearSelection()); closeBatch() }}>清空选择</button>
{canAssignRoles && <button className="ad-mini" disabled={busy} aria-expanded={batchOpen} onClick={batchOpen ? closeBatch : openBatch}>为选中的 {counts.count} 人分配角色</button>}
</div>}
</>}
</div>
}
+205 -5
View File
@@ -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<RoleRecord, 'name'>) => `查看角色 ${role.name} 的成员`
export function memberStatusLabel(member: Pick<RoleMember, 'enabled'>): 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<RoleMember[] | null>(null)
const [query, setQuery] = useState('')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
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<string[]>([])
const [nextUserCursor, setNextUserCursor] = useState('')
const [candidates, setCandidates] = useState<ManagedAccountRecord[] | null>(null)
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
const [selection, setSelection] = useState<MemberSelection>(createSelection)
const [preview, setPreview] = useState<UserRoleAssignmentPreview[] | null>(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 <section className="rp-members" aria-label={`角色「${role.name}」的成员`}>
<div className="ad-row">
<button className="ad-mini" disabled={busy} onClick={onBack}>{backLabel}</button>
<strong className="au-grow">角色「{role.name}」的成员 · {role.bindingCount} 人</strong>
</div>
{!role.enabled && <p className="rp-disabled-note">角色已停用:现有绑定不再授予权限,也不能向其添加用户</p>}
<div className="rp-status">
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</div>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色成员" placeholder="姓名、账号或 ID" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
<select className="lg-input" aria-label="成员状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); setCursor(''); setTrail([]) }}>
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
</select>
</div>
{sameNameHint(members ?? []) && <p className="ad-hint">{sameNameHint(members ?? [])}</p>}
<table className="ad-table"><thead><tr><th>姓名与账号</th><th>状态</th><th>分配时间</th></tr></thead>
<tbody>{(members ?? []).map(member => <tr key={member.userId}>
<td><div className="au-name-cell"><span>{accountName(member)}</span>{member.name.trim() && <span className="ad-dim ad-mono">{member.owner}</span>}</div></td>
<td>{memberStatusLabel(member)}</td><td>{formatAssignedAt(member.assignedAt)}</td>
</tr>)}
{members !== null && members.length === 0 && <tr><td colSpan={3} className="ad-dim">该角色暂无匹配成员</td></tr>}
{members === null && <tr><td colSpan={3} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row">
<button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页成员</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页成员</button>
</div>
{canAssign && role.enabled && <Collapsible open={pickerOpen} onToggle={setPickerOpen} trigger="添加用户到角色">
<div className="au-copy">
<div className="ad-row">
<input className="lg-input" aria-label="搜索要添加的用户" placeholder="搜索用户姓名、账号或 ID" value={userQuery}
onChange={event => { setUserQuery(event.target.value); setUserCursor(''); setUserTrail([]) }} />
<button className="ad-mini" disabled={!userTrail.length} onClick={() => { setUserCursor(userTrail[userTrail.length - 1]); setUserTrail(userTrail.slice(0, -1)) }}>上一页用户</button>
<button className="ad-mini" disabled={!nextUserCursor} onClick={() => { setUserTrail([...userTrail, userCursor]); setUserCursor(nextUserCursor) }}>下一页用户</button>
</div>
{sameNameHint(candidates ?? []) && <p className="ad-hint">{sameNameHint(candidates ?? [])}</p>}
<table className="ad-table"><thead><tr>
<th className="au-select-col"><input type="checkbox" aria-label="全选本页可添加用户" checked={pageState === 'all'} disabled={busy || !selectableIds.length}
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
onChange={event => changeSelection(selectPage(selection, selectableIds, event.target.checked))} /></th>
<th>姓名与账号</th><th>当前角色</th><th>状态</th></tr></thead>
<tbody>{(candidates ?? []).map(account => {
const assigned = assignedToRole(account, role.roleId)
return <tr key={account.userId}>
<td className="au-select-col"><input type="checkbox" aria-label={assigned ? `${accountLabel(account)} 已分配` : `选择 ${accountLabel(account)}`}
checked={!assigned && isSelected(selection, account.userId)} disabled={busy || assigned}
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
<td><div className="au-name-cell"><span>{accountName(account)}</span>{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}</div></td>
<td>{assigned ? <span className="au-same-name">已分配</span> : account.role === null ? '未分配' : account.role?.name ?? '—'}</td>
<td>{account.enabled ? '有效' : '停用'}</td>
</tr>
})}
{candidates !== null && candidates.length === 0 && <tr><td colSpan={4} className="ad-dim">没有匹配用户</td></tr>}
{candidates === null && <tr><td colSpan={4} className="ad-dim">加载中…</td></tr>}</tbody></table>
{preview && <ul className="au-preview-list">{preview.map(item => <li key={item.userId}>
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
</li>)}</ul>}
{preview && !ready && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认添加</p>}
<div className="ad-row">
<span className="ad-hint au-grow" aria-live="polite">{selectionSummary(counts)}</span>
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => changeSelection(clearSelection())}>清空选择</button>
{stale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshVersions()}>刷新 {stale.length} 人的状态并重新预览</button>}
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => void requestPreview()}>预览分配</button>
<button className="lg-submit ad-create-btn" disabled={busy || !ready} onClick={() => void confirm()}>确认添加</button>
</div>
</div>
</Collapsible>}
</section>
}
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<RoleRecord | null>(null)
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [catalogFailed, setCatalogFailed] = useState(false)
@@ -411,7 +605,9 @@ export function RolePermissionsPanel() {
<th scope="row"><div className="rp-role-cell">
<button className="rp-role-name" disabled={busy || roleLoading} onClick={() => openFocus(role)}>{role.name}</button>
{role.description && <span className="rp-role-desc" title={role.description}>{role.description}</span>}
<span className="ad-dim">{role.bindingCount} 人 · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
<span className="ad-dim">{canViewMembers
? <button className="rp-members-link" aria-label={roleMembersLabel(role)} disabled={busy} onClick={() => setMembersRole(role)}>{role.bindingCount} 人</button>
: `${role.bindingCount} 人`} · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
{!role.enabled && <span className="rp-disabled-note">停用角色不可编辑权限,请先启用</span>}
</div></th>
{columns.map(item => <td key={item.actionId}>{cell(role, item)}</td>)}
@@ -435,6 +631,7 @@ export function RolePermissionsPanel() {
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
{canViewMembers && <button className="ad-mini" aria-label={roleMembersLabel(selected)} disabled={busy} onClick={() => setMembersRole(selected)}>成员 {selected.bindingCount} 人</button>}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
@@ -512,8 +709,8 @@ export function RolePermissionsPanel() {
{categories.map(value => <button key={value} className={`ad-mini ${value === activeCategory ? 'on' : ''}`} aria-pressed={value === activeCategory}
onClick={() => setCategory(value)}>{value}</button>)}
</div>}
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => { setMembersRole(null); setView('matrix') }}>角色矩阵</button>}
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => { setMembersRole(null); setView('single') }}>单角色配置</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>
@@ -525,7 +722,10 @@ export function RolePermissionsPanel() {
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</div>
{roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
{membersRole && canViewMembers
? <RoleMembersView key={membersRole.roleId} role={roles?.find(role => role.roleId === membersRole.roleId) ?? membersRole} canAssign={canAssignMembers}
backLabel={effectiveView === 'matrix' ? '返回角色矩阵' : '返回单角色配置'} onBack={() => setMembersRole(null)} onAssigned={() => void load()} />
: roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
{preview && previewPanel(preview)}
{canManagePermissions && dirty && <div className="rp-savebar" role="region" aria-label="未保存的权限修改">
<span className="rp-savebar-summary">已修改 {dirtyList.length} 个角色、{changeCount} 项设置</span>
+141
View File
@@ -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<string> {
const owners = new Map<string, Set<string>>()
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<Record<string, number>>,
): 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<T extends { userId: string; stateVersion: number }>(
client: { adminGetUser: (userId: string) => Promise<T> }, userIds: readonly string[],
): Promise<Record<string, T>> {
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<UserRoleAssignmentPreview, 'currentRoleName' | 'targetRoleName'>) =>
`${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<string, RolePermissionValue> {
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<Record<string, RolePermissionValue>>; 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 })),
}
}
+17
View File
@@ -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; }
+28
View File
@@ -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('软件工程师(已停用)')
})
})
+128
View File
@@ -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> = {}): 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')
})
})
+8 -2
View File
@@ -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')