mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
upload+accessKey
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {motion} from "motion/react"
|
||||
import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {UserService} from "@/lib/services"
|
||||
import type {CreateTokenResponse} from "@/lib/services/user"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function AccessTokenMain() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||||
const [tokenName, setTokenName] = React.useState("")
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null)
|
||||
const [newCreatedToken, setNewCreatedToken] = React.useState<CreateTokenResponse | null>(null)
|
||||
|
||||
// 获取 Token 列表
|
||||
const accessTokensQuery = useQuery({
|
||||
queryKey: ["user", "access-tokens"],
|
||||
queryFn: () => UserService.getAccessTokens(),
|
||||
})
|
||||
|
||||
// 创建 Token
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: (name: string) => UserService.createAccessToken(name),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setTokenName("")
|
||||
setCreateDialogOpen(false)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌创建成功")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 删除 Token
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.deleteAccessToken(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌已撤销")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 轮换 Token
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.rotateAccessToken(id),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌轮换成功,旧密钥已失效")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "轮换访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!tokenName.trim()) {
|
||||
toast.error("请输入令牌名称")
|
||||
return
|
||||
}
|
||||
createTokenMutation.mutate(tokenName.trim())
|
||||
}
|
||||
|
||||
const handleDeleteToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
|
||||
deleteTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
|
||||
rotateTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyText = async (text: string, id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
toast.success("复制成功")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "未使用"
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
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-4xl mx-auto px-4"
|
||||
>
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-lg shadow-indigo-500/20">
|
||||
<Key className="size-6" />
|
||||
</div>
|
||||
<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">个人访问令牌 (AccessToken)</h1>
|
||||
<p className="text-sm text-muted-foreground">管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors shrink-0"
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
生成新令牌
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 安全警告提示 */}
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed">
|
||||
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">安全提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<CardTitle className="text-base font-semibold">活动令牌列表</CardTitle>
|
||||
<CardDescription className="text-xs">当前可用的所有访问令牌</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(accessTokensQuery.data ?? []).map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{token.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all">
|
||||
{token.masked_token}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pt-1">
|
||||
<span>创建于: {formatDate(token.created_at)}</span>
|
||||
<span>最后使用: {formatDate(token.last_used_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 sm:self-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
<Check className="size-3.5 mr-1 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
)}
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`} />
|
||||
轮换
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleDeleteToken(token.id, token.name)}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<Key className="size-8 text-muted-foreground/30" />
|
||||
<span>您当前暂无生成任何访问令牌</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
生成第一个令牌
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建令牌 Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleCreateToken}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name" className="text-xs font-semibold">令牌名称</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="例如:my-development-key"
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl text-xs h-9 border-dashed"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs h-9"
|
||||
>
|
||||
{createTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
正在生成...
|
||||
</>
|
||||
) : (
|
||||
"生成令牌"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 明文 Token 显示 Dialog (仅显示一次) */}
|
||||
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-1.5">
|
||||
<Check className="size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5" />
|
||||
令牌密钥已就绪
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 强提示 */}
|
||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">重要提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
为了系统安全性,数据库中仅存储令牌的 Hash 摘要值,系统本身无法为您找回此明文密钥。离开此窗口后,您将再也无法查看到它的明文值。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs h-9 w-full"
|
||||
>
|
||||
我已经复制并妥善保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {
|
||||
Download,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
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"
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">文件管理</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
{/* 头部 */}
|
||||
<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 className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-500 to-indigo-600 text-white shadow-lg shadow-sky-500/20">
|
||||
<Folder className="size-6" />
|
||||
</div>
|
||||
<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">
|
||||
管理您上传的所有文件,支持下载和批量操作
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function SecurityMain() {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors"
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增认证源
|
||||
|
||||
Reference in New Issue
Block a user