263 lines
9.0 KiB
TypeScript
263 lines
9.0 KiB
TypeScript
import * as React from 'react'
|
||
|
||
import { AppField } from '@/components/app/AppField'
|
||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||
import { AppFilterBar } from '@/components/app/AppFilterBar'
|
||
import { AppPage } from '@/components/app/AppPage'
|
||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Switch } from '@/components/ui/switch'
|
||
|
||
import { createUser, listRoles, listUserGroups, listUsers, setUserGroups, setUserRoles, updateUser } from './api'
|
||
import type { RoleDetail, SystemUser, UserGroup } from './types'
|
||
|
||
type UserForm = {
|
||
username: string
|
||
display_name: string
|
||
email: string
|
||
password: string
|
||
}
|
||
|
||
const emptyForm: UserForm = {
|
||
username: '',
|
||
display_name: '',
|
||
email: '',
|
||
password: ''
|
||
}
|
||
|
||
export function SystemUsersPage() {
|
||
const [users, setUsers] = React.useState<SystemUser[]>([])
|
||
const [roles, setRoles] = React.useState<RoleDetail[]>([])
|
||
const [groups, setGroups] = React.useState<UserGroup[]>([])
|
||
const [query, setQuery] = React.useState('')
|
||
const [form, setForm] = React.useState<UserForm>(emptyForm)
|
||
const [editingUser, setEditingUser] = React.useState<SystemUser | null>(null)
|
||
const [error, setError] = React.useState('')
|
||
|
||
const load = React.useCallback(async () => {
|
||
try {
|
||
const [nextUsers, nextRoles, nextGroups] = await Promise.all([listUsers(), listRoles(), listUserGroups()])
|
||
setUsers(nextUsers)
|
||
setRoles(nextRoles)
|
||
setGroups(nextGroups)
|
||
setError('')
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : '加载失败')
|
||
}
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
void load()
|
||
}, [load])
|
||
|
||
const filteredUsers = users.filter((user) => {
|
||
const keyword = query.trim().toLowerCase()
|
||
if (!keyword) return true
|
||
return `${user.username} ${user.display_name} ${user.email}`.toLowerCase().includes(keyword)
|
||
})
|
||
|
||
async function handleCreateUser(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
await createUser({ ...form, is_active: true })
|
||
setForm(emptyForm)
|
||
await load()
|
||
}
|
||
|
||
async function toggleUserActive(user: SystemUser, checked: boolean) {
|
||
await updateUser(user.id, { is_active: checked })
|
||
await load()
|
||
}
|
||
|
||
const columns: AppDataTableColumn<SystemUser>[] = [
|
||
{
|
||
key: 'user',
|
||
header: '用户',
|
||
cell: (user) => (
|
||
<div>
|
||
<p className="font-medium text-foreground">{user.display_name || user.username}</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">{user.username}</p>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'email',
|
||
header: '邮箱',
|
||
cell: (user) => <span className="text-muted-foreground">{user.email || '-'}</span>
|
||
},
|
||
{
|
||
key: 'roles',
|
||
header: '角色',
|
||
cell: (user) => (
|
||
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||
{user.roles.length ? user.roles.map((role) => <Badge key={role.id} variant="secondary">{role.name}</Badge>) : '-'}
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'groups',
|
||
header: '用户组',
|
||
cell: (user) => (
|
||
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||
{user.user_groups.length
|
||
? user.user_groups.map((group) => <Badge key={group.id} variant="info">{group.name}</Badge>)
|
||
: '-'}
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'active',
|
||
header: '状态',
|
||
cell: (user) => (
|
||
<Switch
|
||
checked={user.is_active}
|
||
onClick={(event) => event.stopPropagation()}
|
||
onCheckedChange={(checked) => void toggleUserActive(user, checked)}
|
||
/>
|
||
)
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: '操作',
|
||
cell: (user) => (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
setEditingUser(user)
|
||
}}
|
||
>
|
||
授权
|
||
</Button>
|
||
)
|
||
}
|
||
]
|
||
|
||
return (
|
||
<AppPage>
|
||
<AppPageHeader title="用户管理" />
|
||
|
||
<form className="grid gap-3 rounded-lg border border-border bg-surface p-3 md:grid-cols-[1fr_1fr_1fr_1fr_auto] md:items-end" onSubmit={handleCreateUser}>
|
||
<AppField label="账号" htmlFor="system-user-username" required>
|
||
<Input id="system-user-username" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} required />
|
||
</AppField>
|
||
<AppField label="显示名称" htmlFor="system-user-display-name">
|
||
<Input id="system-user-display-name" value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} />
|
||
</AppField>
|
||
<AppField label="邮箱" htmlFor="system-user-email">
|
||
<Input id="system-user-email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
|
||
</AppField>
|
||
<AppField label="初始密码" htmlFor="system-user-password" required>
|
||
<Input id="system-user-password" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} required />
|
||
</AppField>
|
||
<Button type="submit" className="w-full md:w-auto">创建用户</Button>
|
||
</form>
|
||
|
||
<AppFilterBar>
|
||
<Input placeholder="搜索账号、姓名或邮箱" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||
</AppFilterBar>
|
||
|
||
{error ? <AppEmptyState title="无法加载用户" description={error} /> : null}
|
||
{!error ? (
|
||
<AppDataTable
|
||
columns={columns}
|
||
rows={filteredUsers}
|
||
getRowKey={(user) => user.id}
|
||
emptyTitle="暂无用户"
|
||
footer={<p className="text-sm text-muted-foreground">当前显示 {filteredUsers.length} 个用户</p>}
|
||
/>
|
||
) : null}
|
||
|
||
<UserAccessDialog
|
||
user={editingUser}
|
||
roles={roles}
|
||
groups={groups}
|
||
onClose={() => setEditingUser(null)}
|
||
onSaved={load}
|
||
/>
|
||
</AppPage>
|
||
)
|
||
}
|
||
|
||
function UserAccessDialog({
|
||
user,
|
||
roles,
|
||
groups,
|
||
onClose,
|
||
onSaved
|
||
}: {
|
||
user: SystemUser | null
|
||
roles: RoleDetail[]
|
||
groups: UserGroup[]
|
||
onClose: () => void
|
||
onSaved: () => Promise<void>
|
||
}) {
|
||
const [roleIds, setRoleIds] = React.useState<number[]>([])
|
||
const [groupIds, setGroupIds] = React.useState<number[]>([])
|
||
|
||
React.useEffect(() => {
|
||
setRoleIds(user?.roles.map((role) => role.id) ?? [])
|
||
setGroupIds(user?.user_groups.map((group) => group.id) ?? [])
|
||
}, [user])
|
||
|
||
async function handleSave() {
|
||
if (!user) return
|
||
await Promise.all([setUserRoles(user.id, roleIds), setUserGroups(user.id, groupIds)])
|
||
await onSaved()
|
||
onClose()
|
||
}
|
||
|
||
return (
|
||
<Dialog open={Boolean(user)} onOpenChange={(open) => !open && onClose()}>
|
||
<DialogContent className="sm:max-w-xl">
|
||
<DialogHeader>
|
||
<DialogTitle>用户授权{user ? `:${user.display_name || user.username}` : ''}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label>角色</Label>
|
||
<div className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
|
||
{roles.map((role) => (
|
||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||
<Checkbox
|
||
checked={roleIds.includes(role.id)}
|
||
onCheckedChange={(checked) =>
|
||
setRoleIds((current) => checked ? [...current, role.id] : current.filter((id) => id !== role.id))
|
||
}
|
||
/>
|
||
<span>{role.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label>用户组</Label>
|
||
<div className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
|
||
{groups.map((group) => (
|
||
<label key={group.id} className="flex items-center gap-2 text-sm">
|
||
<Checkbox
|
||
checked={groupIds.includes(group.id)}
|
||
onCheckedChange={(checked) =>
|
||
setGroupIds((current) => checked ? [...current, group.id] : current.filter((id) => id !== group.id))
|
||
}
|
||
/>
|
||
<span>{group.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||
<Button onClick={() => void handleSave()}>保存授权</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|