chore: checkpoint admin editor and perf work
This commit is contained in:
413
admin/src/pages/analytics-page.tsx
Normal file
413
admin/src/pages/analytics-page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user