This commit is contained in:
ryan
2026-06-08 08:19:22 +08:00
parent 4b72419a96
commit 9d0f9f0576
42 changed files with 1412 additions and 809 deletions
@@ -0,0 +1,160 @@
"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: "",
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.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,
})
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="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>
)
}
+379
View File
@@ -0,0 +1,379 @@
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react"
import {
Download,
FileArchive,
FileAudio,
FileImage,
FileText,
FileVideo,
Loader2,
Search,
Trash2,
Upload,
X,
} from "lucide-react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-8") {
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
/* ─── 文件管理主组件 ────────────────────────────────────── */
export function FilesMain() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [page, setPage] = React.useState(1)
const pageSize = 24
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
// 文件列表查询
const listQuery = useQuery({
queryKey: ["files", "my", page, pageSize, debouncedKeyword],
queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined),
})
const files = listQuery.data?.items ?? []
const total = listQuery.data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
// 删除单文件
const deleteMutation = useMutation({
mutationFn: (id: string) => UploadService.deleteFile(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
toast.success("文件已删除")
setDeleteTarget(null)
},
onError: (err: Error) => toast.error(err.message || "删除失败"),
})
// 批量 ZIP 下载
const batchDownloadMutation = useMutation({
mutationFn: (ids: string[]) => UploadService.batchDownload(ids),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "batch_download.zip"
a.click()
URL.revokeObjectURL(url)
toast.success("批量下载已开始")
},
onError: () => toast.error("批量下载失败"),
})
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const clearSelection = () => setSelectedIds(new Set())
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
const handleDownload = (file: UploadRecord) => {
const url = UploadService.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="py-6 space-y-6 max-w-6xl mx-auto"
>
{/* 头部 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-5">
<div className="flex items-center gap-4">
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
文件管理
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的所有文件,支持下载 and 批量操作
</p>
</div>
</div>
{/* 操作按钮区 */}
<div className="flex items-center gap-2 shrink-0">
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2"
>
<Badge variant="secondary" className="text-xs px-2.5">
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
disabled={batchDownloadMutation.isPending}
>
{batchDownloadMutation.isPending ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<FileArchive className="size-3.5 mr-1" />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
onClick={clearSelection}
>
<X className="size-3.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{keyword && (
<button
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setKeyword("")}
>
<X className="size-3" />
</button>
)}
</div>
{files.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-xs h-8 border border-dashed text-muted-foreground"
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
>
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
</Button>
)}
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
)}
</div>
{/* 文件网格 */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground">
<Upload className="size-12 text-muted-foreground/30" />
<p className="text-sm">
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{files.map((file, idx) => {
const isSelected = selectedIds.has(file.id)
return (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: idx * 0.02 }}
className={`group relative rounded-xl border border-dashed p-3 flex flex-col gap-2 cursor-pointer transition-all duration-200 hover:bg-muted/40 hover:shadow-sm select-none ${
isSelected
? "bg-sky-500/8 border-sky-500/40 ring-1 ring-sky-500/30"
: "bg-card hover:border-muted-foreground/25"
}`}
onClick={() => toggleSelect(file.id)}
>
{/* 选中指示 */}
{isSelected && (
<div className="absolute top-2 left-2 size-4 rounded-full bg-sky-500 flex items-center justify-center z-10">
<svg className="size-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{/* 文件图标 / 图片预览 */}
<div className="flex items-center justify-center h-14 rounded-lg bg-muted/30 overflow-hidden">
{file.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/f/${file.id}`}
alt={file.file_name}
className="h-full w-full object-cover rounded-lg"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
getFileIcon(file.mime_type)
)}
</div>
{/* 文件名 */}
<p className="text-[10px] font-medium leading-tight text-foreground/80 break-all line-clamp-2 text-center">
{file.file_name}
</p>
{/* 元数据 */}
<div className="text-[10px] text-muted-foreground text-center space-y-0.5">
<p>{formatFileSize(file.file_size)}</p>
<p className="truncate">{formatDate(file.created_at)}</p>
</div>
{/* Hover 操作按钮 */}
<div
className="absolute inset-x-2 bottom-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Button
size="icon"
variant="secondary"
className="h-6 w-6 rounded-md flex-1"
title="下载"
onClick={() => handleDownload(file)}
>
<Download className="size-3" />
</Button>
<Button
size="icon"
variant="secondary"
className="h-6 w-6 rounded-md text-destructive hover:bg-destructive/10"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3" />
</Button>
</div>
</motion.div>
)
})}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
)}
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
)
}
+39 -22
View File
@@ -1,25 +1,38 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Separator} from "@/components/ui/separator"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Layers,
Loader2,
Plus,
Search,
ShieldCheck,
UserCheck,
UserX,
X
} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import { AdminUser, AdminService } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ErrorInline } from "@/components/layout/error"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useAdminUsers } from "@/contexts/admin-users-context"
import {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./create-user-modal"
export function UsersManager() {
const {
@@ -43,6 +56,7 @@ export function UsersManager() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
fetchUsers()
@@ -277,6 +291,10 @@ export function UsersManager() {
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增用户
</Button>
</div>
{renderFilterBar()}
@@ -409,9 +427,6 @@ export function UsersManager() {
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
@@ -482,6 +497,8 @@ export function UsersManager() {
)}
</SheetContent>
</Sheet>
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
}