765 lines
28 KiB
TypeScript
765 lines
28 KiB
TypeScript
"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>
|
||
);
|
||
}
|