414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|