Files
termi-blog/admin/src/pages/analytics-page.tsx

414 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
label,
value,
note,
icon: Icon,
}: {
label: string
value: string
note: string
icon: typeof Search
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
}
function formatEventType(value: string) {
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
}
function formatSuccess(value: boolean | null) {
if (value === null) {
return '未记录'
}
return value ? '成功' : '失败'
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadAnalytics = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.analytics()
startTransition(() => {
setData(next)
})
if (showToast) {
toast.success('数据分析已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadAnalytics(false)
}, [loadAnalytics])
const maxDailyTotal = useMemo(() => {
if (!data?.daily_activity.length) {
return 1
}
return Math.max(
...data.daily_activity.map((item) => item.searches + item.ai_questions),
1,
)
}, [data])
if (loading || !data) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Skeleton className="h-[520px] rounded-3xl" />
<Skeleton className="h-[520px] rounded-3xl" />
</div>
</div>
)
}
const statCards = [
{
label: '累计搜索',
value: String(data.overview.total_searches),
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)}`,
icon: Search,
},
{
label: '累计 AI 提问',
value: String(data.overview.total_ai_questions),
note: `近 7 天 ${data.overview.ai_questions_last_7d}`,
icon: BrainCircuit,
},
{
label: '24 小时活跃',
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
icon: Clock3,
},
{
label: '近 7 天去重词',
value: String(
data.overview.unique_search_terms_last_7d +
data.overview.unique_ai_questions_last_7d,
),
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
icon: BarChart3,
},
]
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
</Button>
<Button
variant="secondary"
onClick={() => void loadAnalytics(true)}
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{statCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
</div>
<Badge variant="outline">{data.recent_events.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recent_events.map((event) => (
<TableRow key={event.id}>
<TableCell>
<div className="space-y-1">
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
{formatEventType(event.event_type)}
</Badge>
{event.response_mode ? (
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{event.response_mode}
</p>
) : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<p className="line-clamp-2 font-medium">{event.query}</p>
<p className="text-xs text-muted-foreground">
{event.provider ? `${event.provider}` : '未记录渠道'}
{event.chat_model ? ` / ${event.chat_model}` : ''}
</p>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div>{formatSuccess(event.success)}</div>
<div className="mt-1">
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
</div>
{event.latency_ms !== null ? (
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
{event.latency_ms} ms
</div>
) : null}
</TableCell>
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_search_terms.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_search_terms.length ? (
data.top_search_terms.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle> AI </CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_ai_questions.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_ai_questions.length ? (
data.top_ai_questions.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
24 7
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24 AI
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
</p>
<p className="mt-3 text-3xl font-semibold">
{data.overview.avg_ai_latency_ms_last_7d !== null
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
: '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 AI 使 provider
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.providers_last_7d.length ? (
data.providers_last_7d.map((item) => (
<div
key={item.provider}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="font-medium">{item.provider}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7 </CardTitle>
<CardDescription>
AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data.daily_activity.map((item) => {
const total = item.searches + item.ai_questions
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
return (
<div key={item.date} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{item.date}</span>
<span className="text-muted-foreground">
{item.searches} / AI {item.ai_questions}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
</div>
</div>
</div>
)
}