410 lines
25 KiB
TypeScript
410 lines
25 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||
import {
|
||
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
|
||
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
|
||
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
|
||
} from '../rolePermissions'
|
||
import { Collapsible, Icon } from '@ui'
|
||
import { useStore } from '../store'
|
||
|
||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||
|
||
export function filterRoles(
|
||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||
): RoleRecord[] {
|
||
const normalized = query.trim().toLocaleLowerCase()
|
||
return roles.filter(role => {
|
||
if (status === 'enabled' && !role.enabled) return false
|
||
if (status === 'disabled' && role.enabled) return false
|
||
if (!normalized) return true
|
||
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
|
||
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
|
||
})
|
||
}
|
||
|
||
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
|
||
const grouped = catalog.filter(item => !group || item.group === group)
|
||
return grouped.slice(page * size, page * size + size)
|
||
}
|
||
|
||
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
|
||
return activeSequence === responseSequence
|
||
}
|
||
|
||
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
|
||
return actions?.includes(action) ?? false
|
||
}
|
||
|
||
function roleValues(role: RoleRecord, catalog: 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 }]
|
||
}))
|
||
}
|
||
|
||
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
|
||
|
||
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
|
||
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
|
||
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
|
||
}
|
||
|
||
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
|
||
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
|
||
groups: CapabilityGroup[]
|
||
values: Record<string, RolePermissionValue>
|
||
labels: Record<string, string>
|
||
disabled: boolean
|
||
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
|
||
}) {
|
||
return <div className="rp-groups">{groups.map(group => {
|
||
const state = capabilityGroupState(values, group)
|
||
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
|
||
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
|
||
<div className="ad-row">
|
||
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
|
||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
|
||
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
|
||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
|
||
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
|
||
</div>
|
||
{group.description && <p className="ad-hint">{group.description}</p>}
|
||
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
|
||
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
|
||
</section>
|
||
})}</div>
|
||
}
|
||
|
||
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
|
||
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
|
||
changes: CapabilityGroupChange[]
|
||
breaks: string[]
|
||
labels: Record<string, string>
|
||
roleName: string
|
||
bindingCount: number
|
||
}) {
|
||
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
|
||
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||
return <div>
|
||
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
|
||
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
|
||
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
|
||
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
|
||
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
|
||
</li>)}</ul>
|
||
</div>
|
||
}
|
||
|
||
export function rolePermissionSaveError(error: unknown): string {
|
||
const raw = String(error)
|
||
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
|
||
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
|
||
}
|
||
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
|
||
}
|
||
|
||
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 [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||
const [selectedId, setSelectedId] = useState('')
|
||
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||
const [name, setName] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [query, setQuery] = useState('')
|
||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||
const [actionFilter, setActionFilter] = useState('')
|
||
const [roleCursor, setRoleCursor] = useState('')
|
||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||
const [newName, setNewName] = useState('')
|
||
const [newDescription, setNewDescription] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [roleLoading, setRoleLoading] = useState(false)
|
||
const [notice, setNotice] = useState('')
|
||
const [err, setErr] = useState('')
|
||
const requestSequence = useRef(0)
|
||
const selectedIdRef = useRef('')
|
||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||
selectedIdRef.current = selectedId
|
||
|
||
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
||
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||
const filtered = roles ?? []
|
||
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||
|
||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||
filters = { query, mode, status, action: actionFilter }) => {
|
||
const sequence = ++requestSequence.current
|
||
setRoleLoading(true)
|
||
setErr('')
|
||
const [roleResult, catalogResult] = await Promise.allSettled([
|
||
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
|
||
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
|
||
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
|
||
])
|
||
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||
setRoleLoading(false)
|
||
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||
if (roleResult.status === 'rejected') {
|
||
setErr(String(roleResult.reason))
|
||
return
|
||
}
|
||
const rolePage = roleResult.value
|
||
setRoles(rolePage.roles ?? [])
|
||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||
else setSelectedId('')
|
||
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||
}
|
||
|
||
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||
const nextSequence = sequence ?? ++requestSequence.current
|
||
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
|
||
setSelectedId(role.roleId)
|
||
setName(role.name)
|
||
setDescription(role.description)
|
||
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
|
||
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
|
||
setDrafts(prev => prev[role.roleId] ? prev
|
||
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
|
||
setNotice('')
|
||
setErr('')
|
||
}
|
||
|
||
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||
useEffect(() => {
|
||
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||
return () => { roleDraftLeaveGuard.current = null }
|
||
}, [dirty, selected?.roleId, selected?.name])
|
||
useEffect(() => {
|
||
if (!dirty) return
|
||
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||
window.addEventListener('beforeunload', handler)
|
||
return () => window.removeEventListener('beforeunload', handler)
|
||
}, [dirty])
|
||
|
||
useEffect(() => {
|
||
setNextRoleCursor(''); setRoleLoading(true)
|
||
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
||
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||
|
||
const create = async () => {
|
||
if (!newName.trim()) return
|
||
requestSequence.current++; setRoleLoading(false)
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
const role = await api.createRole(newName.trim(), newDescription.trim())
|
||
setNewName(''); setNewDescription('')
|
||
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||
selectRole(role, catalog, ++requestSequence.current)
|
||
setNotice(`已创建角色 ${role.name}`)
|
||
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||
setNotice(`已创建角色 ${role.name}`)
|
||
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||
}
|
||
|
||
const saveMetadata = async () => {
|
||
if (!selected) return
|
||
const roleId = selected.roleId
|
||
const operationSequence = ++requestSequence.current
|
||
setRoleLoading(false)
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||
await load(); setNotice('角色资料已保存')
|
||
} catch (error) {
|
||
if (String(error).includes('409')) {
|
||
try {
|
||
const fresh = await api.getRole(roleId)
|
||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||
} catch (refreshError) { setErr(String(refreshError)) }
|
||
} else setErr(String(error))
|
||
}
|
||
finally { setBusy(false) }
|
||
}
|
||
|
||
const setEnabled = async () => {
|
||
if (!selected) return
|
||
const roleId = selected.roleId
|
||
const operationSequence = ++requestSequence.current
|
||
setRoleLoading(false)
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
|
||
} catch (error) {
|
||
if (String(error).includes('409')) {
|
||
try {
|
||
const fresh = await api.getRole(roleId)
|
||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
|
||
} catch (refreshError) { setErr(String(refreshError)) }
|
||
} else setErr(String(error))
|
||
} finally { setBusy(false) }
|
||
}
|
||
|
||
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
|
||
const fresh = await api.getRole(roleId)
|
||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||
setRoles(current => current?.some(role => role.roleId === roleId)
|
||
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||
}
|
||
|
||
const savePermissions = async () => {
|
||
if (!selected || !draft) return
|
||
const roleId = selected.roleId
|
||
const operationSequence = ++requestSequence.current
|
||
setRoleLoading(false)
|
||
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
|
||
const prepared = ensureLinkedCapabilities(draft)
|
||
const invalid = validateRolePermissionDraft(prepared)
|
||
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||
setDrafts(current => {
|
||
if (!(roleId in current)) return current
|
||
const next = { ...current }; delete next[roleId]; return next
|
||
})
|
||
await load(roleCursor, true, selected.roleId)
|
||
const expanded = receipt?.linkedExpansion ?? []
|
||
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
|
||
} catch (error) {
|
||
if (String(error).includes('409')) {
|
||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||
} else setErr(rolePermissionSaveError(error))
|
||
}
|
||
finally {
|
||
setBusy(false)
|
||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
|
||
requestAnimationFrame(() => {
|
||
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
|
||
})
|
||
}
|
||
}
|
||
|
||
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
|
||
}
|
||
|
||
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
|
||
const matrix = () => <div className="rp-matrix-wrap">
|
||
<table className="ad-table rp-matrix">
|
||
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
|
||
<tbody>{filtered.map(role => {
|
||
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
|
||
const editable = role.roleId === selectedId && !!draft && canManagePermissions
|
||
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||
{capabilityGroups.map(group => {
|
||
const state = capabilityGroupState(values, group)
|
||
return <td key={group.id}>{editable ? <>
|
||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
|
||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
|
||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
|
||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
|
||
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
|
||
})}
|
||
</tr>
|
||
})}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
return <div className="rp-panel">
|
||
{canManageRoles && <div className="ad-create">
|
||
<div className="ad-row">
|
||
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
|
||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
|
||
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
|
||
</div>
|
||
</div>}
|
||
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
|
||
<div className="ad-row rp-filters">
|
||
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||
</select>
|
||
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||
</select>
|
||
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||
</select>}
|
||
</div>
|
||
</Collapsible>
|
||
<div className="ad-row rp-view-toggle">
|
||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
||
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</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>
|
||
{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>}
|
||
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||
<aside className="rp-role-list">
|
||
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||
</button>)}
|
||
</aside>
|
||
<section className="rp-editor">
|
||
{selected && draft ? <>
|
||
<div className="ad-row">
|
||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||
{canManageRoles && (selected.enabled
|
||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||
</div>
|
||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||
</div>}
|
||
{canViewPermissions ? <>
|
||
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
|
||
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
|
||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
|
||
</Collapsible>
|
||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
|
||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||
</> : <p className="ad-hint">请选择角色</p>}
|
||
</section>
|
||
</div>}
|
||
</div>
|
||
}
|