Files
booschat/dashboard/app/(dashboard)/users/_components/create.tsx
T
2026-04-15 17:20:57 +08:00

375 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { SearchIcon, ShieldAlertIcon, ShieldCheckIcon } from "lucide-react"
import { z } from "zod/v4"
import {
fetchRoleListAll,
type AdminRole,
type CreateAdminUserPayload,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
type CreateUserDrawerProps = {
open: boolean
saving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminUserPayload) => Promise<void>
}
const createFormSchema = z.object({
username: z.string().trim().min(1, "用户名不能为空"),
nickname: z.string().trim(),
avatar: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^https?:\/\/\S+$/i.test(value),
"头像地址必须是 http 或 https 链接"
),
mobile: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^[0-9+\-\s]{6,20}$/.test(value),
"手机号格式不正确"
),
email: z
.string()
.trim()
.refine(
(value) =>
value.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
"邮箱格式不正确"
),
remark: z.string().trim(),
roleIds: z.array(z.number().int().positive()),
})
type CreateForm = z.infer<typeof createFormSchema>
const emptyForm: CreateForm = {
username: "",
nickname: "",
avatar: "",
mobile: "",
email: "",
remark: "",
roleIds: [],
}
const createFormResolver = zodResolver(createFormSchema as never) as Resolver<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>
function toNullableString(value: string) {
const output = value.trim()
return output ? output : null
}
function buildPayload(form: CreateForm): CreateAdminUserPayload {
return {
username: form.username.trim(),
nickname: form.nickname.trim(),
avatar: form.avatar.trim(),
mobile: toNullableString(form.mobile),
email: toNullableString(form.email),
remark: form.remark.trim(),
roleIds: form.roleIds,
}
}
export function CreateUserDrawer({
open,
saving,
onOpenChange,
onSubmit,
}: CreateUserDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
{open ? (
<CreateUserDrawerBody
key="create-user"
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Drawer>
)
}
type CreateUserDrawerBodyProps = {
saving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminUserPayload) => Promise<void>
}
function CreateUserDrawerBody({
saving,
onOpenChange,
onSubmit,
}: CreateUserDrawerBodyProps) {
const [rolesLoading, setRolesLoading] = useState(true)
const [roles, setRoles] = useState<AdminRole[]>([])
const [roleKeyword, setRoleKeyword] = useState("")
const form = useForm<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>({
resolver: createFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
register,
reset,
formState: { errors },
} = form
useEffect(() => {
async function loadRoles() {
setRolesLoading(true)
try {
const list = await fetchRoleListAll()
setRoles(list)
} catch {
setRoles([])
} finally {
setRolesLoading(false)
}
}
void loadRoles()
}, [])
const filteredRoles = useMemo(() => {
const q = roleKeyword.trim().toLowerCase()
if (!q) {
return roles
}
return roles.filter((role) =>
`${role.name} ${role.code}`.toLowerCase().includes(q)
)
}, [roleKeyword, roles])
async function onFormSubmit(values: CreateForm) {
await onSubmit(buildPayload(values))
reset(emptyForm)
setRoleKeyword("")
}
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle>添加用户</DrawerTitle>
<DrawerDescription>
提交后由系统生成初始密码,并仅展示一次,请妥善保存。
</DrawerDescription>
</DrawerHeader>
<form
className="flex h-full flex-col"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.username}>
<FieldLabel htmlFor="create-username">用户名</FieldLabel>
<FieldContent>
<Input
id="create-username"
placeholder="登录名,必填"
autoComplete="off"
aria-invalid={!!errors.username}
{...register("username")}
/>
<FieldError errors={[errors.username]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.nickname}>
<FieldLabel htmlFor="create-nickname">昵称</FieldLabel>
<FieldContent>
<Input
id="create-nickname"
placeholder="可选,默认同用户名"
aria-invalid={!!errors.nickname}
{...register("nickname")}
/>
<FieldError errors={[errors.nickname]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.avatar}>
<FieldLabel htmlFor="create-avatar">头像地址</FieldLabel>
<FieldContent>
<Input
id="create-avatar"
placeholder="可选,http(s) 链接"
aria-invalid={!!errors.avatar}
{...register("avatar")}
/>
<FieldError errors={[errors.avatar]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor="create-mobile">手机号</FieldLabel>
<FieldContent>
<Input
id="create-mobile"
placeholder="可选"
aria-invalid={!!errors.mobile}
{...register("mobile")}
/>
<FieldError errors={[errors.mobile]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="create-email">邮箱</FieldLabel>
<FieldContent>
<Input
id="create-email"
placeholder="可选"
aria-invalid={!!errors.email}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-remark">备注</FieldLabel>
<FieldContent>
<Input
id="create-remark"
placeholder="可选"
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.roleIds}>
<FieldLabel>角色(可选)</FieldLabel>
<FieldContent>
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={roleKeyword}
onChange={(event) => setRoleKeyword(event.target.value)}
placeholder="搜索角色"
className="pl-9"
disabled={rolesLoading}
/>
</div>
<Controller
control={control}
name="roleIds"
render={({ field }) => {
const value = field.value || []
const selectedSet = new Set(value)
return (
<div className="mt-2 max-h-[240px] space-y-1 overflow-y-auto rounded-lg border p-2">
{rolesLoading ? (
<div className="py-6 text-center text-sm text-muted-foreground">
正在加载角色...
</div>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => {
const checked = selectedSet.has(role.id)
const disabled = role.status !== Status.Ok && !checked
return (
<label
key={role.id}
className={cn(
"flex items-center gap-2 rounded-md border px-2.5 py-2 text-sm transition-colors",
disabled
? "cursor-not-allowed border-dashed bg-muted/20 opacity-70"
: "cursor-pointer hover:bg-muted/50",
checked && "border-primary/40 bg-primary/5"
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(nextChecked) => {
if (nextChecked) {
field.onChange([...value, role.id])
return
}
field.onChange(
value.filter(
(id: number) => id !== role.id
)
)
}}
/>
<span className="flex min-w-0 flex-1 items-center gap-2">
{role.status === Status.Ok ? (
<ShieldCheckIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ShieldAlertIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{role.name}</span>
{role.status !== Status.Ok ? (
<Badge variant="outline" className="text-xs">
已禁用
</Badge>
) : null}
</span>
</label>
)
})
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
暂无角色
</div>
)}
</div>
)
}}
/>
<FieldError errors={[errors.roleIds]} />
</FieldContent>
</Field>
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || rolesLoading}>
{saving ? "创建中..." : "创建用户"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
</Button>
</DrawerFooter>
</form>
</DrawerContent>
)
}