全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除

This commit is contained in:
ZhuiGuangAI Dev
2026-06-12 17:27:47 +08:00
parent 464924aea0
commit 4c325c8699
454 changed files with 33647 additions and 980 deletions
@@ -0,0 +1,764 @@
"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>
);
}