mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 07:06:36 +08:00
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:
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react"
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
|
||||
interface TrendData {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface BrowserData {
|
||||
browser: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TopUserData {
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "请求量",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
// Format YYYY-MM-DD to MM/DD
|
||||
function formatDateLabel(dateStr: string) {
|
||||
if (!dateStr || dateStr.length < 10) return dateStr
|
||||
const parts = dateStr.split("-")
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
export function AccessAnalytics() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
|
||||
|
||||
const [trend, setTrend] = useState<TrendData[]>([])
|
||||
const [browsers, setBrowsers] = useState<BrowserData[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUserData[]>([])
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await AdminService.getLogsAnalytics()
|
||||
|
||||
// Formats the date labels for the X-axis representation
|
||||
const formattedTrend = (data.trend || []).map(item => ({
|
||||
...item,
|
||||
formattedDate: formatDateLabel(item.date)
|
||||
}))
|
||||
|
||||
setTrend(formattedTrend)
|
||||
setBrowsers(data.browsers || [])
|
||||
setTopUsers(data.top_users || [])
|
||||
setClickhouseDisabled(false)
|
||||
} catch (err) {
|
||||
const errorInstance = err instanceof Error ? err : new Error("获取数据统计失败")
|
||||
const errMsg = errorInstance.message || ""
|
||||
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
|
||||
setClickhouseDisabled(true)
|
||||
} else {
|
||||
setError(errorInstance)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics()
|
||||
}, [fetchAnalytics])
|
||||
|
||||
const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0)
|
||||
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={fetchAnalytics} />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingStateWithBorder icon={BarChart3} description="加载访问统计指标中..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">近 7 天总请求数</CardTitle>
|
||||
<TrendingUp className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{totalTrendRequests.toLocaleString()}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">系统记录的所有成功认证的访问总频次</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃终端分类数</CardTitle>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{browsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">在一周内发起请求的浏览器代理大类统计</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃独立用户数</CardTitle>
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{topUsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">一周内累计发起高频请求的注册账户总量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Access Trend Chart */}
|
||||
<Card className="bg-card/20 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold">一周访问量趋势</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
展现系统最近 7 天内的每日 API 请求曲线
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={fetchAnalytics} disabled={loading}>
|
||||
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2 pr-4 pt-2">
|
||||
{trend.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={TrendingUp} description="暂无趋势数据" />
|
||||
) : (
|
||||
<div className="h-[280px] w-full">
|
||||
<ChartContainer config={chartConfig} className="w-full h-full aspect-auto">
|
||||
<AreaChart
|
||||
data={trend}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dy={10}
|
||||
className="font-mono"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dx={-10}
|
||||
className="font-mono"
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="var(--color-count)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCount)"
|
||||
name="请求次数"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two Columns for Ranking Statistics */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Browser Rankings */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Globe className="size-4.5 text-muted-foreground" />
|
||||
使用的浏览器排行
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
基于请求头 User-Agent 智能分类的一周占比统计
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{browsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Globe} description="暂无浏览器排行数据" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{browsers.map((item, index) => {
|
||||
const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0
|
||||
return (
|
||||
<div key={item.browser} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="px-1.5 py-0 h-5 font-mono text-[10px] select-none">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.browser}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Users */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Users className="size-4.5 text-muted-foreground" />
|
||||
最活跃的用户排行 (Top 10)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
统计最近一周发起接口访问请求数量最多的账户
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{topUsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Users} description="暂无活跃用户数据" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{topUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none">
|
||||
{(user.username || "U").slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate max-w-[180px] sm:max-w-xs">
|
||||
<div className="text-xs font-bold leading-tight truncate">
|
||||
{user.username || "未知"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground leading-normal truncate">
|
||||
{user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-xs font-bold font-mono">{user.count.toLocaleString()}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">次请求</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
|
||||
interface AccessLog {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
path: string
|
||||
method: string
|
||||
ip: string
|
||||
user_agent: string
|
||||
headers: string
|
||||
status: number
|
||||
latency: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function statusVariant(status: number) {
|
||||
if (status >= 500) return "destructive"
|
||||
if (status >= 400) return "destructive"
|
||||
if (status >= 300) return "outline"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function isClickhouseDisabledError(error: Error) {
|
||||
const errMsg = error.message || ""
|
||||
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// 搜索过滤条件
|
||||
const [usernameFilter, setUsernameFilter] = useState("")
|
||||
const [pathFilter, setPathFilter] = useState("")
|
||||
const [startTimeFilter, setStartTimeFilter] = useState("")
|
||||
const [endTimeFilter, setEndTimeFilter] = useState("")
|
||||
|
||||
// 实际提交的搜索条件
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ["admin", "access-logs", page, searchParams],
|
||||
queryFn: async () => {
|
||||
let startISO = ""
|
||||
if (searchParams.start_time) {
|
||||
startISO = new Date(searchParams.start_time).toISOString()
|
||||
}
|
||||
let endISO = ""
|
||||
if (searchParams.end_time) {
|
||||
endISO = new Date(searchParams.end_time).toISOString()
|
||||
}
|
||||
|
||||
return AdminService.getAccessLogs({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
username: searchParams.username || undefined,
|
||||
path: searchParams.path || undefined,
|
||||
start_time: startISO || undefined,
|
||||
end_time: endISO || undefined,
|
||||
})
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const logs = logsQuery.data?.list ?? []
|
||||
const total = logsQuery.data?.total ?? 0
|
||||
const loading = logsQuery.isPending || logsQuery.isFetching
|
||||
const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error)
|
||||
const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null
|
||||
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: usernameFilter.trim(),
|
||||
path: pathFilter.trim(),
|
||||
start_time: startTimeFilter,
|
||||
end_time: endTimeFilter,
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setUsernameFilter("")
|
||||
setPathFilter("")
|
||||
setStartTimeFilter("")
|
||||
setEndTimeFilter("")
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`${subject}已复制到剪贴板`)
|
||||
}
|
||||
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return "暂无头部数据"
|
||||
try {
|
||||
const parsed = JSON.parse(headersRaw)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return headersRaw
|
||||
}
|
||||
}
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<form onSubmit={handleSearch} className="grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-user" className="text-xs">用户名</Label>
|
||||
<Input
|
||||
id="search-user"
|
||||
placeholder="输入用户名搜索..."
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-path" className="text-xs">接口路径</Label>
|
||||
<Input
|
||||
id="search-path"
|
||||
placeholder="模糊匹配路径..."
|
||||
value={pathFilter}
|
||||
onChange={(e) => setPathFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-start" className="text-xs">起始时间</Label>
|
||||
<Input
|
||||
id="search-start"
|
||||
type="datetime-local"
|
||||
value={startTimeFilter}
|
||||
onChange={(e) => setStartTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-end" className="text-xs">结束时间</Label>
|
||||
<Input
|
||||
id="search-end"
|
||||
type="datetime-local"
|
||||
value={endTimeFilter}
|
||||
onChange={(e) => setEndTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="h-8 flex-1">
|
||||
<Search className="size-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleReset} className="h-8 flex-1">
|
||||
<RotateCcw className="size-3.5 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Loading Table */}
|
||||
{loading && logs.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载访问日志中..." />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="未查询到任何访问日志数据" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">请求方法</TableHead>
|
||||
<TableHead className="min-w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[150px]">用户</TableHead>
|
||||
<TableHead className="w-[120px]">IP</TableHead>
|
||||
<TableHead className="w-[90px]">状态</TableHead>
|
||||
<TableHead className="w-[100px]">耗时</TableHead>
|
||||
<TableHead className="w-[170px]">请求时间</TableHead>
|
||||
<TableHead className="w-[80px] text-center">详情</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono font-semibold uppercase text-xs">
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs break-all max-w-[280px]">
|
||||
{log.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{log.username || "未知用户"}</span>
|
||||
{log.nickname && (
|
||||
<span className="text-[10px] text-muted-foreground">({log.nickname})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(log.status)}>
|
||||
{log.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatLatency(log.latency)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setSelectedLog(log)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {total} 条记录,当前第 {page}/{totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l">
|
||||
<SheetHeader className="border-b px-5 py-4 shrink-0">
|
||||
<SheetTitle className="text-lg font-bold">访问日志详情</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
访问 ID: {selectedLog?.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{selectedLog ? (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Summary Details */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">请求方法</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold uppercase">{selectedLog.method}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">响应状态</div>
|
||||
<div className="mt-1">
|
||||
<Badge variant={statusVariant(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">耗时</div>
|
||||
<div className="mt-1 font-mono text-sm">{formatLatency(selectedLog.latency)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">IP 地址</div>
|
||||
<div className="mt-1 font-mono text-sm">{selectedLog.ip}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">用户</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"}
|
||||
<span className="block font-mono text-[10px] text-muted-foreground mt-0.5">ID: {selectedLog.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求路径</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
|
||||
{selectedLog.path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Time */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求时间</Label>
|
||||
<div className="font-mono text-xs">{formatDateTime(selectedLog.created_at)}</div>
|
||||
</div>
|
||||
|
||||
{/* User Agent */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">User-Agent</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(selectedLog.user_agent, "User-Agent")}
|
||||
title="复制 User-Agent"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed">
|
||||
{selectedLog.user_agent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="grid gap-2 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">Request Headers</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getPrettyHeaders(selectedLog.headers), "Headers")}
|
||||
title="复制 Headers"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]">
|
||||
{getPrettyHeaders(selectedLog.headers)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<EmptyStateWithBorder icon={Activity} description="未选中日志明细" />
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {memo, useCallback, useEffect, useRef, useState} from "react"
|
||||
import {useVirtualizer} from "@tanstack/react-virtual"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
interface LogEntry {
|
||||
index: number
|
||||
data: string
|
||||
}
|
||||
|
||||
// Distance (px) from bottom to treat as "at bottom"
|
||||
const BOTTOM_THRESHOLD = 40
|
||||
const LOG_LINE_ESTIMATE_PX = 20
|
||||
const LOG_VIRTUAL_OVERSCAN = 24
|
||||
|
||||
const LogLine = memo(function LogLine({data}: {data: string}) {
|
||||
const level = parseLogLevel(data)
|
||||
const color = level === "error"
|
||||
? "text-red-400"
|
||||
: level === "warn"
|
||||
? "text-yellow-400"
|
||||
: level === "debug"
|
||||
? "text-gray-500"
|
||||
: "text-gray-300"
|
||||
|
||||
return (
|
||||
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
|
||||
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
if (base.startsWith("http")) return base
|
||||
|
||||
// Relative URL fallback
|
||||
const proto = window.location.protocol
|
||||
const host = window.location.host
|
||||
return `${proto}//${host}${base}`
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
}
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const base = getApiBaseUrl()
|
||||
const wsBase = base.replace(/^http/, "ws")
|
||||
return `${wsBase}/api/v1/admin/logs/ws`
|
||||
}
|
||||
|
||||
function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" {
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error"
|
||||
if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn"
|
||||
if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug"
|
||||
if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function AppLogs() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
// autoScroll = true → new logs auto-scroll to bottom
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const autoScrollRef = useRef(autoScroll)
|
||||
const isUserScrolling = useRef(false)
|
||||
|
||||
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => LOG_LINE_ESTIMATE_PX,
|
||||
overscan: LOG_VIRTUAL_OVERSCAN,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
})
|
||||
|
||||
// ---- Scroll detection ------------------------------------------------
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
if (!isUserScrolling.current) return
|
||||
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||
if (atBottom && !autoScrollRef.current) {
|
||||
setAutoScroll(true)
|
||||
} else if (!atBottom && autoScrollRef.current) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const onScrollEnd = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => { isUserScrolling.current = false }, 150)
|
||||
}
|
||||
el.addEventListener("scroll", onScrollEnd, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScrollEnd)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Auto-scroll to bottom when new logs arrive ----------------------
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !containerRef.current) return
|
||||
isUserScrolling.current = false
|
||||
const el = containerRef.current
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}, [logs, autoScroll])
|
||||
|
||||
// ---- Data fetching ---------------------------------------------------
|
||||
const fetchLogs = useCallback(async (cursor: number = 0) => {
|
||||
try {
|
||||
return await AdminService.getLogs(cursor)
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error("获取日志失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadHistory = useCallback(async (cursor: number = 0) => {
|
||||
const isInitial = cursor === 0
|
||||
if (isInitial) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchLogs(cursor)
|
||||
if (isInitial) {
|
||||
setLogs(data.lines || [])
|
||||
} else {
|
||||
const el = containerRef.current
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
setLogs(prev => [...(data.lines || []), ...prev])
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return
|
||||
isUserScrolling.current = false
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight
|
||||
})
|
||||
}
|
||||
setHasMore(data.has_more)
|
||||
setNextCursor(data.next_cursor)
|
||||
} catch (err) {
|
||||
if (isInitial) {
|
||||
setError(err instanceof Error ? err : new Error("获取日志失败"))
|
||||
} else {
|
||||
toast.error("加载更早日志失败")
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) setLoading(false)
|
||||
else setLoadingMore(false)
|
||||
}
|
||||
}, [fetchLogs])
|
||||
|
||||
// ---- WebSocket -------------------------------------------------------
|
||||
const connectWs = useCallback(() => {
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
|
||||
const ws = new WebSocket(buildWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "log" && msg.data) {
|
||||
const entry: LogEntry = msg.data
|
||||
setLogs(prev => {
|
||||
const next = [...prev, entry]
|
||||
return next.length > 2000 ? next.slice(-2000) : next
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => { wsRef.current = null }
|
||||
}, [])
|
||||
|
||||
// ---- Initialize ------------------------------------------------------
|
||||
useEffect(() => {
|
||||
loadHistory(0).then(() => connectWs())
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ---- Actions ---------------------------------------------------------
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setAutoScroll(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (nextCursor > 0) loadHistory(nextCursor)
|
||||
}, [nextCursor, loadHistory])
|
||||
|
||||
// ---- Render ----------------------------------------------------------
|
||||
if (loading) return <LoadingStateWithBorder />
|
||||
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Log viewer — fixed height, scrollable */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
|
||||
>
|
||||
{/* Load older logs */}
|
||||
{hasMore && (
|
||||
<div className="sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="text-muted-foreground hover:text-foreground h-7 text-xs"
|
||||
>
|
||||
{loadingMore
|
||||
? <><Loader2 className="size-3 mr-1.5 animate-spin" />加载中...</>
|
||||
: <><ChevronUp className="size-3 mr-1.5" />加载更早日志</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-3"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = logs[virtualRow.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{transform: `translateY(${virtualRow.start}px)`}}
|
||||
>
|
||||
<LogLine data={entry.data} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating "back to latest" button */}
|
||||
{!autoScroll && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={scrollToBottom}
|
||||
className="shadow-lg bg-background/80 backdrop-blur-sm border-border/50"
|
||||
>
|
||||
<ArrowDown className="size-3.5 mr-1.5" />
|
||||
回到最新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {HardDrive, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus} from "@/lib/services/admin"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
*/
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
interface CacheManagerProps {
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null)
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true)
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false)
|
||||
const [clearingCache, setClearingCache] = useState<boolean>(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false)
|
||||
|
||||
// 策略配置表单状态
|
||||
const [maxSizeMB, setMaxSizeMB] = useState<string>("100")
|
||||
const [ttlMinutes, setTtlMinutes] = useState<string>("60")
|
||||
const [lruEnabled, setLruEnabled] = useState<boolean>(true)
|
||||
|
||||
// 获取磁盘缓存状态
|
||||
const fetchCacheStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingCache(true)
|
||||
try {
|
||||
const data = await AdminService.getCacheStatus()
|
||||
setCacheStatus(data)
|
||||
setMaxSizeMB(data.max_size_mb.toString())
|
||||
setTtlMinutes(data.ttl_minutes.toString())
|
||||
setLruEnabled(data.lru_enabled)
|
||||
} catch (err) {
|
||||
toast.error("获取磁盘缓存状态失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
}
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
await AdminService.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
try {
|
||||
await AdminService.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化拉取
|
||||
useEffect(() => {
|
||||
fetchCacheStatus()
|
||||
}, [fetchCacheStatus])
|
||||
|
||||
// 当外部刷新触发器改变时自动刷新
|
||||
useEffect(() => {
|
||||
fetchCacheStatus(true)
|
||||
}, [refreshTrigger, fetchCacheStatus])
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="size-4 text-primary animate-pulse" />
|
||||
<CardTitle className="text-sm font-semibold">缓存管理</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start md:self-auto"
|
||||
onClick={() => fetchCacheStatus(true)}
|
||||
disabled={loadingCache}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingCache ? "animate-spin" : ""}`} />
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingCache && !cacheStatus ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : cacheStatus ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* 左边:状态区 (2/5 cols) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">运行状态</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 已占空间 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">已用空间</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatBytes(cacheStatus.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Key数量 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">缓存键数量</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatNumber(cacheStatus.keys_count)} <span className="text-xs text-muted-foreground font-normal">个文件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储路径 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1.5">缓存基准目录</p>
|
||||
<code className="text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate" title={cacheStatus.base_path}>
|
||||
{cacheStatus.base_path}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-medium w-full"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 右边:配置区 (3/5 cols) */}
|
||||
<div className="lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">策略配置</h4>
|
||||
<form onSubmit={handleSaveConfig} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="maxSizeMB" className="text-xs font-medium">最大容量限制 (MB)</Label>
|
||||
<Input
|
||||
id="maxSizeMB"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<Input
|
||||
id="ttlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
value={ttlMinutes}
|
||||
onChange={(e) => setTtlMinutes(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 60,0 表示永不过期"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20">
|
||||
<div className="space-y-1 pr-4">
|
||||
<Label htmlFor="lruEnabled" className="text-xs font-semibold block cursor-pointer">
|
||||
启用 LRU 淘汰机制
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
>
|
||||
{savingConfig ? "正在保存..." : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<HardDrive className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未加载到缓存状态信息</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 清除缓存确认对话框 */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="size-4" />
|
||||
确认清空所有缓存?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground pt-1">
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useRef, useState} from "react"
|
||||
import {useTheme} from "next-themes"
|
||||
import CodeMirror from "@uiw/react-codemirror"
|
||||
import {sql} from "@codemirror/lang-sql"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowLeft, Play, RefreshCw, Terminal, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import type {ExecuteSQLResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface SQLConsoleProps {
|
||||
dbType?: string // "postgres" | "sqlite"
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
// SQL 控制台状态
|
||||
const [sqlQuery, setSqlQuery] = useState<string>("")
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false)
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
|
||||
const [sqlError, setSqlError] = useState<string | null>(null)
|
||||
|
||||
// 拖拽与主题状态
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const cmTheme = resolvedTheme === "dark" ? "dark" : "light"
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startY = e.clientY
|
||||
const startHeight = editorHeight
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const containerHeight = containerRef.current.getBoundingClientRect().height
|
||||
const newHeight = startHeight + deltaY
|
||||
|
||||
// Limit editor height between 80px and containerHeight - 80px
|
||||
if (newHeight > 80 && newHeight < containerHeight - 80) {
|
||||
setEditorHeight(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
// 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">SQL 查询终端</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dbType === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]"
|
||||
>
|
||||
{/* 顶部编辑工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
<span className="text-xs font-semibold">SQL 编辑器</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 快速模板 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">快速模板:</span>
|
||||
<Select onValueChange={handlePresetSQLChange}>
|
||||
<SelectTrigger className="h-7 w-[180px] text-[11px] bg-background">
|
||||
<SelectValue placeholder="选择预设 SQL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SELECT * FROM users LIMIT 10;">查询用户 (SELECT users)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM uploads LIMIT 10;">查询文件 (SELECT uploads)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;">查询任务流水 (SELECT task_executions)</SelectItem>
|
||||
<SelectItem value="SELECT sqlite_version();">SQLite 版本 (SQLite only)</SelectItem>
|
||||
<SelectItem value="SELECT version();">PostgreSQL 版本 (Postgres only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSqlQuery("")}
|
||||
title="清空编辑器"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 拖动分割线 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10"
|
||||
title="拖动调整大小"
|
||||
>
|
||||
<div className="w-8 h-1 rounded bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
{/* 下半区:查看结果 (可滑动查看) */}
|
||||
<div className="flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10">
|
||||
{/* 结果栏工具提示 */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="font-semibold">执行输出</span>
|
||||
{sqlResult && (
|
||||
<span>
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<div className="flex-1 overflow-auto p-4 min-h-0">
|
||||
{executingSQL && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 space-y-2">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
<span className="text-xs text-muted-foreground">正在在数据库执行查询,请稍候...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlError && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]">
|
||||
<p className="font-semibold mb-1">SQL 执行报错 (Error):</p>
|
||||
<pre className="whitespace-pre-wrap">{sqlError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult && (
|
||||
<div className="h-full flex flex-col">
|
||||
{sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && (
|
||||
<div className="border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0">
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sqlResult.results && sqlResult.results.length > 0 ? (
|
||||
sqlResult.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{sqlResult.columns!.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-1.5">
|
||||
<span className="truncate max-w-[240px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult.type === "exec" && (
|
||||
<div className="bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto">
|
||||
<p className="text-foreground text-sm font-semibold">SQL 执行成功</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!executingSQL && !sqlResult && !sqlError && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60">
|
||||
<Terminal className="size-8 mb-2" />
|
||||
<span className="text-xs">编辑器就绪,请在上方编写 SQL 并运行查看结果</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Layers, RefreshCw} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import type {TableDataResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface TableBrowserProps {
|
||||
tables: string[]
|
||||
loadingTables: boolean
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) {
|
||||
const [selectedTable, setSelectedTable] = useState<string>("")
|
||||
const [tableData, setTableData] = useState<TableDataResponse | null>(null)
|
||||
const [page, setPage] = useState<number>(1)
|
||||
const [loadingData, setLoadingData] = useState<boolean>(false)
|
||||
const pageSize = 10
|
||||
|
||||
// 获取具体表数据
|
||||
const fetchTableData = useCallback(async (tableName: string, targetPage: number, size: number) => {
|
||||
if (!tableName) return
|
||||
setLoadingData(true)
|
||||
try {
|
||||
const data = await DbManageService.getTableData({
|
||||
table: tableName,
|
||||
page: targetPage,
|
||||
pageSize: size,
|
||||
})
|
||||
setTableData(data)
|
||||
} catch (err) {
|
||||
toast.error(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingData(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 默认选择第一张表
|
||||
useEffect(() => {
|
||||
if (tables.length > 0 && !selectedTable) {
|
||||
setSelectedTable(tables[0])
|
||||
}
|
||||
}, [tables, selectedTable])
|
||||
|
||||
// 当选中的表、页码或外部刷新触发器改变时获取数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
}
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">数据表浏览器</CardTitle>
|
||||
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loadingTables ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
) : (
|
||||
<Select value={selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger className="h-8 w-[200px] text-xs bg-background border-border/40">
|
||||
<SelectValue placeholder="选择数据表" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingData && !tableData ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : tableData && tableData.columns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
{loadingData && (
|
||||
<div className="absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0">
|
||||
<TableRow>
|
||||
{tableData.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2.5">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableData.results && tableData.results.length > 0 ? (
|
||||
tableData.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{tableData.columns.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-2">
|
||||
<span className="truncate max-w-[200px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<div>
|
||||
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs px-2 font-mono">
|
||||
第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Layers className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未选择数据表或表结构无法加载</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
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 {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {formatFileSize, getFileUrl, 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 FileList() {
|
||||
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 [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
|
||||
const [page, setPage] = React.useState(1)
|
||||
const pageSize = 15 // 表格视图下 15 条更紧凑
|
||||
|
||||
// 搜索防抖
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword)
|
||||
setPage(1)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
// 文件列表查询
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["files", "all", page, pageSize, debouncedKeyword],
|
||||
queryFn: () => UploadService.listUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
})
|
||||
|
||||
const files = listQuery.data?.items ?? []
|
||||
const total = listQuery.data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
|
||||
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
files.forEach((f) => next.delete(f.id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
files.forEach((f) => next.add(f.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单文件
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => UploadService.deleteFile(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
|
||||
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)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 搜索栏 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 打包下载等批量操作 */}
|
||||
<div className="flex items-center gap-2 ml-auto 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>
|
||||
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件列表 Table */}
|
||||
{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="border border-dashed rounded-xl bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-dashed">
|
||||
<TableHead className="w-[50px] pl-4 py-3">
|
||||
<Checkbox
|
||||
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] py-3">预览</TableHead>
|
||||
<TableHead className="w-[180px] py-3">ID</TableHead>
|
||||
<TableHead className="py-3">文件名</TableHead>
|
||||
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
|
||||
<TableHead className="w-[100px] py-3">业务类别</TableHead>
|
||||
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
|
||||
<TableHead className="w-[100px] py-3">大小</TableHead>
|
||||
<TableHead className="w-[150px] py-3">上传时间</TableHead>
|
||||
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedIds.has(file.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
className={`border-dashed hover:bg-muted/30 transition-colors ${
|
||||
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
|
||||
}`}
|
||||
>
|
||||
<TableCell className="pl-4 py-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(file.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileUrl(file.id, "low") ?? undefined}
|
||||
alt={file.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type, "size-4.5")
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
|
||||
{file.id}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
|
||||
{file.file_name}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
|
||||
{file.file_path}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
|
||||
{file.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
|
||||
{file.mime_type}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs text-muted-foreground">
|
||||
{formatDate(file.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-right pr-4">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
title="查看详情"
|
||||
onClick={() => setDetailTarget(file)}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted"
|
||||
title="下载"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="删除"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 详情查看 Sheet */}
|
||||
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
|
||||
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
|
||||
<SheetHeader className="border-b pb-4 px-0">
|
||||
<SheetTitle>文件详情</SheetTitle>
|
||||
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailTarget && (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 大图/格式预览 */}
|
||||
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
|
||||
{detailTarget.mime_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileUrl(detailTarget.id, "low") ?? undefined}
|
||||
alt={detailTarget.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{getFileIcon(detailTarget.mime_type, "size-14")}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{detailTarget.extension} 文件
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元数据列表 */}
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件 ID</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件名</span>
|
||||
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">存储路径</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">大小</span>
|
||||
<span className="col-span-2 text-foreground/90">
|
||||
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">MIME 类型</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件后缀</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">业务类型</span>
|
||||
<span className="col-span-2">
|
||||
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
|
||||
{detailTarget.type}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">存储驱动</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.storage_driver || "local"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">上传者 ID</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">上传时间</span>
|
||||
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<span className="text-muted-foreground font-medium block">额外元数据</span>
|
||||
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(detailTarget.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 删除确认 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {
|
||||
Upload,
|
||||
Loader2,
|
||||
HardDrive,
|
||||
Database,
|
||||
TrendingUp,
|
||||
Files,
|
||||
Info,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service"
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
"图片": "images",
|
||||
"视频": "videos",
|
||||
"音频": "audio",
|
||||
"文档": "documents",
|
||||
"压缩包": "archives",
|
||||
"其他": "others",
|
||||
}
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: "图片",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
videos: {
|
||||
label: "视频",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
audio: {
|
||||
label: "音频",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
documents: {
|
||||
label: "文档",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
archives: {
|
||||
label: "压缩包",
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
others: {
|
||||
label: "其他",
|
||||
color: "hsl(var(--chart-6))",
|
||||
},
|
||||
count: {
|
||||
label: "新增文件数",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
size: {
|
||||
label: "新增大小",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function FileStats() {
|
||||
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["files", "stats"],
|
||||
queryFn: () => UploadService.getFileStats(),
|
||||
})
|
||||
|
||||
const stats = statsQuery.data
|
||||
|
||||
const trendData = React.useMemo(() => {
|
||||
if (!stats?.trend) return []
|
||||
return stats.trend.map((item) => ({
|
||||
...item,
|
||||
formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD
|
||||
value: trendMetric === "count" ? item.count : item.size,
|
||||
}))
|
||||
}, [stats?.trend, trendMetric])
|
||||
|
||||
const categoryCountData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.count,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const categorySizeData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.size > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.size,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const maxStats = React.useMemo(() => {
|
||||
if (!stats?.categories || stats.categories.length === 0) {
|
||||
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
|
||||
}
|
||||
let maxCountName = "无"
|
||||
let maxCount = 0
|
||||
let maxSizeName = "无"
|
||||
let maxSize = 0
|
||||
|
||||
stats.categories.forEach((cat) => {
|
||||
if (cat.count > maxCount) {
|
||||
maxCount = cat.count
|
||||
maxCountName = cat.name
|
||||
}
|
||||
if (cat.size > maxSize) {
|
||||
maxSize = cat.size
|
||||
maxSizeName = cat.name
|
||||
}
|
||||
})
|
||||
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize }
|
||||
}, [stats?.categories])
|
||||
|
||||
const trendSummary = React.useMemo(() => {
|
||||
if (!stats?.trend) return { count: 0, size: 0 }
|
||||
return stats.trend.reduce(
|
||||
(acc, curr) => ({
|
||||
count: acc.count + curr.count,
|
||||
size: acc.size + curr.size,
|
||||
}),
|
||||
{ count: 0, size: 0 }
|
||||
)
|
||||
}, [stats?.trend])
|
||||
|
||||
if (statsQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="size-8 animate-spin text-sky-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats || stats.total_count === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
|
||||
<Upload className="size-14 text-muted-foreground/25" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="font-medium">暂无上传数据</p>
|
||||
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 四个指标卡片 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">总文件数</CardTitle>
|
||||
<Files className="size-4 text-sky-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-sky-500">
|
||||
{stats.total_count.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">个</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">占用存储容量</CardTitle>
|
||||
<Database className="size-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增文件</CardTitle>
|
||||
<TrendingUp className="size-4 text-indigo-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-indigo-500">
|
||||
+{trendSummary.count.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">个</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增大小</CardTitle>
|
||||
<HardDrive className="size-4 text-amber-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 7 天新增趋势折线图 */}
|
||||
<Card className="bg-card/20 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
|
||||
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={trendMetric === "count" ? "secondary" : "ghost"}
|
||||
className="h-7 text-xs px-2.5 rounded-md"
|
||||
onClick={() => setTrendMetric("count")}
|
||||
>
|
||||
新增数量 (个)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={trendMetric === "size" ? "secondary" : "ghost"}
|
||||
className="h-7 text-xs px-2.5 rounded-md"
|
||||
onClick={() => setTrendMetric("size")}
|
||||
>
|
||||
新增大小 (MB)
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2 pr-4 pt-2">
|
||||
<div className="h-[280px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
|
||||
const formattedValue =
|
||||
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full animate-pulse"
|
||||
style={{
|
||||
backgroundColor:
|
||||
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 两个饼图分布 */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* 饼图 1: 格式数量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
{categoryCountData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
|
||||
暂无分类数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryCountData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryCountData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => {
|
||||
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
|
||||
const label = configObj?.label || name
|
||||
const color = configObj?.color || "hsl(var(--muted))"
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{value} 个
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey="name" />}
|
||||
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<p className="leading-normal">
|
||||
文件类型中数量最多的是{" "}
|
||||
<strong className="font-bold">「{maxStats.maxCountName}」</strong>,累计上传了{" "}
|
||||
<strong className="font-mono font-bold">{maxStats.maxCount}</strong> 个文件。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 饼图 2: 格式容量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
{categorySizeData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
|
||||
暂无容量数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categorySizeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categorySizeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => {
|
||||
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
|
||||
const label = configObj?.label || name
|
||||
const color = configObj?.color || "hsl(var(--muted))"
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{formatFileSize(Number(value))}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey="name" />}
|
||||
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<p className="leading-normal">
|
||||
文件类型中占用存储最大的是{" "}
|
||||
<strong className="font-bold">「{maxStats.maxSizeName}」</strong>,共消耗了{" "}
|
||||
<strong className="font-mono font-bold">{formatFileSize(maxStats.maxSize)}</strong>{" "}
|
||||
存储容量。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {ChevronDown, Edit2, Layers, Loader2, Plus, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
|
||||
|
||||
export function EventsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
const availableChannels = React.useMemo(() => {
|
||||
const customChannels = (channelsQuery.data ?? [])
|
||||
.filter(c => c.enabled)
|
||||
.map(c => c.name)
|
||||
return ["email", ...customChannels]
|
||||
}, [channelsQuery.data])
|
||||
|
||||
// --- 获取通知事件 ---
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-events"],
|
||||
queryFn: () => PushService.listEvents(),
|
||||
})
|
||||
|
||||
const builtInEventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-builtin-events"],
|
||||
queryFn: () => PushService.listBuiltInEvents(),
|
||||
})
|
||||
|
||||
// --- 获取所有系统可调度任务类型 ---
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => AdminService.getTaskTypes(),
|
||||
})
|
||||
|
||||
// --- 修改保存事件 Mutation ---
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setEditEventOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.toggleEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("操作失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setCreateEventOpen(false)
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventEnabled(true)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
toast.success("配置删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("配置删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 事件编辑对话框状态 ---
|
||||
const [editEventOpen, setEditEventOpen] = React.useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
|
||||
const [eventChannels, setEventChannels] = React.useState<string[]>([])
|
||||
const [eventTargets, setEventTargets] = React.useState("")
|
||||
const [eventTemplate, setEventTemplate] = React.useState("")
|
||||
|
||||
// 事件创建对话框状态
|
||||
const [createEventOpen, setCreateEventOpen] = React.useState(false)
|
||||
const [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
|
||||
const [newEventKey, setNewEventKey] = React.useState("")
|
||||
const [newEventTaskType, setNewEventTaskType] = React.useState("")
|
||||
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
|
||||
const [newEventTargets, setNewEventTargets] = React.useState("")
|
||||
const [newEventTemplate, setNewEventTemplate] = React.useState("")
|
||||
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
|
||||
|
||||
const availableBuiltInEvents = React.useMemo(() => {
|
||||
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
|
||||
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
|
||||
}, [builtInEventsQuery.data, eventsQuery.data])
|
||||
|
||||
const handleEditEventClick = (event: PushEvent) => {
|
||||
setSelectedEvent(event)
|
||||
setEventChannels(event.channels)
|
||||
setEventTargets((event.targets ?? []).join(", "))
|
||||
setEventTemplate(event.template)
|
||||
setEditEventOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEvent = () => {
|
||||
if (!selectedEvent) return
|
||||
|
||||
try {
|
||||
JSON.parse(eventTemplate)
|
||||
} catch {
|
||||
toast.error("消息模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
|
||||
const targets = eventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: selectedEvent.id,
|
||||
data: {
|
||||
channels: eventChannels,
|
||||
targets,
|
||||
template: eventTemplate,
|
||||
enabled: selectedEvent.enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateEventClick = () => {
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventTargets("")
|
||||
setNewEventTemplate("")
|
||||
setNewEventEnabled(true)
|
||||
setCreateEventOpen(true)
|
||||
}
|
||||
|
||||
const handleNewEventKeyChange = (key: string) => {
|
||||
setNewEventKey(key)
|
||||
const ev = availableBuiltInEvents.find(e => e.key === key)
|
||||
if (ev) {
|
||||
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewEventTaskTypeChange = (taskType: string) => {
|
||||
setNewEventTaskType(taskType)
|
||||
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
|
||||
if (taskMeta) {
|
||||
const defaultTemplate = {
|
||||
title: `任务完成: ${taskMeta.name}`,
|
||||
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
|
||||
level: "INFO",
|
||||
}
|
||||
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (newEventType === "builtin" && !newEventKey) {
|
||||
toast.error("请选择系统事件")
|
||||
return
|
||||
}
|
||||
if (newEventType === "task" && !newEventTaskType) {
|
||||
toast.error("请选择异步任务")
|
||||
return
|
||||
}
|
||||
|
||||
if (newEventTemplate) {
|
||||
try {
|
||||
JSON.parse(newEventTemplate)
|
||||
} catch {
|
||||
toast.error("内容渲染模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const targets = newEventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
createEventMutation.mutate({
|
||||
event_key: newEventType === "builtin" ? newEventKey : undefined,
|
||||
task_type: newEventType === "task" ? newEventTaskType : undefined,
|
||||
channels: newEventChannels,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
template: newEventTemplate || undefined,
|
||||
enabled: newEventEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增通知事件
|
||||
</Button>
|
||||
</div>
|
||||
{eventsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
|
||||
) : eventsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (eventsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无通知配置事件,请点击右上角新增" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
|
||||
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(eventsQuery.data ?? []).map((event) => (
|
||||
<TableRow
|
||||
key={event.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
|
||||
{event.task_type && (
|
||||
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
|
||||
任务
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(event.channels ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
|
||||
) : (
|
||||
event.channels.map(ch => (
|
||||
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
|
||||
{ch === "email" ? "邮件" : ch}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(!event.targets || event.targets.length === 0) ? (
|
||||
<span className="text-muted-foreground text-[10px] font-mono">-</span>
|
||||
) : (
|
||||
event.targets.map(t => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
配置
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm("确定要删除该通知事件配置吗?")) {
|
||||
deleteEventMutation.mutate(event.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增事件 ==================== */}
|
||||
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增通知事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">事件类型</Label>
|
||||
<div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "builtin"}
|
||||
onChange={() => {
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>系统内置事件</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "task"}
|
||||
onChange={() => {
|
||||
setNewEventType("task")
|
||||
setNewEventKey("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>任务完成事件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newEventType === "builtin" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统事件</Label>
|
||||
{builtInEventsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载系统事件中...</span>
|
||||
</div>
|
||||
) : availableBuiltInEvents.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
所有内置事件都已配置,没有可新增的事件。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择系统事件" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableBuiltInEvents.map(ev => (
|
||||
<SelectItem key={ev.key} value={ev.key} className="text-xs">
|
||||
{ev.name} ({ev.key})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统异步任务</Label>
|
||||
{taskTypesQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载异步任务中...</span>
|
||||
</div>
|
||||
) : (taskTypesQuery.data ?? []).length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
暂无可用的系统任务。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择异步任务" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(taskTypesQuery.data ?? []).map(taskMeta => (
|
||||
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
|
||||
{taskMeta.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "builtin" && newEventKey && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">事件说明:</span>
|
||||
<span>
|
||||
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "task" && newEventTaskType && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">任务说明:</span>
|
||||
<span>
|
||||
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{newEventChannels.length > 0
|
||||
? newEventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={newEventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setNewEventChannels([...newEventChannels, ch])
|
||||
} else {
|
||||
setNewEventChannels(newEventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={newEventTargets}
|
||||
onChange={e => setNewEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{newEventType === "task"
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={newEventTemplate}
|
||||
onChange={e => setNewEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-semibold">启用状态</Label>
|
||||
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newEventEnabled}
|
||||
onCheckedChange={setNewEventEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createEventMutation.isPending || (newEventType === "builtin" && availableBuiltInEvents.length === 0)}
|
||||
onClick={handleCreateEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
启用保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:编辑事件 ==================== */}
|
||||
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>事件通知渠道配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
|
||||
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
|
||||
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.task_type && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
|
||||
<Input
|
||||
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{eventChannels.length > 0
|
||||
? eventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={eventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setEventChannels([...eventChannels, ch])
|
||||
} else {
|
||||
setEventChannels(eventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={eventTargets}
|
||||
onChange={e => setEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{selectedEvent.task_type
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={eventTemplate}
|
||||
onChange={e => setEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
onClick={handleSaveEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {PushHistory} from "@/lib/services/push"
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
switch (level) {
|
||||
case "CRITICAL":
|
||||
return "destructive"
|
||||
case "IMPORTANT":
|
||||
return "default"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
const [historyPage, setHistoryPage] = React.useState(1)
|
||||
const [historySearch, setHistorySearch] = React.useState("")
|
||||
const [historyStatus, setHistoryStatus] = React.useState("all")
|
||||
|
||||
const [detailOpen, setDetailOpen] = React.useState(false)
|
||||
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
|
||||
|
||||
const handleRowClick = (hist: PushHistory) => {
|
||||
setSelectedHistory(hist)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryFn: () =>
|
||||
PushService.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
status: historyStatus === "all" ? undefined : historyStatus,
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入事件键过滤..."
|
||||
className="pl-8 text-xs h-9"
|
||||
value={historySearch}
|
||||
onChange={e => {
|
||||
setHistorySearch(e.target.value)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Select
|
||||
value={historyStatus}
|
||||
onValueChange={val => {
|
||||
setHistoryStatus(val)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="发送状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
|
||||
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
|
||||
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => historiesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="text-xs font-semibold">事件</TableHead>
|
||||
<TableHead className="text-xs font-semibold">渠道</TableHead>
|
||||
<TableHead className="text-xs font-semibold">目标</TableHead>
|
||||
<TableHead className="text-xs font-semibold">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold">等级</TableHead>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold">时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historiesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : historiesQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (historiesQuery.data?.results ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
|
||||
无推送历史数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(historiesQuery.data?.results ?? []).map(hist => (
|
||||
<TableRow
|
||||
key={hist.id}
|
||||
className="hover:bg-muted/10 cursor-pointer transition-colors"
|
||||
onClick={() => handleRowClick(hist)}
|
||||
>
|
||||
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
|
||||
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
|
||||
{hist.channel === "email" ? "邮件" : hist.channel}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
|
||||
{hist.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
|
||||
{hist.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
variant={hist.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold"
|
||||
>
|
||||
{hist.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
{hist.status !== "success" && hist.error_msg && (
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
|
||||
{hist.error_msg}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(hist.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>共 {historiesQuery.data.total} 条历史记录</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{historyPage}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage * 10 >= historiesQuery.data.total}
|
||||
onClick={() => setHistoryPage(p => p + 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:推送详情 ==================== */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
推送通知详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看该条通知发送的详细审计信息与内容
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedHistory && (
|
||||
<div className="space-y-4 py-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">事件标识</span>
|
||||
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送渠道</span>
|
||||
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
|
||||
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">推送目标</span>
|
||||
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
|
||||
{selectedHistory.target}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送时间</span>
|
||||
<div className="bg-muted/40 p-2 rounded border">
|
||||
{new Date(selectedHistory.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知等级</span>
|
||||
<div>
|
||||
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
|
||||
{selectedHistory.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送状态</span>
|
||||
<div>
|
||||
<Badge
|
||||
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold py-0.5 px-2"
|
||||
>
|
||||
{selectedHistory.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-destructive block">失败原因</span>
|
||||
<div className="font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all">
|
||||
{selectedHistory.error_msg}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知标题</span>
|
||||
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
|
||||
{selectedHistory.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知内容</span>
|
||||
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
|
||||
{selectedHistory.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Edit2, Loader2, Play, Plus, Settings, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
|
||||
|
||||
export function SettingsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
// --- 获取动态通道表单字段定义 ---
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels-definitions"],
|
||||
queryFn: () => PushService.listChannelDefinitions(),
|
||||
})
|
||||
|
||||
|
||||
// --- 消息通道 CRUD Mutations ---
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateChannelMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
|
||||
PushService.updateChannel(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
toast.success("通道删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 消息通道与设置相关 State ---
|
||||
const [channelDialogOpen, setChannelDialogOpen] = React.useState(false)
|
||||
const [editingChannel, setEditingChannel] = React.useState<PushChannel | null>(null)
|
||||
const [channelName, setChannelName] = React.useState("")
|
||||
const [channelDescription, setChannelDescription] = React.useState("")
|
||||
const [channelType, setChannelType] = React.useState("custom")
|
||||
const [channelToken, setChannelToken] = React.useState("")
|
||||
const [channelUrl, setChannelUrl] = React.useState("")
|
||||
const [channelOther, setChannelOther] = React.useState("")
|
||||
|
||||
const activeDef = React.useMemo<ChannelDefinition | undefined>(() => {
|
||||
return (definitionsQuery.data ?? []).find(d => d.type === channelType)
|
||||
}, [definitionsQuery.data, channelType])
|
||||
|
||||
const [testChannelOpen, setTestChannelOpen] = React.useState(false)
|
||||
const [testChannelName, setTestChannelName] = React.useState("")
|
||||
const [testChannelTarget, setTestChannelTarget] = React.useState("")
|
||||
const [isTestingChannel, setIsTestingChannel] = React.useState(false)
|
||||
|
||||
const handleChannelTypeChange = (newType: string) => {
|
||||
setChannelType(newType)
|
||||
setChannelUrl("")
|
||||
setChannelToken("")
|
||||
if (newType === "custom") {
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
} else {
|
||||
setChannelOther("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateChannelClick = () => {
|
||||
setEditingChannel(null)
|
||||
setChannelName("")
|
||||
setChannelDescription("")
|
||||
setChannelType("custom")
|
||||
setChannelToken("")
|
||||
setChannelUrl("")
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEditChannelClick = (channel: PushChannel) => {
|
||||
setEditingChannel(channel)
|
||||
setChannelName(channel.name)
|
||||
setChannelDescription(channel.description ?? "")
|
||||
setChannelType(channel.type)
|
||||
setChannelToken(channel.token ?? "")
|
||||
setChannelUrl(channel.url)
|
||||
setChannelOther(channel.other)
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveChannel = () => {
|
||||
if (!channelName && !editingChannel) {
|
||||
toast.error("通道名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
|
||||
toast.error("通道名称只能使用英文字母、数字和下划线")
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeDef) {
|
||||
toast.error("无效的通道类型")
|
||||
return
|
||||
}
|
||||
|
||||
// 动态字段必填性校验
|
||||
for (const field of activeDef.fields) {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
if (field.required && !value.trim()) {
|
||||
toast.error(`${field.label}不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
|
||||
if (channelType !== "email") {
|
||||
if (channelUrl && !channelUrl.startsWith("https://")) {
|
||||
toast.error("地址必须以 https:// 开头以确保安全性")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 结构格式校验
|
||||
if (channelType === "custom") {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("请求体必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
} else if (channelType === "lark" && channelOther) {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("自定义卡片模版必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (editingChannel) {
|
||||
updateChannelMutation.mutate({
|
||||
id: editingChannel.id,
|
||||
data: {
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: editingChannel.enabled,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
createChannelMutation.mutate({
|
||||
name: channelName,
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestChannelClick = (name: string) => {
|
||||
setTestChannelName(name)
|
||||
setTestChannelTarget("")
|
||||
setTestChannelOpen(true)
|
||||
}
|
||||
|
||||
const handleSendChannelTest = async () => {
|
||||
try {
|
||||
setIsTestingChannel(true)
|
||||
toast.info("正在发送测试推送...")
|
||||
await PushService.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
})
|
||||
toast.success("测试推送发送成功,请前往对应平台确认。")
|
||||
setTestChannelOpen(false)
|
||||
} catch (err: unknown) {
|
||||
toast.error("连通性测试失败: " + (err as Error).message)
|
||||
} finally {
|
||||
setIsTestingChannel(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">自定义推送通道</h2>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新建消息通道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channelsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." />
|
||||
) : channelsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (channelsQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-12 border border-dashed rounded-lg flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">名称</TableHead>
|
||||
<TableHead className="w-[100px] whitespace-nowrap py-2 h-8">类型</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">备注</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(channelsQuery.data ?? []).map(ch => (
|
||||
<TableRow
|
||||
key={ch.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
>
|
||||
<TableCell className="text-xs font-mono font-bold py-1">
|
||||
{ch.name}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 h-4.5 whitespace-nowrap">
|
||||
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate py-1">
|
||||
{ch.description || <span className="italic">无备注</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={checked => {
|
||||
updateChannelMutation.mutate({
|
||||
id: ch.id,
|
||||
data: {
|
||||
description: ch.description,
|
||||
type: ch.type,
|
||||
token: ch.token,
|
||||
url: ch.url,
|
||||
other: ch.other,
|
||||
enabled: checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestChannelClick(ch.name)}
|
||||
className="h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Play className="size-2.5 mr-1" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit2 className="size-2.5 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
|
||||
deleteChannelMutation.mutate(ch.id)
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-2.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
|
||||
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
|
||||
value={channelName}
|
||||
onChange={e => setChannelName(e.target.value)}
|
||||
disabled={!!editingChannel}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">备注</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入备注信息"
|
||||
value={channelDescription}
|
||||
onChange={e => setChannelDescription(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">通道类型</Label>
|
||||
<Select value={channelType} onValueChange={handleChannelTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definitionsQuery.data ?? []).map(d => (
|
||||
<SelectItem key={d.type} value={d.type} className="text-xs">
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeDef && (
|
||||
<>
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
|
||||
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{activeDef.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeDef.fields.map(field => {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
const onChange = (val: string) => {
|
||||
if (field.key === "url") setChannelUrl(val);
|
||||
else if (field.key === "token") setChannelToken(val);
|
||||
else setChannelOther(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom post helper templates card */}
|
||||
{channelType === "custom" && (
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
|
||||
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: "$title\n$description\n$content\n$url"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载飞书 Webhook 模版");
|
||||
}}
|
||||
>
|
||||
飞书 Webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
title: "$title",
|
||||
text: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载钉钉机器人模版");
|
||||
}}
|
||||
>
|
||||
钉钉群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
content: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载企业微信机器人模版");
|
||||
}}
|
||||
>
|
||||
企业微信群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://api.day.app/push");
|
||||
setChannelOther(JSON.stringify({
|
||||
device_key: "$to",
|
||||
title: "$title",
|
||||
body: "$content",
|
||||
url: "$url"
|
||||
}, null, 2));
|
||||
toast.success("已加载 Bark App 模版");
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
|
||||
onClick={handleSaveChannel}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:测试渠道连通性 ==================== */}
|
||||
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送测试通知</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">推送通道名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={testChannelName}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
|
||||
value={testChannelTarget}
|
||||
onChange={e => setTestChannelTarget(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isTestingChannel}
|
||||
onClick={handleSendChannelTest}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
发送测试
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Activity, Cpu, Database, HardDrive, Layers, RefreshCw} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import type {SystemStatus} from "@/lib/services/admin"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时系统状态展示与管理组件
|
||||
*/
|
||||
export function SystemStatusManager() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wavelet, setWavelet] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevStatusRef = useRef<SystemStatus | null>(null)
|
||||
const [changedFields, setChangedFields] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 获取状态数据
|
||||
const fetchStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setWavelet(true)
|
||||
try {
|
||||
const data = await AdminService.getSystemStatus()
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
const changes: Record<string, boolean> = {}
|
||||
Object.keys(data).forEach((key) => {
|
||||
const k = key as keyof SystemStatus
|
||||
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
|
||||
changes[k] = true
|
||||
}
|
||||
})
|
||||
setChangedFields(changes)
|
||||
// 1秒后清除变化状态
|
||||
setTimeout(() => {
|
||||
setChangedFields({})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
prevStatusRef.current = data
|
||||
} catch (err) {
|
||||
toast.error("获取系统状态失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setWavelet(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
// 首次加载
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const time = parseInt(intervalTime, 10)
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchStatus(true)
|
||||
}, time)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, intervalTime, fetchStatus])
|
||||
|
||||
// 计算内存分配百分比 (Alloc / Sys)
|
||||
const getMemoryUsagePercent = () => {
|
||||
if (!status) return 0
|
||||
|
||||
const parseSizeToBytes = (sizeStr: string): number => {
|
||||
const parts = sizeStr.split(" ")
|
||||
if (parts.length < 2) return 0
|
||||
const value = parseFloat(parts[0])
|
||||
const unit = parts[1].toLowerCase()
|
||||
|
||||
if (unit.startsWith("gib")) return value * 1024 * 1024 * 1024
|
||||
if (unit.startsWith("mib")) return value * 1024 * 1024
|
||||
if (unit.startsWith("kib")) return value * 1024
|
||||
return value
|
||||
}
|
||||
|
||||
const alloc = parseSizeToBytes(status.alloc)
|
||||
const sys = parseSizeToBytes(status.sys)
|
||||
if (sys === 0) return 0
|
||||
return Math.min(Math.round((alloc / sys) * 100), 100)
|
||||
}
|
||||
|
||||
// 闪烁动画类
|
||||
const getPulseClass = (field: string) => {
|
||||
return changedFields[field] ? "animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block" : "transition-all duration-300"
|
||||
}
|
||||
|
||||
const RenderMetricRow = ({ label, value, field }: { label: string; value: string | number; field?: string }) => (
|
||||
<div className="flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ""}`}>
|
||||
{typeof value === "number" ? formatNumber(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 p-1">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
{/* 顶部控制栏 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统状态</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
|
||||
自动刷新
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{autoRefresh && (
|
||||
<Select value={intervalTime} onValueChange={setIntervalTime}>
|
||||
<SelectTrigger className="h-7 w-20 text-[11px] bg-background border-border/40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1000">1s</SelectItem>
|
||||
<SelectItem value="2000">2s</SelectItem>
|
||||
<SelectItem value="5000">5s</SelectItem>
|
||||
<SelectItem value="10000">10s</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-[11px] gap-1.5"
|
||||
onClick={() => fetchStatus()}
|
||||
disabled={wavelet}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<>
|
||||
{/* 主网格排版 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* 1. 服务概览 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">服务概览</CardTitle>
|
||||
<CardDescription className="text-[10px]">服务的基础生命指标</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Activity className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="服务运行时间" value={status.uptime} field="uptime" />
|
||||
<RenderMetricRow label="当前 Goroutines 数量" value={status.num_goroutine} field="num_goroutine" />
|
||||
<RenderMetricRow label="Heap 对象数量" value={status.heap_objects} field="heap_objects" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. 内存统计 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">内存统计</CardTitle>
|
||||
<CardDescription className="text-[10px]">主内存消耗概览</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Cpu className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<RenderMetricRow label="当前内存使用量" value={status.alloc} field="alloc" />
|
||||
<RenderMetricRow label="所有已分配的内存" value={status.total_alloc} field="total_alloc" />
|
||||
<RenderMetricRow label="内存占用量" value={status.sys} field="sys" />
|
||||
<RenderMetricRow label="下次 GC 内存回收量" value={status.next_gc} field="next_gc" />
|
||||
</div>
|
||||
{/* 物理内存水位比例 */}
|
||||
<div className="space-y-1 px-1 pt-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>当前使用率 (Alloc / Sys)</span>
|
||||
<span className="font-mono">{getMemoryUsagePercent()}%</span>
|
||||
</div>
|
||||
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. 堆/栈详情 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">堆/栈详情</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时堆栈内存空间细节</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Database className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="当前 Heap 内存使用量" value={status.heap_alloc} field="heap_alloc" />
|
||||
<RenderMetricRow label="Heap 内存占用量" value={status.heap_sys} field="heap_sys" />
|
||||
<RenderMetricRow label="Heap 内存空闲量" value={status.heap_idle} field="heap_idle" />
|
||||
<RenderMetricRow label="正在使用的 Heap 内存" value={status.heap_inuse} field="heap_inuse" />
|
||||
<RenderMetricRow label="已释放的 Heap 内存" value={status.heap_released} field="heap_released" />
|
||||
<RenderMetricRow label="启动 Stack 使用量" value={status.stack_inuse} field="stack_inuse" />
|
||||
<RenderMetricRow label="已分配的 Stack 内存" value={status.stack_sys} field="stack_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. 底层组件与结构体 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">底层及结构体内存</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时底层管理结构体开销</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<HardDrive className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="MSpan 结构内存使用量" value={status.mspan_inuse} field="mspan_inuse" />
|
||||
<RenderMetricRow label="已分配的 MSpan 结构内存" value={status.mspan_sys} field="mspan_sys" />
|
||||
<RenderMetricRow label="MCache 结构内存使用量" value={status.mcache_inuse} field="mcache_inuse" />
|
||||
<RenderMetricRow label="已分配的 MCache 结构内存" value={status.mcache_sys} field="mcache_sys" />
|
||||
<RenderMetricRow label="已分配的剖析哈希表内存" value={status.buck_hash_sys} field="buck_hash_sys" />
|
||||
<RenderMetricRow label="已分配的 GC 元数据内存" value={status.gc_sys} field="gc_sys" />
|
||||
<RenderMetricRow label="其它已分配的系统内存" value={status.other_sys} field="other_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 5. 垃圾回收与分配计数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">垃圾回收与分配计数</CardTitle>
|
||||
<CardDescription className="text-[10px]">GC 历史数据与分配频次</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Layers className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
|
||||
<div>
|
||||
<RenderMetricRow label="GC 执行次数" value={status.num_gc} field="num_gc" />
|
||||
<RenderMetricRow label="距离上次 GC 时间" value={status.last_gc_time} field="last_gc_time" />
|
||||
<RenderMetricRow label="GC 暂停时间总量" value={status.pause_total_ns} field="pause_total_ns" />
|
||||
<RenderMetricRow label="上次 GC 暂停时间" value={status.last_pause} field="last_pause" />
|
||||
</div>
|
||||
<div>
|
||||
<RenderMetricRow label="内存分配次数" value={status.mallocs} field="mallocs" />
|
||||
<RenderMetricRow label="内存释放次数" value={status.frees} field="frees" />
|
||||
<RenderMetricRow label="指针查找次数" value={status.lookups} field="lookups" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Database, Loader2, Play, Save} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {AdminService} from "@/lib/services/admin/admin.service"
|
||||
import type {
|
||||
ObjectStorageConfig,
|
||||
StorageConfig,
|
||||
StorageDriver,
|
||||
TaskExecution,
|
||||
TaskExecutionStatus
|
||||
} from "@/lib/services/admin/types"
|
||||
|
||||
const storageConfigKey = "storage_config"
|
||||
const storageMigrationTaskType = "storage_migration"
|
||||
|
||||
const driverLabels: Record<StorageDriver, string> = {
|
||||
local: "本地文件系统",
|
||||
s3: "AWS S3",
|
||||
r2: "Cloudflare R2",
|
||||
minio: "MinIO",
|
||||
oss: "阿里云 OSS",
|
||||
webdav: "WebDAV",
|
||||
}
|
||||
|
||||
const emptyObjectConfig: ObjectStorageConfig = {
|
||||
endpoint: "",
|
||||
region: "",
|
||||
bucket: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
path_style: false,
|
||||
key_prefix: "",
|
||||
cdn_url: "",
|
||||
}
|
||||
|
||||
function normalizeConfig(config: StorageConfig): StorageConfig {
|
||||
const s3 = {...emptyObjectConfig, ...config.s3}
|
||||
const r2 = {...emptyObjectConfig, ...config.r2}
|
||||
const minio = {...emptyObjectConfig, ...config.minio}
|
||||
const oss = {...emptyObjectConfig, ...config.oss}
|
||||
const webdav = {...config.webdav}
|
||||
return {
|
||||
...config,
|
||||
local: {root: config.local?.root || "."},
|
||||
s3: {...s3, region: s3.region || "us-east-1"},
|
||||
r2: {...r2, region: r2.region || "auto"},
|
||||
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
|
||||
oss,
|
||||
webdav: {
|
||||
endpoint: webdav.endpoint || "",
|
||||
username: webdav.username || "",
|
||||
password: webdav.password || "",
|
||||
base_path: webdav.base_path || "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StorageMigrationView = {
|
||||
state: "idle" | TaskExecutionStatus
|
||||
id?: string
|
||||
task_id?: string
|
||||
source_driver?: StorageDriver
|
||||
target_driver?: StorageDriver
|
||||
error?: string
|
||||
}
|
||||
|
||||
type StorageMigrationPayload = {
|
||||
target: StorageConfig
|
||||
}
|
||||
|
||||
function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
|
||||
return parsed.target ? normalizeConfig(parsed.target) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : "未知"
|
||||
}
|
||||
|
||||
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
|
||||
const execution = executions[0]
|
||||
if (!execution || execution.status === "succeeded") {
|
||||
return {state: "idle"}
|
||||
}
|
||||
const target = parseMigrationPayload(execution.payload)
|
||||
return {
|
||||
state: execution.status,
|
||||
id: execution.id,
|
||||
task_id: execution.task_id,
|
||||
source_driver: current?.driver,
|
||||
target_driver: target?.driver,
|
||||
error: execution.error_message,
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageConfigTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [config, setConfig] = React.useState<StorageConfig | null>(null)
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "storage-config"],
|
||||
queryFn: async () => {
|
||||
const [configRecord, executions] = await Promise.all([
|
||||
AdminService.getSystemConfig(storageConfigKey),
|
||||
AdminService.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
|
||||
])
|
||||
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
|
||||
const migration = latestMigration(executions.items, current)
|
||||
return {
|
||||
config: current,
|
||||
migration,
|
||||
}
|
||||
},
|
||||
refetchInterval: (result) => {
|
||||
const state = result.state.data?.migration.state
|
||||
return state === "pending" || state === "running" ? 2000 : false
|
||||
},
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data?.config) {
|
||||
setConfig((current) => {
|
||||
const normalized = normalizeConfig(query.data.config)
|
||||
if (current) {
|
||||
return {
|
||||
...normalized,
|
||||
driver: current.driver,
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
}, [query.data?.config])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
const activeDriver = query.data?.config.driver || value.driver
|
||||
const valueToSave = {
|
||||
...value,
|
||||
driver: activeDriver,
|
||||
}
|
||||
await AdminService.updateSystemConfig(storageConfigKey, {
|
||||
value: JSON.stringify(valueToSave),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("存储配置已保存")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存存储配置失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
const payload: StorageMigrationPayload = {target: value}
|
||||
return AdminService.dispatchTask({
|
||||
task_type: storageMigrationTaskType,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "下发存储迁移任务失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (executionID: string) => AdminService.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已重新下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
|
||||
})
|
||||
|
||||
if (query.isPending || !config) {
|
||||
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
|
||||
}
|
||||
|
||||
const migration = query.data?.migration
|
||||
const isReadOnly = migration ? migration.state !== "idle" : false
|
||||
const isFormDisabled =
|
||||
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
|
||||
saveMutation.isPending ||
|
||||
migrateMutation.isPending
|
||||
|
||||
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
|
||||
setConfig((current) => current ? {
|
||||
...current,
|
||||
[driver]: {...current[driver], ...patch},
|
||||
} : current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{isReadOnly && migration && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
存储维护模式
|
||||
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
|
||||
{migration.state}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
|
||||
</p>
|
||||
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
|
||||
onClick={() => migration.id && runMutation.mutate(migration.id)}
|
||||
>
|
||||
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
重试迁移
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<Database className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<FieldGroup className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<FieldLabel className="text-xs font-semibold">存储类型</FieldLabel>
|
||||
<Select
|
||||
value={config.driver}
|
||||
disabled={isFormDisabled}
|
||||
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
|
||||
>
|
||||
<SelectTrigger className="w-full border-dashed bg-card text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
|
||||
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{config.driver === "local" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField
|
||||
label="根目录"
|
||||
value={config.local.root}
|
||||
placeholder="."
|
||||
onChange={(root) => setConfig({...config, local: {root}})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
|
||||
<ObjectFields
|
||||
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
|
||||
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
|
||||
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.driver === "webdav" && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
|
||||
</div>
|
||||
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
|
||||
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="基础路径" value={config.webdav.base_path} placeholder="wavelet" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
|
||||
⚠️ 您已切换存储类型。请先点击“保存配置”保存各存储端的配置凭据,然后点击“开始迁移”手动执行文件迁移。
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => saveMutation.mutate(config)}
|
||||
className="border-dashed"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||
保存配置
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<Button
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => migrateMutation.mutate(config)}
|
||||
>
|
||||
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
开始迁移
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObjectFields({
|
||||
driver,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
driver: "s3" | "r2" | "minio" | "oss"
|
||||
value: ObjectStorageConfig
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{driver === "r2" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
|
||||
</div>
|
||||
)}
|
||||
{driver !== "s3" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
|
||||
</div>
|
||||
)}
|
||||
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
|
||||
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
|
||||
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
|
||||
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
|
||||
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
|
||||
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
|
||||
{(driver === "s3" || driver === "minio") && (
|
||||
<div className="md:col-span-2">
|
||||
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
|
||||
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
|
||||
</div>
|
||||
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
}) {
|
||||
return (
|
||||
<Field className="flex flex-col gap-1.5">
|
||||
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
|
||||
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
|
||||
pending: "等待中",
|
||||
running: "执行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: "系统",
|
||||
manual: "手动",
|
||||
retry: "重试",
|
||||
schedule: "定时",
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
if (!duration) return "-"
|
||||
if (duration < 1000) return `${ duration }ms`
|
||||
return `${ (duration / 1000).toFixed(2) }s`
|
||||
}
|
||||
|
||||
function statusVariant(status: TaskExecutionStatus) {
|
||||
if (status === "failed") return "destructive"
|
||||
if (status === "succeeded") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
export function TaskExecutionsManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [executionsPage, setExecutionsPage] = useState(1)
|
||||
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
|
||||
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
|
||||
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => AdminService.getTaskTypes(),
|
||||
})
|
||||
|
||||
const executionsQuery = useQuery({
|
||||
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
|
||||
queryFn: () => AdminService.listTaskExecutions({
|
||||
page: executionsPage,
|
||||
page_size: PAGE_SIZE,
|
||||
status: executionStatus === "all" ? undefined : executionStatus,
|
||||
task_type: executionTaskType === "all" ? undefined : executionTaskType,
|
||||
}),
|
||||
})
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["admin", "task-execution", selectedExecutionId],
|
||||
queryFn: () => AdminService.getTaskExecution(selectedExecutionId!),
|
||||
enabled: detailOpen && !!selectedExecutionId,
|
||||
})
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: string) => AdminService.retryTaskExecution(id),
|
||||
onSuccess: (taskID) => {
|
||||
toast.success("任务已重新下发", {
|
||||
description: `新任务 ID:${ taskID }`,
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error("任务重试失败", {
|
||||
description: err.message || "未知错误",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
|
||||
const executions = executionsQuery.data?.items ?? []
|
||||
const executionsTotal = executionsQuery.data?.total ?? 0
|
||||
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
|
||||
const executionsError = executionsQuery.isError ? executionsQuery.error : null
|
||||
const selectedExecution = detailQuery.data ?? executionPreview
|
||||
const detailLoading = detailQuery.isFetching
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
|
||||
[executionsTotal],
|
||||
)
|
||||
|
||||
const openExecutionDetail = (execution: TaskExecution) => {
|
||||
setExecutionPreview(execution)
|
||||
setSelectedExecutionId(execution.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
|
||||
setExecutionStatus(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setExecutionTaskType(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
|
||||
<SelectTrigger size="sm" className="w-[120px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">等待中</SelectItem>
|
||||
<SelectItem value="running">执行中</SelectItem>
|
||||
<SelectItem value="succeeded">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
|
||||
<SelectTrigger size="sm" className="w-[180px]">
|
||||
<SelectValue placeholder="任务类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部任务</SelectItem>
|
||||
{taskTypes.map((task) => (
|
||||
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
|
||||
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{executionsError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : executionsLoading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[180px]">任务</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[110px]">触发</TableHead>
|
||||
<TableHead className="w-[120px]">重试</TableHead>
|
||||
<TableHead className="w-[120px]">耗时</TableHead>
|
||||
<TableHead className="min-w-[220px]">结果/错误</TableHead>
|
||||
<TableHead className="w-[170px]">创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((execution) => (
|
||||
<TableRow
|
||||
key={execution.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => openExecutionDetail(execution)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(execution.status)}>
|
||||
{STATUS_LABELS[execution.status] || execution.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{execution.retry_count}/{execution.max_retry}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDuration(execution.duration)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
|
||||
{execution.error_message || execution.result || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDateTime(execution.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={executionsPage <= 1 || executionsLoading}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
|
||||
disabled={executionsPage >= totalPages || executionsLoading}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="w-full p-0 sm:max-w-[640px]">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>任务执行详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{detailLoading && !selectedExecution ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
|
||||
) : selectedExecution ? (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">状态</div>
|
||||
<div className="mt-2">
|
||||
<Badge variant={statusVariant(selectedExecution.status)}>
|
||||
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">触发来源</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">重试次数</div>
|
||||
<div className="mt-2 font-mono text-sm">
|
||||
{selectedExecution.retry_count}/{selectedExecution.max_retry}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">耗时</div>
|
||||
<div className="mt-2 font-mono text-sm">
|
||||
{formatDuration(selectedExecution.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>任务标识</Label>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
|
||||
{selectedExecution.task_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>创建时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>更新时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>执行结果</Label>
|
||||
<div className="min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all">
|
||||
{selectedExecution.result || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedExecution.error_message && (
|
||||
<div className="grid gap-2">
|
||||
<Label>错误信息</Label>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all">
|
||||
{selectedExecution.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Payload</Label>
|
||||
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
|
||||
{selectedExecution.payload || "{}"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>执行日志</Label>
|
||||
<pre className="min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{selectedExecution.log || "暂无日志"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t">
|
||||
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
|
||||
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新详情
|
||||
</Button>
|
||||
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
|
||||
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
|
||||
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
|
||||
重试任务
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
|
||||
|
||||
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import {format} from "date-fns"
|
||||
import {zhCN} from "date-fns/locale"
|
||||
import {Calendar} from "@/components/ui/calendar"
|
||||
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
|
||||
|
||||
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
|
||||
'order_sync': {
|
||||
icon: Layers,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
|
||||
},
|
||||
'user_gamification': {
|
||||
icon: Play,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
|
||||
},
|
||||
'dispute_auto_refund': {
|
||||
icon: Clock,
|
||||
color: "text-rose-600 dark:text-rose-400",
|
||||
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TASK_CONFIG = {
|
||||
icon: Layers,
|
||||
color: "text-zinc-600 dark:text-zinc-400",
|
||||
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
|
||||
}
|
||||
|
||||
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
|
||||
const timeString = date ? format(date, "HH:mm:ss") : ""
|
||||
|
||||
const handleDateSelect = (newDate: Date | undefined) => {
|
||||
if (!newDate) {
|
||||
setDate(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const updated = new Date(newDate)
|
||||
|
||||
if (date) {
|
||||
updated.setHours(date.getHours())
|
||||
updated.setMinutes(date.getMinutes())
|
||||
updated.setSeconds(date.getSeconds())
|
||||
} else {
|
||||
updated.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
setDate(updated)
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
if (!newTime) return
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
const updated = new Date(date)
|
||||
updated.setHours(hours || 0)
|
||||
updated.setMinutes(minutes || 0)
|
||||
updated.setSeconds(seconds || 0)
|
||||
setDate(updated)
|
||||
} else {
|
||||
const today = new Date()
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
today.setHours(hours || 0)
|
||||
today.setMinutes(minutes || 0)
|
||||
today.setSeconds(seconds || 0)
|
||||
setDate(today)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal text-xs h-8",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 size-3" />
|
||||
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeString}
|
||||
onChange={handleTimeChange}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskManager() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
|
||||
const [dispatching, setDispatching] = useState(false)
|
||||
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
|
||||
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
|
||||
const [userId, setUserId] = useState("")
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({})
|
||||
|
||||
const fetchTaskTypes = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await AdminService.getTaskTypes()
|
||||
setTaskTypes(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
if (targetTask?.params) {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask.params.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}
|
||||
}, [selectedTaskType, taskTypes])
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!selectedTaskType) return
|
||||
|
||||
try {
|
||||
setDispatching(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: selectedTaskType
|
||||
}
|
||||
|
||||
if (targetTask?.supports_time) {
|
||||
if (startTime) params.start_time = startTime.toISOString()
|
||||
if (endTime) params.end_time = endTime.toISOString()
|
||||
}
|
||||
|
||||
if (targetTask?.type === 'user_gamification') {
|
||||
if (userId) params.user_id = userId
|
||||
}
|
||||
|
||||
// Handle dynamic parameters — type coercion (number vs string) is
|
||||
// centralised in buildTaskPayload; do not inline it here.
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setDispatching(false)
|
||||
return
|
||||
}
|
||||
params.payload = payload ?? undefined
|
||||
}
|
||||
|
||||
const taskID = await AdminService.dispatchTask(params)
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
|
||||
})
|
||||
setDialogOpen(false)
|
||||
|
||||
setStartTime(undefined)
|
||||
setEndTime(undefined)
|
||||
setUserId("")
|
||||
setParamValues({})
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDispatchDialog = (type: string) => {
|
||||
setSelectedTaskType(type)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
|
||||
</div>
|
||||
) : loading && taskTypes.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{taskTypes.map((task, index) => {
|
||||
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ task.type }-${ index }`}
|
||||
className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
|
||||
config.gradient
|
||||
)}
|
||||
>
|
||||
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
|
||||
{task.queue}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
类型:{task.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
重试:{task.max_retry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1">
|
||||
<Button
|
||||
className="w-full h-7 text-xs"
|
||||
variant="secondary"
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置任务参数并启动,任务将在后台异步执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>执行任务</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
|
||||
{(() => {
|
||||
const meta = getSelectedTaskMeta()
|
||||
if (!meta) return null
|
||||
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<>
|
||||
<div className={cn("p-2", config.color)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{meta.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (targetTask?.supports_time) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<DatePickerWithTime date={startTime} setDate={setStartTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{targetTask.params.map((param) => (
|
||||
<div key={param.name} className="grid gap-2">
|
||||
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1">
|
||||
{param.label}
|
||||
{param.required && <span className="text-destructive font-bold">*</span>}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs min-h-[80px]"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 pt-1 h-9">
|
||||
<Switch
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className="text-[10px] text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
const hasTime = targetTask?.supports_time
|
||||
const hasParams = targetTask?.params && targetTask.params.length > 0
|
||||
if (!hasTime && !hasParams) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>此任务无需额外配置参数,确认后将直接下发。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
|
||||
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
|
||||
{dispatching ? '下发中' : '启动'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
export function TaskSchedulesManager() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([])
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
|
||||
// Modal States
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
|
||||
|
||||
// Delete States
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// Form Fields
|
||||
const [name, setName] = useState("")
|
||||
const [selectedTaskType, setSelectedTaskType] = useState("")
|
||||
const [cron, setCron] = useState("")
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({})
|
||||
|
||||
// Fetch Schedules & Task Types
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [schedulesData, taskTypesData] = await Promise.all([
|
||||
AdminService.listSchedules(),
|
||||
AdminService.getTaskTypes()
|
||||
])
|
||||
setSchedules(schedulesData || [])
|
||||
setTaskTypes(taskTypesData || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Track task type selection to initialize parameters
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
|
||||
// Parse existing payload when editing the same task type
|
||||
try {
|
||||
const parsed = JSON.parse(editingSchedule.payload || "{}")
|
||||
const values: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
values[p.name] = String(parsed[p.name] ?? "")
|
||||
})
|
||||
setParamValues(values)
|
||||
} catch {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
}
|
||||
} else {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
}
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}, [selectedTaskType, taskTypes, editingSchedule])
|
||||
|
||||
const handleToggleActive = async (schedule: Schedule) => {
|
||||
try {
|
||||
const updated = await AdminService.updateSchedule(schedule.id, {
|
||||
name: schedule.name,
|
||||
task_type: schedule.task_type,
|
||||
cron: schedule.cron,
|
||||
payload: schedule.payload,
|
||||
is_active: !schedule.is_active
|
||||
})
|
||||
|
||||
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
|
||||
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
|
||||
} catch (err) {
|
||||
toast.error("修改任务状态失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingSchedule(null)
|
||||
setName("")
|
||||
setSelectedTaskType(taskTypes[0]?.type || "")
|
||||
setCron("0 */2 * * *")
|
||||
setIsActive(true)
|
||||
setParamValues({})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (schedule: Schedule) => {
|
||||
setEditingSchedule(schedule)
|
||||
setName(schedule.name)
|
||||
setSelectedTaskType(schedule.task_type)
|
||||
setCron(schedule.cron)
|
||||
setIsActive(schedule.is_active)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("任务名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!cron.trim()) {
|
||||
toast.error("Cron 表达式不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
let payloadStr = '{}'
|
||||
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setSubmitLoading(false)
|
||||
return
|
||||
}
|
||||
payloadStr = payload ?? '{}'
|
||||
}
|
||||
|
||||
if (editingSchedule) {
|
||||
const req: UpdateScheduleRequest = {
|
||||
name,
|
||||
task_type: selectedTaskType,
|
||||
cron,
|
||||
payload: payloadStr,
|
||||
is_active: isActive
|
||||
}
|
||||
const updated = await AdminService.updateSchedule(editingSchedule.id, req)
|
||||
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
|
||||
toast.success("定时任务更新成功")
|
||||
} else {
|
||||
const req: CreateScheduleRequest = {
|
||||
name,
|
||||
task_type: selectedTaskType,
|
||||
cron,
|
||||
payload: payloadStr,
|
||||
is_active: isActive
|
||||
}
|
||||
const created = await AdminService.createSchedule(req)
|
||||
setSchedules(prev => [created, ...prev])
|
||||
toast.success("定时任务创建成功")
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
} catch (err) {
|
||||
toast.error("提交失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setDeletingId(id)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingId) return
|
||||
try {
|
||||
setDeleteLoading(true)
|
||||
await AdminService.deleteSchedule(deletingId)
|
||||
setSchedules(prev => prev.filter(s => s.id !== deletingId))
|
||||
toast.success("定时任务删除成功")
|
||||
setDeleteOpen(false)
|
||||
} catch (err) {
|
||||
toast.error("删除失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
const getTaskName = (type: string) => {
|
||||
const meta = taskTypes.find(t => t.type === type)
|
||||
return meta ? meta.name : type
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
新增定时任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
|
||||
</div>
|
||||
) : loading && schedules.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[800px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[180px]">任务名称</TableHead>
|
||||
<TableHead className="w-[160px]">关联异步任务</TableHead>
|
||||
<TableHead className="w-[120px]">Cron 表达式</TableHead>
|
||||
<TableHead className="min-w-[200px]">执行参数 (Payload)</TableHead>
|
||||
<TableHead className="w-[100px] text-center">启用状态</TableHead>
|
||||
<TableHead className="w-[120px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedules.map((schedule) => (
|
||||
<TableRow key={schedule.id}>
|
||||
<TableCell className="font-medium">{schedule.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
|
||||
{schedule.cron}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
|
||||
{schedule.payload || "{}"}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={schedule.is_active}
|
||||
onCheckedChange={() => handleToggleActive(schedule)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
>
|
||||
<Edit2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/90"
|
||||
onClick={() => openDeleteDialog(schedule.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置定时任务的调度参数和运行载荷。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-name">任务名称</Label>
|
||||
<Input
|
||||
id="sched-name"
|
||||
placeholder="例如:每日数据清理"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-type">关联异步任务</Label>
|
||||
<select
|
||||
id="sched-type"
|
||||
value={selectedTaskType}
|
||||
onChange={(e) => setSelectedTaskType(e.target.value)}
|
||||
disabled={!!editingSchedule}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{taskTypes.map(t => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.name} ({t.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-cron">Cron 表达式</Label>
|
||||
<Input
|
||||
id="sched-cron"
|
||||
placeholder="e.g. 0 */2 * * * 或 @daily"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sched-active">启用状态</Label>
|
||||
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sched-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed">
|
||||
<Info className="h-4 w-4 shrink-0" />
|
||||
<span>所选任务类型不需要运行参数参数。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
|
||||
{targetTask.params.map((param) => (
|
||||
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
|
||||
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
|
||||
{param.label}
|
||||
{param.required && <span className="text-destructive font-bold">*</span>}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs min-h-[70px]"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 pt-1 h-9">
|
||||
<Switch
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className="text-[10px] text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
|
||||
{submitLoading && <Spinner className="size-3 mr-1" />}
|
||||
{editingSchedule ? '保存修改' : '确认创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除定时任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
|
||||
{deleteLoading && <Spinner className="size-3 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} fro
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
|
||||
|
||||
const emptyForm: AuthSourceRequest = {
|
||||
@@ -63,9 +63,9 @@ export function AuthSourceModal({
|
||||
setSaving(true)
|
||||
try {
|
||||
if (source) {
|
||||
await AdminService.updateAuthSource(source.id, form)
|
||||
await services.adminAuthSource.updateAuthSource(source.id, form)
|
||||
} else {
|
||||
await AdminService.createAuthSource(form)
|
||||
await services.adminAuthSource.createAuthSource(form)
|
||||
}
|
||||
await onChanged()
|
||||
toast.success("认证源已保存")
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -33,13 +33,13 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
export function InfoTab() {
|
||||
const updateQuery = useQuery({
|
||||
queryKey: ["admin", "update"],
|
||||
queryFn: () => AdminService.getUpdateStatus(),
|
||||
queryFn: () => services.adminStatus.getUpdateStatus(),
|
||||
refetchInterval: 30 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const applyUpdateMutation = useMutation({
|
||||
mutationFn: () => AdminService.applyUpdate(),
|
||||
mutationFn: () => services.adminStatus.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
toast.success("升级包已校验完成,服务正在重启")
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {KeyRound, ShieldAlert, X} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {TemplatesManager} from "./templates"
|
||||
import {toast} from "sonner"
|
||||
@@ -21,7 +21,7 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
|
||||
const uploadTypesQuery = useQuery({
|
||||
queryKey: ["admin", "upload-types"],
|
||||
queryFn: () => AdminService.listUploadTypes(),
|
||||
queryFn: () => services.adminSystemConfig.listUploadTypes(),
|
||||
})
|
||||
|
||||
const updateWhitelistMutation = useMutation({
|
||||
@@ -30,7 +30,7 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: file_access_whitelist")
|
||||
}
|
||||
await AdminService.updateSystemConfig("file_access_whitelist", {
|
||||
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
|
||||
value: newValue,
|
||||
description: config.description,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -90,7 +90,7 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
|
||||
const newConfig = { ...menuDisplayConfig, [path]: enabled }
|
||||
const currentCfg = configs["menu_display_config"]
|
||||
await AdminService.updateSystemConfig("menu_display_config", {
|
||||
await services.adminSystemConfig.updateSystemConfig("menu_display_config", {
|
||||
value: JSON.stringify(newConfig),
|
||||
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import type {ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services/auth"
|
||||
import {UploadService} from "@/lib/services/upload/upload.service"
|
||||
import services from "@/lib/services"
|
||||
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -193,7 +193,7 @@ export function ProfileMain() {
|
||||
|
||||
const handleCroppedImage = async (croppedBase64: string) => {
|
||||
try {
|
||||
const res = await UploadService.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
|
||||
const res = await services.upload.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
|
||||
setAvatarUrl(`/f/${res.id}`)
|
||||
setIsCropDialogOpen(false)
|
||||
setCropFile(null)
|
||||
|
||||
@@ -24,7 +24,7 @@ import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "sources"],
|
||||
queryFn: () => AdminService.listAuthSources(),
|
||||
queryFn: () => services.adminAuthSource.listAuthSources(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,7 +120,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: login_session_ttl_hours")
|
||||
}
|
||||
await AdminService.updateSystemConfig("login_session_ttl_hours", {
|
||||
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
|
||||
value: value,
|
||||
description: config.description,
|
||||
})
|
||||
@@ -159,7 +159,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await AdminService.updateSystemConfig(key, {
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
@@ -176,7 +176,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
const toggleSourceMutation = useMutation({
|
||||
mutationFn: async (source: AuthSource) => {
|
||||
await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
@@ -190,7 +190,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
const deleteSourceMutation = useMutation({
|
||||
mutationFn: async (sourceId: string) => {
|
||||
await AdminService.deleteAuthSource(sourceId)
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
@@ -219,7 +219,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
await AdminService.updateSystemConfig(update.key, {
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await AdminService.updateSystemConfig(key, {
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
@@ -78,11 +78,11 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const currentAddrCfg = configs["server_address"]
|
||||
const currentSiteCfg = configs["site_name"]
|
||||
await Promise.all([
|
||||
AdminService.updateSystemConfig("server_address", {
|
||||
services.adminSystemConfig.updateSystemConfig("server_address", {
|
||||
value: serverAddress,
|
||||
description: currentAddrCfg?.description || "服务器地址",
|
||||
}),
|
||||
AdminService.updateSystemConfig("site_name", {
|
||||
services.adminSystemConfig.updateSystemConfig("site_name", {
|
||||
value: siteName,
|
||||
description: currentSiteCfg?.description || "系统平台的展示名称",
|
||||
}),
|
||||
@@ -122,7 +122,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
await AdminService.updateSystemConfig(update.key, {
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
@@ -148,7 +148,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
|
||||
const res = await AdminService.testSMTP({
|
||||
const res = await services.adminSystemConfig.testSMTP({
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: parseInt(smtpPort, 10) || 587,
|
||||
smtp_username: smtpUsername,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -29,12 +29,12 @@ export function TemplatesManager() {
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["admin", "templates"],
|
||||
queryFn: () => AdminService.listTemplates(),
|
||||
queryFn: () => services.adminTemplate.listTemplates(),
|
||||
})
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AdminService.createTemplate({
|
||||
await services.adminTemplate.createTemplate({
|
||||
key,
|
||||
name,
|
||||
type,
|
||||
@@ -55,7 +55,7 @@ export function TemplatesManager() {
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await AdminService.updateTemplate(key, {
|
||||
await services.adminTemplate.updateTemplate(key, {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
@@ -75,7 +75,7 @@ export function TemplatesManager() {
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await AdminService.deleteTemplate(key)
|
||||
await services.adminTemplate.deleteTemplate(key)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service"
|
||||
import services, {formatFileSize, getFileUrl} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
@@ -83,7 +83,7 @@ export function UserFileManager() {
|
||||
// 我的文件列表查询
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["user-files", page, pageSize, debouncedKeyword],
|
||||
queryFn: () => UploadService.listMyUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
queryFn: () => services.upload.listMyUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
})
|
||||
|
||||
const files = data?.items ?? []
|
||||
@@ -93,7 +93,7 @@ export function UserFileManager() {
|
||||
// 上传文件 Mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
return UploadService.uploadFile(file, "generic", undefined, uploadAccessMode)
|
||||
return services.upload.uploadFile(file, "generic", undefined, uploadAccessMode)
|
||||
},
|
||||
onMutate: () => {
|
||||
setUploading(true)
|
||||
@@ -115,7 +115,7 @@ export function UserFileManager() {
|
||||
|
||||
// 删除文件 Mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => UploadService.deleteMyFile(id),
|
||||
mutationFn: (id: string) => services.upload.deleteMyFile(id),
|
||||
onSuccess: () => {
|
||||
toast.success("文件已成功删除")
|
||||
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
|
||||
@@ -138,7 +138,7 @@ export function UserFileManager() {
|
||||
}
|
||||
|
||||
const handleDownload = (file: UploadRecord) => {
|
||||
const url = UploadService.getDownloadUrl(file.id)
|
||||
const url = services.upload.getDownloadUrl(file.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.file_name
|
||||
|
||||
Reference in New Issue
Block a user