232 lines
15 KiB
TypeScript
232 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
|
|
} from '../api'
|
|
import { useStore } from '../store'
|
|
import { Collapsible, Icon } from '@ui'
|
|
import { hasSystemAction } from './RolePermissionsPanel'
|
|
|
|
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
|
const fields: { key: keyof AccountProfile; label: string }[] = [
|
|
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
|
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
|
]
|
|
|
|
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
|
|
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
|
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
|
|
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
|
|
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
|
|
</div>)}</div>
|
|
}
|
|
|
|
export function AccountUsersPanel() {
|
|
const me = useStore(state => state.me)
|
|
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
|
const canViewRoles = allowed('system.roles.view')
|
|
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
|
|
const canViewGrants = allowed('system.permissions.view')
|
|
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
|
|
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
|
|
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
|
|
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
|
|
const [roles, setRoles] = useState<RoleRecord[]>([])
|
|
const [roleQuery, setRoleQuery] = useState('')
|
|
const [roleCursor, setRoleCursor] = useState('')
|
|
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
|
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
|
const [roleLoading, setRoleLoading] = useState(false)
|
|
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
|
const [grants, setGrants] = useState<string[]>([])
|
|
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
|
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
|
const [newOwner, setNewOwner] = useState('')
|
|
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
|
const [query, setQuery] = useState('')
|
|
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
|
const [field, setField] = useState('default')
|
|
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
|
const [cursor, setCursor] = useState('')
|
|
const [trail, setTrail] = useState<string[]>([])
|
|
const [nextCursor, setNextCursor] = useState('')
|
|
const [selectedRoleId, setSelectedRoleId] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [notice, setNotice] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const sequence = useRef(0)
|
|
const roleSequence = useRef(0)
|
|
|
|
const search = async (pageCursor = cursor) => {
|
|
const request = ++sequence.current
|
|
try {
|
|
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
|
|
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
|
|
if (request !== sequence.current) return
|
|
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
|
|
} catch (error) { if (request === sequence.current) setErr(String(error)) }
|
|
}
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => { void search() }, 180)
|
|
return () => { window.clearTimeout(timer); sequence.current++ }
|
|
}, [query, mode, field, status, cursor])
|
|
|
|
const open = async (account: ManagedAccountRecord) => {
|
|
const request = ++sequence.current
|
|
roleSequence.current++
|
|
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
|
|
setSelected(null); setRoleView(null); setErr(''); setNotice('')
|
|
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 }
|
|
const detail = detailResult[0].value
|
|
setSelected(detail); setDraft(detail.profile)
|
|
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
|
|
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
|
|
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
|
|
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
|
|
])
|
|
if (request !== sequence.current) return
|
|
if (roleResult.status === 'fulfilled' && roleResult.value) {
|
|
const currentRole = roleResult.value as UserRoleView
|
|
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
|
|
} else { setRoleView(null); setSelectedRoleId('') }
|
|
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
|
else setCatalog([])
|
|
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
|
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
|
} else { setGrants([]); setAuthorityEpoch(0) }
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
|
|
const request = ++roleSequence.current
|
|
setRoleLoading(true); setNextRoleCursor('')
|
|
const timer = window.setTimeout(() => {
|
|
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
|
|
.then(page => {
|
|
if (request !== roleSequence.current) return
|
|
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
|
|
})
|
|
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
|
|
}, 180)
|
|
return () => { window.clearTimeout(timer) }
|
|
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
|
|
|
|
const refreshFirst = async () => {
|
|
setCursor(''); setTrail([]); setNextCursor('')
|
|
await search('')
|
|
}
|
|
|
|
const create = async () => {
|
|
if (!newOwner.trim()) return
|
|
setBusy(true); setErr(''); setNotice('')
|
|
try {
|
|
const account = await api.createManagedUser(newOwner.trim(), newProfile)
|
|
setNewOwner(''); setNewProfile(emptyProfile())
|
|
await refreshFirst(); await open(account)
|
|
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
|
|
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
const saveProfile = async () => {
|
|
if (!selected) return
|
|
const patch: Partial<AccountProfile> = {}
|
|
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
|
|
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
|
|
setBusy(true); setErr(''); setNotice('')
|
|
try {
|
|
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
|
|
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
|
|
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
const assignRole = async () => {
|
|
if (!selected || !selectedRoleId) return
|
|
setBusy(true); setErr(''); setNotice('')
|
|
try {
|
|
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
|
|
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
|
|
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
const saveGrants = async () => {
|
|
if (!selected) return
|
|
setBusy(true); setErr(''); setNotice('')
|
|
try {
|
|
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
|
|
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
|
|
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
const toggleState = async () => {
|
|
if (!selected) return
|
|
setBusy(true); setErr(''); setNotice('')
|
|
try {
|
|
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
|
|
setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用')
|
|
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
|
}
|
|
|
|
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
|
return <div>
|
|
{allowed('system.users.create') && <div className="ad-create">
|
|
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
|
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
|
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
|
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
|
</Collapsible>
|
|
</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>}
|
|
{selected ? <div className="rp-editor">
|
|
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
|
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
|
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
|
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
|
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
|
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
|
</Collapsible>}
|
|
<div className="ad-row">
|
|
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
|
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
|
</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>}
|
|
{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
|
|
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
|
|
</label>)}</div>
|
|
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
|
</Collapsible>}
|
|
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
|
</div> : <>
|
|
<div className="ad-row rp-filters">
|
|
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
|
|
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => setMode(event.target.value as typeof mode)}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
|
|
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => setField(event.target.value)}>
|
|
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
|
|
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
|
|
</select>
|
|
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => setStatus(event.target.value as typeof status)}><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></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.enabled ? '有效' : '停用'}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></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>
|
|
</>}
|
|
</div>
|
|
}
|