364 lines
24 KiB
TypeScript
364 lines
24 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'
|
||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||
import {
|
||
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
|
||
} from '../rolePermissions'
|
||
|
||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||
|
||
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: '备注' },
|
||
]
|
||
|
||
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
|
||
account.passwordSet ? '已设置密码' : '待设置密码'
|
||
|
||
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 IdentityChip({ account }: { account: IdentityFlags }) {
|
||
const kind = accountIdentityKind(account)
|
||
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||
return <span className="ad-chip">普通用户</span>
|
||
}
|
||
|
||
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||
}
|
||
|
||
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||
const change = accountActionPolicy(me, account).adminChange
|
||
if (!change) return null
|
||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||
}
|
||
|
||
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
|
||
const held = group.actions.filter(id => grants.includes(id)).length
|
||
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
|
||
}
|
||
|
||
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
|
||
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
|
||
}
|
||
|
||
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
|
||
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
|
||
catalog: PermissionDescriptor[]
|
||
grants: string[]
|
||
disabled: boolean
|
||
onChange: (grants: string[]) => void
|
||
}) {
|
||
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
|
||
return <div className="rp-grants">{groups.map(group => {
|
||
const state = grantLevel(grants, group)
|
||
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
|
||
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
|
||
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
|
||
{group.label}{state === 'mixed' ? '(部分)' : ''}
|
||
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
|
||
</label>
|
||
})}</div>
|
||
}
|
||
|
||
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
|
||
export function GrantChangePreview({ baseline, grants, labels, owner }: {
|
||
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
|
||
}) {
|
||
const added = grants.filter(id => !baseline.includes(id))
|
||
const removed = baseline.filter(id => !grants.includes(id))
|
||
if (added.length === 0 && removed.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">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
|
||
<ul className="rp-diff">
|
||
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
|
||
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
|
||
</ul>
|
||
</div>
|
||
}
|
||
|
||
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
|
||
export function ProjectCreateSourceNote({ role, grants }: {
|
||
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
|
||
}) {
|
||
const sources = projectCreateSources(role, grants)
|
||
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||
}
|
||
|
||
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
|
||
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
|
||
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
|
||
if (!role || role.permissions) return role
|
||
return roles.find(item => item.roleId === role.roleId) ?? role
|
||
}
|
||
|
||
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 [grantBaseline, setGrantBaseline] = useState<string[]>([])
|
||
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||
const [newOwner, setNewOwner] = useState('')
|
||
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||
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 labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||
|
||
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||
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(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
|
||
}
|
||
}
|
||
const resetPaging = () => { setCursor(''); setTrail([]) }
|
||
|
||
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) {
|
||
const actions = [...(grantsResult.value.actions ?? [])].sort()
|
||
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||
} else { setGrants([]); setGrantBaseline([]); 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 () => {
|
||
resetPaging(); setNextCursor('')
|
||
await search('', true)
|
||
}
|
||
|
||
const create = async () => {
|
||
if (!newOwner.trim()) return
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||
await open(account)
|
||
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||
await refreshFirst()
|
||
} 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('')
|
||
const userId = selected.userId
|
||
try {
|
||
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
|
||
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
|
||
const expanded = result.linkedExpansion ?? []
|
||
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
|
||
} catch (error) {
|
||
const raw = String(error)
|
||
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
|
||
else if (raw.includes('409')) {
|
||
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
|
||
try {
|
||
const fresh = await api.getSystemGrants(userId)
|
||
const server = [...(fresh.actions ?? [])].sort()
|
||
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
|
||
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
|
||
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
|
||
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
|
||
}
|
||
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); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
|
||
await refreshFirst()
|
||
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
|
||
finally { setBusy(false) }
|
||
}
|
||
|
||
const setAdmin = async (isAdmin: boolean) => {
|
||
if (!selected) return
|
||
setBusy(true); setErr(''); setNotice('')
|
||
try {
|
||
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||
finally { setBusy(false) }
|
||
}
|
||
|
||
const policy = selected ? accountActionPolicy(me, selected) : null
|
||
|
||
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>
|
||
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||
<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 && policy ? <div className="rp-editor">
|
||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||
{allowed('system.users.edit') && policy.maintain && <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 && policy.maintain && <><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') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||
</div>
|
||
{canAssignRoles && policy.maintain && <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>}
|
||
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
|
||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||
{canManageGrants && policy.maintain && <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); resetPaging() }} />
|
||
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
|
||
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
|
||
<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); 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><IdentityChip account={account} /></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>
|
||
<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>
|
||
}
|