refactor(frontend): colocate admin components and split service layer

Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
This commit is contained in:
ryan
2026-06-18 10:55:14 +08:00
parent de8a21a49f
commit beae8d2dd9
56 changed files with 594 additions and 888 deletions
@@ -0,0 +1,182 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, 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 {useAdminUsers} from "@/contexts/admin-users-context"
import type {CreateUserRequest} from "@/lib/services/admin"
const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
email: "",
is_active: true,
is_admin: false,
}
export function CreateUserModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const { createUser } = useAdminUsers()
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (isOpen) {
setForm(emptyForm)
setErrors({})
} else {
setSaving(false)
}
}, [isOpen])
const validate = () => {
const newErrors: Record<string, string> = {}
if (!form.username.trim()) {
newErrors.username = "用户名不能为空"
} else if (form.username.trim().length < 3) {
newErrors.username = "用户名长度不能少于 3 位"
}
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
}
if (!form.password) {
newErrors.password = "密码不能为空"
} else if (form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setSaving(true)
try {
await createUser({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
email: form.email.trim(),
})
onClose()
} catch {
// Errors are already handled by context toast notifications
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="请输入用户名 (至少 3 位)"
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<Input
id="nickname"
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="请输入登录密码 (至少 8 位)"
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">启用账户</div>
<div className="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">管理员权限</div>
<div className="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
</div>
<Switch
checked={form.is_admin}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "创建中..." : "创建"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,198 @@
"use client"
import * as React from "react"
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import type {AdminUser} from "@/lib/services/admin"
import {cn, formatDateTime} from "@/lib/utils"
interface UserDetailSheetProps {
selectedUser: AdminUser | null
isOpen: boolean
onOpenChange: (open: boolean) => void
detailLoading: boolean
onStatusToggle: (user: AdminUser) => Promise<void>
onDeleteTarget: (user: AdminUser) => void
}
export function UserDetailSheet({
selectedUser,
isOpen,
onOpenChange,
detailLoading,
onStatusToggle,
onDeleteTarget,
}: UserDetailSheetProps) {
const displayValue = (value?: string) => value && value.trim() ? value : "-"
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
{detailLoading && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在刷新详情
</div>
)}
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Mail className="size-3" />
邮箱
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Smartphone className="size-3" />
手机
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="size-3 flex items-center justify-center font-bold text-[9px]">⚧</span>
性别
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<MapPin className="size-3" />
所在地
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Globe className="size-3" />
网站
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
</div>
<div className="flex flex-col gap-2 p-3.5 text-sm">
<span className="text-[10px] text-muted-foreground">简介</span>
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">账户状态</span>
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
{selectedUser.is_active ? "正常" : "禁用"}
</Badge>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">管理员</span>
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => onStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
<Button
variant="outline"
className="w-full h-9 text-xs font-medium"
onClick={() => onDeleteTarget(selectedUser)}
>
<Trash2 className="size-3 mr-1" />
删除用户
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,237 @@
"use client"
import * as React from "react"
import {ChevronDown, ChevronLeft, ChevronRight, Filter, Loader2, Search, X} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Separator} from "@/components/ui/separator"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {cn} from "@/lib/utils"
export function UserFilterBar() {
const {
total,
loading,
page,
pageSize,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers
} = useAdminUsers()
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
}