agent-exec transport 5be3a44afded25495039416fcd4551c0/39cdc052739e
This commit is contained in:
+41
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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('软件工程师(已停用)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user