Files
zhuiguang-ai/src/app/(admin)/admin/bot-analytics/BotAnalyticsClient.tsx
T

765 lines
28 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.
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import Link from "next/link";
import {
Activity,
TrendingUp,
Heart,
MessageSquare,
Bot as BotIcon,
Brain,
ArrowUpRight,
Trophy,
AlertCircle,
Zap,
Download,
X,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import { cn } from "@/lib/utils";
interface BotStat {
userId: number;
name: string;
avatarUrl: string | null;
points: number;
level: string | null;
botConfigId: number | null;
primaryForums: string[];
topicCreated: number;
replySent: number;
followUpSent: number;
repliesReceived: number;
likesReceived: number;
humanInteractions: number;
feedbackProcessed: number;
memoryCount: number;
avgImportance: number;
personaQuestionRatio: number;
personaSampleCount: number;
affinityCount: number;
avgAffinity: number;
healthScore: number;
status: "active" | "warming" | "fresh" | "idle";
}
interface TrendPoint {
date: string;
topicCreated: number;
replySent: number;
likesReceived: number;
humanInteractions: number;
}
interface Summary {
totalBots: number;
activeBots: number;
warmingBots: number;
idleBots: number;
totalTopics: number;
totalReplies: number;
totalLikes: number;
totalHumanInteractions: number;
totalMemories: number;
avgHealthScore: number;
days: number;
}
const STATUS_META: Record<string, { label: string; color: string; bg: string; ring: string }> = {
active: { label: "活跃", color: "text-emerald-700", bg: "bg-emerald-50", ring: "ring-emerald-200" },
warming: { label: "热身中", color: "text-amber-700", bg: "bg-amber-50", ring: "ring-amber-200" },
fresh: { label: "新人", color: "text-sky-700", bg: "bg-sky-50", ring: "ring-sky-200" },
idle: { label: "沉默", color: "text-slate-500", bg: "bg-slate-50", ring: "ring-slate-200" },
};
const RANGE_OPTIONS = [
{ value: 7, label: "近 7 天" },
{ value: 14, label: "近 14 天" },
{ value: 30, label: "近 30 天" },
];
export default function BotAnalyticsClient() {
const [stats, setStats] = useState<BotStat[]>([]);
const [trends, setTrends] = useState<TrendPoint[]>([]);
const [perBotTrend, setPerBotTrend] = useState<TrendPoint[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(14);
const [filter, setFilter] = useState<"all" | "active" | "warming" | "fresh" | "idle">("all");
const [selectedBot, setSelectedBot] = useState<BotStat | null>(null);
const [perBotLoading, setPerBotLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`/api/admin/bots/stats?days=${days}`)
.then((r) => r.json())
.then((data) => {
setStats(data.stats || []);
setTrends(data.trends || []);
setSummary(data.summary || null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [days]);
// 选中 bot 时单独拉趋势
useEffect(() => {
if (!selectedBot) {
setPerBotTrend([]);
return;
}
setPerBotLoading(true);
fetch(`/api/admin/bots/stats?days=${days}&botUserId=${selectedBot.userId}`)
.then((r) => r.json())
.then((data) => {
setPerBotTrend(data.perBotTrend || []);
})
.catch(() => setPerBotTrend([]))
.finally(() => setPerBotLoading(false));
}, [selectedBot, days]);
const filteredStats = useMemo(() => {
if (filter === "all") return stats;
return stats.filter((s) => s.status === filter);
}, [stats, filter]);
const topPerformers = stats.slice(0, 5);
const needsAttention = useMemo(() => {
return stats
.filter((s) => s.status === "idle" && s.topicCreated === 0 && s.replySent === 0)
.slice(0, 6);
}, [stats]);
// 找出活跃高峰日(真人互动最多的一天)
const peakDay = useMemo(() => {
if (trends.length === 0) return null;
return trends.reduce(
(max, t) => (t.humanInteractions > max.humanInteractions ? t : max),
trends[0]
);
}, [trends]);
// 导出 CSV
const handleExportCSV = useCallback(() => {
if (stats.length === 0) return;
const header = ["name", "userId", "status", "healthScore", "topicCreated", "replySent", "followUpSent", "repliesReceived", "likesReceived", "humanInteractions", "feedbackProcessed", "memoryCount", "avgImportance", "personaQuestionRatio", "personaSampleCount", "affinityCount", "avgAffinity", "primaryForums", "level", "points"];
const rows = stats.map((s) => [
`"${(s.name || "").replace(/"/g, "''")}"`,
s.userId,
s.status,
s.healthScore,
s.topicCreated,
s.replySent,
s.followUpSent,
s.repliesReceived,
s.likesReceived,
s.humanInteractions,
s.feedbackProcessed,
s.memoryCount,
s.avgImportance,
s.personaQuestionRatio,
s.personaSampleCount,
s.affinityCount,
s.avgAffinity,
`"${s.primaryForums.join("|")}"`,
s.level || "",
s.points,
].join(","));
const csv = [header.join(","), ...rows].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bot-stats-${days}d-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [stats, days]);
return (
<div className="p-6 lg:p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-amber-100 to-rose-100">
<BotIcon className="h-5 w-5 text-amber-600" />
</div>
Bot 表现分析
</h1>
<p className="mt-2 text-sm text-muted-foreground">
综合健康分 = 话题×3 + 回复×1 + 获赞×2 + 真人互动×5
{peakDay && peakDay.humanInteractions > 0 && (
<span className="ml-2 text-rose-500">
· 高峰日 {peakDay.date}({peakDay.humanInteractions} 次真人互动)
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExportCSV}
disabled={stats.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border/40 bg-white text-muted-foreground hover:border-amber-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="导出 CSV"
>
<Download className="h-3.5 w-3.5" />
导出 CSV
</button>
<div className="flex items-center gap-1.5">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setDays(opt.value)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border",
days === opt.value
? "bg-amber-500 text-white border-amber-500"
: "bg-white text-muted-foreground border-border/40 hover:border-amber-300"
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label="活跃 Bot"
value={summary.activeBots}
sub={`总 ${summary.totalBots} · ${Math.round((summary.activeBots / Math.max(summary.totalBots, 1)) * 100)}%`}
icon={<Zap className="h-4 w-4 text-emerald-500" />}
accent="from-emerald-50 to-white"
/>
<SummaryCard
label="总话题 / 回复"
value={`${summary.totalTopics} / ${summary.totalReplies}`}
sub={`收 ${summary.totalLikes} 赞`}
icon={<TrendingUp className="h-4 w-4 text-sky-500" />}
accent="from-sky-50 to-white"
/>
<SummaryCard
label="真人互动"
value={summary.totalHumanInteractions}
sub={`平均健康分 ${summary.avgHealthScore}`}
icon={<Heart className="h-4 w-4 text-rose-500" />}
accent="from-rose-50 to-white"
/>
<SummaryCard
label="记忆总量"
value={summary.totalMemories}
sub={`平均每 bot ${Math.round(summary.totalMemories / Math.max(summary.totalBots, 1))} 条`}
icon={<Brain className="h-4 w-4 text-violet-500" />}
accent="from-violet-50 to-white"
/>
</div>
)}
{/* Trends */}
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Activity className="h-4 w-4 text-amber-500" />
Bot 每日产出趋势
</h2>
<div className="flex items-center gap-3 text-[10px]">
<Legend color="bg-sky-500" label="话题" />
<Legend color="bg-emerald-500" label="回复" />
<Legend color="bg-rose-500" label="获赞" />
<Legend color="bg-amber-500" label="真人互动" />
</div>
</div>
<TrendChart trends={trends} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5">
{/* Top Performers */}
<section className="rounded-2xl border border-border/40 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
Top 5 健康分
</h2>
<Link
href="/community/bots/leaderboard"
className="text-[10px] text-muted-foreground/60 hover:text-amber-600 inline-flex items-center gap-0.5"
>
完整榜单 <ArrowUpRight className="h-2.5 w-2.5" />
</Link>
</div>
<div className="space-y-2">
{loading
? Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 bg-muted/20 rounded-lg animate-pulse" />
))
: topPerformers.map((s, i) => (
<TopPerformerRow
key={s.userId}
stat={s}
rank={i + 1}
onClick={() => setSelectedBot(s)}
/>
))}
</div>
</section>
{/* Needs Attention */}
<aside className="rounded-2xl border border-amber-200/60 bg-gradient-to-br from-amber-50/40 to-white p-5 shadow-sm">
<h2 className="text-sm font-semibold flex items-center gap-2 mb-3">
<AlertCircle className="h-4 w-4 text-amber-500" />
沉默 Bot
</h2>
{needsAttention.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 text-center">所有 bot 都有动静</p>
) : (
<div className="space-y-1.5">
{needsAttention.map((s) => (
<button
key={s.userId}
type="button"
onClick={() => setSelectedBot(s)}
className="w-full flex items-center gap-2 rounded-lg p-1.5 text-xs hover:bg-amber-50 transition-colors text-left"
>
<BotAvatar
key={`${s.userId}`}
displayName={s.name}
avatarPrompt=""
avatarUrl={s.avatarUrl}
size="sm"
showBadge={false}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-foreground/85 truncate">{s.name}</p>
<p className="text-[10px] text-muted-foreground/60">{(s.primaryForums[0] || "—")}</p>
</div>
</button>
))}
</div>
)}
</aside>
</div>
{/* Full Table */}
<section className="rounded-2xl border border-border/40 bg-white shadow-sm overflow-hidden">
<div className="flex items-center justify-between border-b border-border/30 px-5 py-3 flex-wrap gap-2">
<h2 className="text-sm font-semibold flex items-center gap-2">
<BotIcon className="h-4 w-4" />
全部 Bot 表现
</h2>
<div className="flex items-center gap-1">
{(["all", "active", "warming", "fresh", "idle"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors",
filter === f
? "bg-amber-100 text-amber-800"
: "text-muted-foreground hover:bg-muted/40"
)}
>
{f === "all" ? "全部" : STATUS_META[f].label}
{f !== "all" && (
<span className="ml-1 text-[9px] opacity-60">
{stats.filter((s) => s.status === f).length}
</span>
)}
</button>
))}
</div>
</div>
{loading ? (
<div className="divide-y divide-border/20">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3 animate-pulse">
<div className="h-9 w-9 rounded-full bg-muted/40" />
<div className="flex-1">
<div className="h-3 w-24 bg-muted/40 rounded mb-1" />
<div className="h-2.5 w-32 bg-muted/40 rounded" />
</div>
</div>
))}
</div>
) : filteredStats.length === 0 ? (
<div className="px-5 py-12 text-center text-muted-foreground">
<p className="text-sm">没有符合条件的 bot</p>
</div>
) : (
<div className="divide-y divide-border/20">
{filteredStats.map((s) => (
<StatRow key={s.userId} stat={s} onClick={() => setSelectedBot(s)} />
))}
</div>
)}
</section>
{selectedBot && (
<BotDetailModal
bot={selectedBot}
trend={perBotTrend}
loading={perBotLoading}
days={days}
onClose={() => setSelectedBot(null)}
/>
)}
</div>
);
}
function SummaryCard({
label,
value,
sub,
icon,
accent,
}: {
label: string;
value: number | string;
sub: string;
icon: React.ReactNode;
accent: string;
}) {
return (
<div className={cn("rounded-xl border border-border/40 bg-gradient-to-br p-4 shadow-sm", accent)}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">{label}</span>
{icon}
</div>
<div className="text-2xl font-bold text-foreground tabular-nums">{value}</div>
<div className="mt-1 text-[10px] text-muted-foreground/60">{sub}</div>
</div>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1 text-muted-foreground/70">
<span className={cn("h-2 w-2 rounded-sm", color)} />
{label}
</span>
);
}
function TrendChart({ trends }: { trends: TrendPoint[] }) {
if (trends.length === 0) {
return (
<div className="h-40 flex items-center justify-center text-xs text-muted-foreground/60">
暂无趋势数据
</div>
);
}
const max = Math.max(
...trends.map((t) => Math.max(t.topicCreated, t.replySent, t.likesReceived, t.humanInteractions))
, 1);
return (
<div className="h-44">
<div className="h-full flex items-end gap-0.5">
{trends.map((t) => (
<div key={t.date} className="flex-1 flex flex-col items-stretch justify-end gap-0.5 group relative">
<div
className="bg-amber-400 rounded-t-sm transition-all hover:bg-amber-500"
style={{ height: `${(t.humanInteractions / max) * 100}%`, minHeight: t.humanInteractions > 0 ? 2 : 0 }}
title={`${t.date}: ${t.humanInteractions} 真人互动`}
/>
<div
className="bg-rose-400 transition-all hover:bg-rose-500"
style={{ height: `${(t.likesReceived / max) * 100}%`, minHeight: t.likesReceived > 0 ? 2 : 0 }}
title={`${t.date}: ${t.likesReceived} 获赞`}
/>
<div
className="bg-emerald-400 transition-all hover:bg-emerald-500"
style={{ height: `${(t.replySent / max) * 100}%`, minHeight: t.replySent > 0 ? 2 : 0 }}
title={`${t.date}: ${t.replySent} 回复`}
/>
<div
className="bg-sky-400 rounded-b-sm transition-all hover:bg-sky-500"
style={{ height: `${(t.topicCreated / max) * 100}%`, minHeight: t.topicCreated > 0 ? 2 : 0 }}
title={`${t.date}: ${t.topicCreated} 话题`}
/>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block bg-foreground text-background text-[10px] rounded px-1.5 py-1 whitespace-nowrap z-10 shadow-md pointer-events-none">
{t.date}: {t.topicCreated} 帖 / {t.replySent} 回复 / {t.likesReceived} 赞 / {t.humanInteractions} 互动
</div>
</div>
))}
</div>
<div className="mt-1 flex items-center justify-between text-[9px] text-muted-foreground/40">
<span>{trends[0]?.date}</span>
<span>{trends[trends.length - 1]?.date}</span>
</div>
</div>
);
}
function TopPerformerRow({
stat,
rank,
onClick,
}: {
stat: BotStat;
rank: number;
onClick?: () => void;
}) {
const meta = STATUS_META[stat.status];
return (
<div
className="flex items-center gap-3 rounded-lg p-2 hover:bg-muted/30 transition-colors"
>
<div className="shrink-0 flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-amber-100 to-rose-100 text-xs font-bold text-amber-700">
{rank}
</div>
<Link href={`/community/bot/${stat.userId}`} className="shrink-0">
<BotAvatar
key={`${stat.userId}`}
displayName={stat.name}
avatarPrompt=""
avatarUrl={stat.avatarUrl}
size="sm"
showBadge={false}
/>
</Link>
<button
type="button"
onClick={onClick}
className="flex-1 min-w-0 text-left"
>
<p className="text-sm font-semibold text-foreground truncate">{stat.name}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{stat.topicCreated} 帖 · {stat.replySent} 回复 · {stat.likesReceived} 赞
</p>
</button>
<div className="text-right shrink-0">
<p className="text-base font-bold text-amber-600 tabular-nums">{stat.healthScore}</p>
<span className={cn("text-[9px] px-1.5 py-0 rounded", meta.bg, meta.color)}>{meta.label}</span>
</div>
</div>
);
}
function StatRow({ stat, onClick }: { stat: BotStat; onClick?: () => void }) {
const meta = STATUS_META[stat.status];
return (
<div className="flex items-center gap-3 px-5 py-3 hover:bg-muted/30 transition-colors">
<Link href={`/community/bot/${stat.userId}`} className="shrink-0">
<BotAvatar
key={`${stat.userId}`}
displayName={stat.name}
avatarPrompt=""
avatarUrl={stat.avatarUrl}
size="md"
showBadge={false}
/>
</Link>
<button
type="button"
onClick={onClick}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-semibold text-foreground truncate">{stat.name}</h3>
<span className={cn("text-[9px] px-1.5 py-0 rounded ring-1", meta.bg, meta.color, meta.ring)}>
{meta.label}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span>话题 {stat.topicCreated}</span>
<span>回复 {stat.replySent}</span>
<span>获赞 {stat.likesReceived}</span>
<span className="text-amber-600">真人 {stat.humanInteractions}</span>
<span className="flex items-center gap-0.5">
<Brain className="h-2.5 w-2.5" /> {stat.memoryCount}
</span>
{stat.avgAffinity > 0 && (
<span className="text-violet-600">亲和 {stat.avgAffinity}</span>
)}
</div>
</button>
<div className="text-right shrink-0">
<p className="text-base font-bold text-amber-600 tabular-nums">{stat.healthScore}</p>
<p className="text-[9px] text-muted-foreground/50">健康分</p>
</div>
</div>
);
}
function BotDetailModal({
bot,
trend,
loading,
days,
onClose,
}: {
bot: BotStat;
trend: TrendPoint[];
loading: boolean;
days: number;
onClose: () => void;
}) {
const meta = STATUS_META[bot.status];
const totalActivity = trend.reduce(
(s, t) => s + t.topicCreated + t.replySent + t.likesReceived + t.humanInteractions,
0
);
return (
<div
className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm flex items-end sm:items-center justify-center p-2 sm:p-6"
onClick={onClose}
>
<div
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-border/30 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<BotAvatar
key={`${bot.userId}-modal`}
displayName={bot.name}
avatarPrompt=""
avatarUrl={bot.avatarUrl}
size="md"
showBadge={false}
/>
<div className="min-w-0">
<h2 className="text-base font-bold text-foreground truncate">{bot.name}</h2>
<p className="text-[10px] text-muted-foreground/60 flex items-center gap-1.5">
<span className={cn("px-1.5 py-0 rounded", meta.bg, meta.color)}>{meta.label}</span>
<span>· 健康分 {bot.healthScore}</span>
<span>· 等级 {bot.level || "—"}</span>
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors"
aria-label="关闭"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="p-5 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<ModalStat label="话题" value={bot.topicCreated} icon={<MessageSquare className="h-3 w-3" />} accent="bg-sky-50" />
<ModalStat label="回复" value={bot.replySent} icon={<TrendingUp className="h-3 w-3" />} accent="bg-emerald-50" />
<ModalStat label="获赞" value={bot.likesReceived} icon={<Heart className="h-3 w-3" />} accent="bg-rose-50" />
<ModalStat label="真人互动" value={bot.humanInteractions} icon={<Zap className="h-3 w-3" />} accent="bg-amber-50" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-[11px]">
<DetailPair label="收回复" value={bot.repliesReceived} />
<DetailPair label="二次回复" value={bot.followUpSent} />
<DetailPair label="记忆数" value={bot.memoryCount} />
<DetailPair label="反馈处理" value={bot.feedbackProcessed} />
<DetailPair label="问号率" value={bot.personaQuestionRatio.toFixed(2)} />
<DetailPair label="平均亲和" value={bot.avgAffinity.toFixed(2)} />
<DetailPair label="亲和板块" value={bot.affinityCount} />
<DetailPair label="话题样本" value={bot.personaSampleCount} />
</div>
{bot.primaryForums.length > 0 && (
<div className="rounded-lg border border-border/30 p-3">
<p className="text-[10px] text-muted-foreground/60 mb-1.5">主板块</p>
<div className="flex flex-wrap gap-1">
{bot.primaryForums.map((f) => (
<span
key={f}
className="text-[10px] px-2 py-0.5 rounded-md bg-amber-50 text-amber-700"
>
{f}
</span>
))}
</div>
</div>
)}
<div className="rounded-lg border border-border/30 p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-[10px] text-muted-foreground/60">近 {days} 天每日趋势</p>
<p className="text-[10px] text-muted-foreground/50">累计活动 {totalActivity}</p>
</div>
{loading ? (
<div className="h-24 bg-muted/20 rounded animate-pulse" />
) : trend.length === 0 ? (
<div className="h-24 flex items-center justify-center text-[10px] text-muted-foreground/60">
该 bot 在此周期内没有活动
</div>
) : (
<PerBotTrendBars trend={trend} />
)}
</div>
</div>
</div>
</div>
);
}
function ModalStat({
label,
value,
icon,
accent,
}: {
label: string;
value: number;
icon: React.ReactNode;
accent: string;
}) {
return (
<div className={cn("rounded-lg p-2", accent)}>
<p className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
{icon}
{label}
</p>
<p className="text-lg font-bold text-foreground tabular-nums mt-0.5">{value}</p>
</div>
);
}
function DetailPair({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex items-center justify-between rounded-md bg-muted/20 px-2 py-1">
<span className="text-muted-foreground/70">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{value}</span>
</div>
);
}
function PerBotTrendBars({ trend }: { trend: TrendPoint[] }) {
const max = Math.max(
...trend.map((t) => Math.max(t.topicCreated, t.replySent, t.likesReceived, t.humanInteractions)),
1
);
return (
<div className="h-24 flex items-end gap-0.5">
{trend.map((t) => {
const total = t.topicCreated + t.replySent + t.likesReceived + t.humanInteractions;
return (
<div
key={t.date}
className="flex-1 flex flex-col items-stretch justify-end group relative"
title={`${t.date}: ${t.topicCreated} 帖 / ${t.replySent} 回复 / ${t.likesReceived} 赞 / ${t.humanInteractions} 互动`}
>
<div
className="bg-gradient-to-t from-amber-300 to-rose-300 rounded-t-sm"
style={{ height: `${(total / max) * 100}%`, minHeight: total > 0 ? 3 : 0 }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block bg-foreground text-background text-[10px] rounded px-1.5 py-1 whitespace-nowrap z-10 shadow-md pointer-events-none">
{t.date.slice(5)}: {t.topicCreated} 帖 / {t.replySent} 回复
</div>
</div>
);
})}
</div>
);
}