全项目扫描修复: 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,362 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import Link from "next/link";
import {
GraduationCap,
Trophy,
Sparkles,
RefreshCw,
Play,
Activity,
Eye,
Heart,
MessageSquare,
Users,
AlertCircle,
ChevronDown,
ChevronUp,
TrendingUp,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface SourceMetrics {
replyCount: number;
likeCount: number;
viewCount: number;
humanReplies: number;
engagementScore: number;
}
interface Learning {
id: number;
botId: number;
botUserId: number | null;
botName: string;
botAvatar: string | null;
weekKey: string;
sourceRefType: string;
sourceRefId: number;
sourceTitle: string | null;
sourceUserName: string | null;
sourceUserAvatar: string | null;
sourceMetrics: SourceMetrics;
relevanceScore: number;
learnedInsight: string;
learnCategories: string[];
status: string;
usedCount: number;
learnedAt: string;
expiresAt: string | null;
forumSlug: string | null;
categoryName: string | null;
}
interface Summary {
totalLearnings: number;
activeCount: number;
usedCount: number;
avgRelevance: number;
botCount: number;
weeks: number;
}
const RANGE_OPTIONS = [
{ value: 1, label: "近 1 周" },
{ value: 2, label: "近 2 周" },
{ value: 4, label: "近 4 周" },
{ value: 8, label: "近 8 周" },
];
const CATEGORY_META: Record<string, { label: string; color: string; bg: string }> = {
hook: { label: "钩子", color: "text-violet-700", bg: "bg-violet-50" },
story: { label: "故事", color: "text-amber-700", bg: "bg-amber-50" },
data: { label: "数据", color: "text-sky-700", bg: "bg-sky-50" },
question: { label: "提问", color: "text-emerald-700", bg: "bg-emerald-50" },
contrarian: { label: "反主流", color: "text-rose-700", bg: "bg-rose-50" },
empathy: { label: "共情", color: "text-pink-700", bg: "bg-pink-50" },
practical: { label: "实用", color: "text-slate-700", bg: "bg-slate-100" },
};
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
active: { label: "生效中", color: "text-emerald-700", bg: "bg-emerald-50" },
used: { label: "已用", color: "text-sky-700", bg: "bg-sky-50" },
expired: { label: "已过期", color: "text-slate-500", bg: "bg-slate-50" },
};
export default function BotAdversarialLearningClient() {
const [weeks, setWeeks] = useState(4);
const [data, setData] = useState<{ summary: Summary; learnings: Learning[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/bots/adversarial-learning?weeks=${weeks}`, { cache: "no-store" });
const json = await res.json();
if (!res.ok) throw new Error(json.error || "请求失败");
setData(json);
} catch (e: unknown) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [weeks]);
useEffect(() => {
fetchData();
}, [fetchData]);
const triggerRun = useCallback(
async (mode: "week" | "force") => {
setRunning(true);
setRunResult(null);
try {
const res = await fetch("/api/admin/bots/adversarial-learning/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lookback: 7, force: mode === "force" }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || "运行失败");
setRunResult(`✓ exitCode=${json.exitCode} duration=${json.duration}ms`);
await fetchData();
} catch (e: unknown) {
setRunResult(`✗ ${(e as Error).message}`);
} finally {
setRunning(false);
}
},
[fetchData]
);
const toggle = useCallback((id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const groupedByWeek = useMemo(() => {
const map = new Map<string, Learning[]>();
if (!data) return map;
for (const l of data.learnings) {
if (!map.has(l.weekKey)) map.set(l.weekKey, []);
map.get(l.weekKey)!.push(l);
}
return map;
}, [data]);
return (
<div className="p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl lg:text-3xl font-bold flex items-center gap-2">
<GraduationCap className="w-6 h-6 text-violet-600" />
Bot 对抗学习看板
</h1>
<p className="text-sm text-muted-foreground mt-1">
每周日 23:00 自动给每个 bot 选 1 篇过去 7 天真人发布的高互动帖,LLM 提取"为什么火"的洞察,下周 bot 发帖/回复时作为参考
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={weeks}
onChange={(e) => setWeeks(Number(e.target.value))}
className="px-3 py-2 text-sm rounded-md border bg-background"
>
{RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button
onClick={() => triggerRun("week")}
disabled={running}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
>
<Play className={cn("w-3.5 h-3.5", running && "animate-pulse")} />
本周学习
</button>
<button
onClick={() => triggerRun("force")}
disabled={running}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
>
<Sparkles className="w-3.5 h-3.5" />
强制重学
</button>
<button
onClick={fetchData}
disabled={loading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
>
<RefreshCw className={cn("w-3.5 h-3.5", loading && "animate-spin")} />
刷新
</button>
</div>
</div>
{runResult && (
<div
className={cn(
"p-3 rounded-md text-sm font-mono",
runResult.startsWith("✓")
? "bg-emerald-50 text-emerald-700"
: "bg-rose-50 text-rose-700"
)}
>
{runResult}
</div>
)}
{error && (
<div className="p-3 rounded-md bg-rose-50 text-rose-700 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> {error}
</div>
)}
{data && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<SummaryCard icon={<GraduationCap className="w-4 h-4" />} label="学习总数" value={data.summary.totalLearnings} accent="text-violet-600" />
<SummaryCard icon={<Activity className="w-4 h-4" />} label="生效中" value={data.summary.activeCount} accent="text-emerald-600" />
<SummaryCard icon={<Trophy className="w-4 h-4" />} label="引用次数" value={data.summary.usedCount} accent="text-amber-600" />
<SummaryCard icon={<TrendingUp className="w-4 h-4" />} label="平均关联度" value={data.summary.avgRelevance.toFixed(2)} accent="text-rose-600" />
<SummaryCard icon={<Users className="w-4 h-4" />} label="参与 bot" value={data.summary.botCount} accent="text-sky-600" />
</div>
)}
<div className="p-3 bg-violet-50/40 rounded-md text-xs text-slate-600 flex flex-wrap items-center gap-3">
<div><b>学习源</b>:过去 7 天 真人(非 bot)发布的高互动 topic(reply+like+humanReplies 排序)</div>
<div>·</div>
<div><b>关联度</b> = 同板块(1.0) + 关键词重叠(0.6) + 时效衰减(0.3)</div>
<div>·</div>
<div><b>每周 1 条</b>,unique(botId, weekKey),默认 7 天后过期</div>
</div>
{/* 按周分组 */}
{data && data.learnings.length === 0 && (
<div className="p-8 text-center text-muted-foreground text-sm border rounded-lg bg-slate-50/40">
还没有对抗学习数据。运行 <code className="px-1 bg-slate-100 rounded">node scripts/bot-adversarial-learning-run.mjs</code> 开始第一周,或点击"本周学习"按钮。
</div>
)}
{Array.from(groupedByWeek.entries()).map(([weekKey, items]) => (
<div key={weekKey} className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 text-violet-700 text-sm font-bold">
{weekKey.split("-W")[1]}
</span>
<span>第 {weekKey.split("-W")[1]} 周 ({weekKey})</span>
<span className="text-xs text-muted-foreground font-normal">· {items.length} 条学习</span>
</h2>
<div className="grid gap-3">
{items.map((l) => {
const isExpanded = expanded.has(l.id);
const statusMeta = STATUS_META[l.status] || STATUS_META.active;
return (
<div key={l.id} className="border rounded-lg bg-white">
<button
onClick={() => toggle(l.id)}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50"
>
{l.botAvatar ? (
<img src={l.botAvatar} alt={l.botName} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-sm font-medium">
{l.botName?.charAt(0) || "?"}
</div>
)}
<div className="flex-1 text-left min-w-0">
<div className="font-medium text-sm flex items-center gap-2">
{l.botName}
<span className="text-xs text-muted-foreground">←</span>
<span className="text-sm text-slate-700 truncate">{l.sourceTitle || `topic#${l.sourceRefId}`}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{l.sourceUserName && <>@{l.sourceUserName} · </>}
{l.categoryName && <>{l.categoryName} · </>}
关联度 {l.relevanceScore}
</div>
</div>
<div className="hidden md:flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3 h-3" /> {l.sourceMetrics.replyCount}</span>
<span className="inline-flex items-center gap-1"><Heart className="w-3 h-3 text-rose-500" /> {l.sourceMetrics.likeCount}</span>
<span className="inline-flex items-center gap-1"><Users className="w-3 h-3" /> {l.sourceMetrics.humanReplies}</span>
<span className="inline-flex items-center gap-1"><Eye className="w-3 h-3" /> {l.sourceMetrics.viewCount}</span>
</div>
<span className={cn("text-xs px-2 py-0.5 rounded-full font-medium", statusMeta.bg, statusMeta.color)}>
{statusMeta.label}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">
引用 {l.usedCount}
</span>
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{isExpanded && (
<div className="border-t bg-slate-50/40 p-4 space-y-3">
<div className="bg-white border rounded-md p-3">
<div className="text-xs font-medium text-muted-foreground mb-1">📌 来源真人帖</div>
<div className="text-sm font-medium">{l.sourceTitle}</div>
{l.sourceUserName && (
<div className="text-xs text-muted-foreground mt-1">作者:{l.sourceUserName}</div>
)}
</div>
<div className="bg-white border rounded-md p-3">
<div className="text-xs font-medium text-muted-foreground mb-1">🧠 LLM 提取的洞察</div>
<div className="text-sm whitespace-pre-wrap leading-relaxed">{l.learnedInsight}</div>
{l.learnCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{l.learnCategories.map((c) => {
const m = CATEGORY_META[c] || { label: c, color: "text-slate-600", bg: "bg-slate-100" };
return (
<span key={c} className={cn("text-xs px-1.5 py-0.5 rounded font-medium", m.bg, m.color)}>
{m.label}
</span>
);
})}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
生效窗口:{l.learnedAt.slice(0, 16)} → {l.expiresAt?.slice(0, 16) || "永久"}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
<div className="text-xs text-muted-foreground text-center">
<Link href="/admin/bot-analytics" className="hover:underline">
← 返回 Bot 表现分析
</Link>
</div>
</div>
);
}
function SummaryCard({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number | string; accent: string }) {
return (
<div className="border rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className={cn("text-2xl font-bold mt-1", accent)}>{value}</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { Suspense } from "react";
import BotAdversarialLearningClient from "./BotAdversarialLearningClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot 对抗学习看板 · 管理后台",
};
export default function BotAdversarialLearningPage() {
return (
<Suspense
fallback={
<div className="p-6 lg:p-8 text-center text-sm text-muted-foreground">加载中...</div>
}
>
<BotAdversarialLearningClient />
</Suspense>
);
}
@@ -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>
);
}
@@ -0,0 +1,19 @@
import { Suspense } from "react";
import BotAnalyticsClient from "./BotAnalyticsClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot 表现分析 · 管理后台",
};
export default function BotAnalyticsPage() {
return (
<Suspense
fallback={
<div className="p-6 lg:p-8 text-center text-sm text-muted-foreground">加载中...</div>
}
>
<BotAnalyticsClient />
</Suspense>
);
}
@@ -0,0 +1,611 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import Link from "next/link";
import {
FlaskConical,
Trophy,
Activity,
TrendingUp,
Heart,
MessageSquare,
Eye,
Sparkles,
RefreshCw,
Play,
Bot as BotIcon,
ChevronDown,
ChevronUp,
Check,
X,
AlertCircle,
BarChart3,
Sigma,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import { cn } from "@/lib/utils";
interface VariantDaily {
date: string;
contentCount: number;
replies: number;
likes: number;
humanReplies: number;
avgReplies: number;
avgLikes: number;
computedScore: number;
}
interface Variant {
id: number;
botId: number;
botUserId: number | null;
botName: string;
botAvatar: string | null;
primaryForums: string[];
variantKey: string;
label: string;
description: string | null;
isControl: boolean;
isActive: boolean;
weight: number;
experimentId: number | null;
sampleCount: number;
replyCount: number;
likeCount: number;
humanReplyCount: number;
engagementScore: number;
computedScore: number;
styleHints: unknown;
daily: VariantDaily[];
totalCounts: { replies: number; likes: number; humanReplies: number; impressions: number };
updatedAt: string;
}
interface Comparison {
variantKey: string;
label: string;
computedScore: number;
controlRate?: number;
sampleCount: number;
zScore?: number;
pValue?: number;
significant?: boolean;
winner?: boolean;
status: "winning" | "losing" | "monitoring" | "no_data";
}
interface Analysis {
botId: number;
botUserId: number | null;
botName: string;
botAvatar: string | null;
control: { variantKey: string; label: string; sampleCount: number; computedScore: number };
comparisons: Comparison[];
winner: { variantKey: string; pValue: number; sampleCount: number } | null;
status: "winner_found" | "no_significant_winner" | "collecting_data";
}
interface Experiment {
id: number;
botId: number;
name: string;
description: string | null;
status: string;
winnerVariantKey: string | null;
startedAt: string;
endedAt: string | null;
lastAnalyzedAt: string | null;
minSamplesPerArm: number;
significanceLevel: number;
}
interface Summary {
totalBots: number;
totalVariants: number;
totalExperiments: number;
activeExperiments: number;
completedExperiments: number;
winnersFound: number;
totalAssignments: number;
totalImpressions: number;
totalReplies: number;
totalLikes: number;
totalHumanReplies: number;
days: number;
}
const RANGE_OPTIONS = [
{ value: 3, label: "近 3 天" },
{ value: 7, label: "近 7 天" },
{ value: 14, label: "近 14 天" },
{ value: 30, label: "近 30 天" },
];
const STATUS_META: Record<string, { label: string; color: string; bg: string; icon: React.ReactNode }> = {
winner_found: { label: "已决出", color: "text-emerald-700", bg: "bg-emerald-50", icon: <Trophy className="w-3.5 h-3.5" /> },
no_significant_winner: { label: "差异不显著", color: "text-amber-700", bg: "bg-amber-50", icon: <BarChart3 className="w-3.5 h-3.5" /> },
collecting_data: { label: "收集中", color: "text-sky-700", bg: "bg-sky-50", icon: <Activity className="w-3.5 h-3.5" /> },
};
const COMP_STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
winning: { label: "胜出", color: "text-emerald-700", bg: "bg-emerald-50" },
losing: { label: "落后", color: "text-rose-700", bg: "bg-rose-50" },
monitoring: { label: "观察中", color: "text-slate-600", bg: "bg-slate-50" },
no_data: { label: "无数据", color: "text-slate-400", bg: "bg-slate-50" },
};
export default function BotExperimentsClient() {
const [days, setDays] = useState(7);
const [data, setData] = useState<{
summary: Summary;
variants: Variant[];
experiments: Experiment[];
analysis: Analysis[];
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedBots, setExpandedBots] = useState<Set<number>>(new Set());
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/bots/experiments?days=${days}`, { cache: "no-store" });
const json = await res.json();
if (!res.ok) throw new Error(json.error || "请求失败");
setData(json);
} catch (e: unknown) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [days]);
useEffect(() => {
fetchData();
}, [fetchData]);
const triggerRun = useCallback(async (mode: "all" | "metrics" | "analyze") => {
setRunning(true);
setRunResult(null);
try {
const res = await fetch("/api/admin/bots/experiments/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, lookback: days }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || "运行失败");
setRunResult(`✓ exitCode=${json.exitCode} duration=${json.duration}ms mode=${json.mode}`);
await fetchData();
} catch (e: unknown) {
setRunResult(`✗ ${(e as Error).message}`);
} finally {
setRunning(false);
}
}, [days, fetchData]);
const toggleBot = useCallback((botId: number) => {
setExpandedBots((prev) => {
const next = new Set(prev);
if (next.has(botId)) next.delete(botId);
else next.add(botId);
return next;
});
}, []);
const updateVariant = useCallback(async (variantId: number, patch: Record<string, unknown>) => {
try {
const res = await fetch("/api/admin/bots/experiments/variants", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variantId, ...patch }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || "更新失败");
await fetchData();
} catch (e: unknown) {
alert("更新失败: " + (e as Error).message);
}
}, [fetchData]);
const variantsByBot = useMemo(() => {
const map = new Map<number, Variant[]>();
if (!data) return map;
for (const v of data.variants) {
if (!map.has(v.botId)) map.set(v.botId, []);
map.get(v.botId)!.push(v);
}
for (const list of Array.from(map.values())) {
list.sort((a: Variant, b: Variant) => b.sampleCount - a.sampleCount);
}
return map;
}, [data]);
return (
<div className="p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl lg:text-3xl font-bold flex items-center gap-2">
<FlaskConical className="w-6 h-6 text-violet-600" />
Bot Persona A/B 看板
</h1>
<p className="text-sm text-muted-foreground mt-1">
每个 bot 同时跑 2-3 个 persona 变体,统计真实互动分(赞×3 + 真人回复×5 + 回复),显著性检验后自动切换 winner 权重
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="px-3 py-2 text-sm rounded-md border bg-background"
>
{RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button
onClick={() => triggerRun("metrics")}
disabled={running}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
>
<Sigma className="w-3.5 h-3.5" />
采指标
</button>
<button
onClick={() => triggerRun("all")}
disabled={running}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
>
<Play className={cn("w-3.5 h-3.5", running && "animate-pulse")} />
采指标 + 切换 winner
</button>
<button
onClick={fetchData}
disabled={loading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
>
<RefreshCw className={cn("w-3.5 h-3.5", loading && "animate-spin")} />
刷新
</button>
</div>
</div>
{runResult && (
<div className={cn(
"p-3 rounded-md text-sm font-mono",
runResult.startsWith("✓") ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700"
)}>
{runResult}
</div>
)}
{error && (
<div className="p-3 rounded-md bg-rose-50 text-rose-700 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> {error}
</div>
)}
{/* Summary Cards */}
{data && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<SummaryCard icon={<BotIcon className="w-4 h-4" />} label="Bot 数" value={data.summary.totalBots} accent="text-violet-600" />
<SummaryCard icon={<FlaskConical className="w-4 h-4" />} label="变体数" value={data.summary.totalVariants} accent="text-sky-600" />
<SummaryCard icon={<Activity className="w-4 h-4" />} label="活跃实验" value={data.summary.activeExperiments} accent="text-emerald-600" />
<SummaryCard icon={<Trophy className="w-4 h-4" />} label="已决出 winner" value={data.summary.winnersFound} accent="text-amber-600" />
<SummaryCard icon={<Sparkles className="w-4 h-4" />} label="总样本" value={data.summary.totalAssignments} accent="text-rose-600" />
<SummaryCard icon={<Heart className="w-4 h-4" />} label="总互动" value={data.summary.totalReplies + data.summary.totalLikes} accent="text-pink-600" />
</div>
)}
{/* Legend */}
<div className="p-3 bg-violet-50/40 rounded-md text-xs text-slate-600 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1"><Sigma className="w-3 h-3" /> <b>综合分</b>= (赞×3 + 真人回复×5 + 回复) ÷ 样本数</div>
<div>·</div>
<div><b>显著性</b>= z 检验,p&lt;0.1 且样本≥20</div>
<div>·</div>
<div><b>切换</b>= winner 权重提到 0.85,其他降到 0.075,experiment → completed</div>
</div>
{/* Analysis List */}
{data && data.analysis.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
显著性分析({data.analysis.length} 个 bot)
</h2>
<div className="grid gap-3">
{data.analysis.map((a) => {
const variants = variantsByBot.get(a.botId) || [];
const expanded = expandedBots.has(a.botId);
const meta = STATUS_META[a.status];
return (
<div key={a.botId} className="border rounded-lg bg-white">
<button
onClick={() => toggleBot(a.botId)}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50"
>
{a.botAvatar ? (
<img src={a.botAvatar} alt={a.botName} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-sm font-medium">
{a.botName?.charAt(0) || "?"}
</div>
)}
<div className="flex-1 text-left">
<div className="font-medium">{a.botName}</div>
<div className="text-xs text-muted-foreground">
{variants.length} 变体 · control 样本 {a.control.sampleCount} · 控综合分 {a.control.computedScore}
</div>
</div>
{a.winner && (
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200">
<Trophy className="w-3 h-3" /> winner={a.winner.variantKey} (p={a.winner.pValue})
</span>
)}
<span className={cn("inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full", meta.bg, meta.color)}>
{meta.icon} {meta.label}
</span>
{expanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{expanded && (
<div className="border-t bg-slate-50/40 p-4 space-y-3">
<div className="grid gap-2">
{a.comparisons.map((c) => {
const v = variants.find((x) => x.variantKey === c.variantKey);
if (!v) return null;
const cm = COMP_STATUS_META[c.status];
return (
<div key={c.variantKey} className="bg-white border rounded-md p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm">{c.label}</span>
{v.isControl && (
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">control</span>
)}
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", cm.bg, cm.color)}>
{cm.label}
</span>
{c.winner && <Trophy className="w-3.5 h-3.5 text-amber-500" />}
</div>
<div className="text-xs text-muted-foreground">
样本 {c.sampleCount} · z={c.zScore} · p={c.pValue}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-xs mb-2">
<MetricCell label="权重" value={v.weight.toFixed(2)} />
<MetricCell label="综合分" value={c.computedScore.toFixed(2)} highlight />
<MetricCell label="收赞" value={v.likeCount} />
<MetricCell label="收真人" value={v.humanReplyCount} />
<MetricCell label="收回复" value={v.replyCount} />
</div>
{(() => {
const hints = v.styleHints as Record<string, unknown> | null;
if (!hints || typeof hints !== "object" || Object.keys(hints).length === 0) return null;
return (
<details className="text-xs text-slate-500">
<summary className="cursor-pointer hover:text-slate-700">style hints</summary>
<pre className="mt-1 p-2 bg-slate-50 rounded text-[10px] overflow-auto">
{JSON.stringify(hints, null, 2)}
</pre>
</details>
);
})()}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<label className="flex items-center gap-1">
权重
<input
type="number"
min={0}
max={1}
step={0.05}
defaultValue={v.weight}
onBlur={(e) => {
const val = parseFloat(e.target.value);
if (!isNaN(val) && val !== v.weight) {
updateVariant(v.id, { weight: val });
}
}}
className="w-16 px-1.5 py-0.5 border rounded text-xs"
/>
</label>
<button
onClick={() => updateVariant(v.id, { isActive: !v.isActive })}
className={cn(
"px-2 py-0.5 rounded border text-xs",
v.isActive
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-slate-50 text-slate-500 border-slate-200"
)}
>
{v.isActive ? <><Check className="w-3 h-3 inline" /> 启用</> : <><X className="w-3 h-3 inline" /> 停用</>}
</button>
<button
onClick={() => {
if (confirm(`确认重置 ${v.label} 的统计数据?`)) {
updateVariant(v.id, { resetStats: true });
}
}}
className="px-2 py-0.5 rounded border text-xs text-rose-600 border-rose-200 hover:bg-rose-50"
>
重置样本
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Experiments Timeline */}
{data && data.experiments.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Activity className="w-4 h-4" />
实验状态({data.experiments.length} 个)
</h2>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium">#</th>
<th className="px-3 py-2 text-left font-medium">Bot</th>
<th className="px-3 py-2 text-left font-medium">状态</th>
<th className="px-3 py-2 text-left font-medium">winner</th>
<th className="px-3 py-2 text-left font-medium">min samples</th>
<th className="px-3 py-2 text-left font-medium">p 阈值</th>
<th className="px-3 py-2 text-left font-medium">开始</th>
<th className="px-3 py-2 text-left font-medium">最近分析</th>
</tr>
</thead>
<tbody>
{data.experiments.slice(0, 20).map((e) => {
const botConfig = data.variants.find((v) => v.botId === e.botId);
return (
<tr key={e.id} className="border-t hover:bg-slate-50/50">
<td className="px-3 py-2 text-slate-500">#{e.id}</td>
<td className="px-3 py-2 font-medium">{botConfig?.botName || `bot#${e.botId}`}</td>
<td className="px-3 py-2">
<span className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium",
e.status === "active" && "bg-emerald-50 text-emerald-700",
e.status === "completed" && "bg-sky-50 text-sky-700",
e.status === "paused" && "bg-amber-50 text-amber-700"
)}>
{e.status}
</span>
</td>
<td className="px-3 py-2">{e.winnerVariantKey || "—"}</td>
<td className="px-3 py-2">{e.minSamplesPerArm}</td>
<td className="px-3 py-2">{e.significanceLevel}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{e.startedAt?.slice(0, 16)}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{e.lastAnalyzedAt?.slice(0, 16) || "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Variants Detail Table */}
{data && data.variants.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Sparkles className="w-4 h-4" />
变体总览({data.variants.length} 条)
</h2>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium">Bot</th>
<th className="px-3 py-2 text-left font-medium">变体</th>
<th className="px-3 py-2 text-right font-medium">样本</th>
<th className="px-3 py-2 text-right font-medium">收赞</th>
<th className="px-3 py-2 text-right font-medium">收真人</th>
<th className="px-3 py-2 text-right font-medium">收回复</th>
<th className="px-3 py-2 text-right font-medium">综合分</th>
<th className="px-3 py-2 text-right font-medium">权重</th>
<th className="px-3 py-2 text-left font-medium">状态</th>
</tr>
</thead>
<tbody>
{data.variants
.slice()
.sort((a, b) => b.computedScore - a.computedScore)
.slice(0, 60)
.map((v) => (
<tr key={v.id} className="border-t hover:bg-slate-50/50">
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
{v.botAvatar ? (
<img src={v.botAvatar} alt={v.botName} className="w-5 h-5 rounded-full object-cover" />
) : (
<div className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-[10px]">
{v.botName?.charAt(0) || "?"}
</div>
)}
<span className="text-xs">{v.botName}</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono">{v.variantKey}</span>
{v.isControl && (
<span className="text-[10px] px-1 rounded bg-slate-100 text-slate-500">ctrl</span>
)}
</div>
</td>
<td className="px-3 py-2 text-right font-mono text-xs">{v.sampleCount}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-rose-600">{v.likeCount}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-emerald-600">{v.humanReplyCount}</td>
<td className="px-3 py-2 text-right font-mono text-xs">{v.replyCount}</td>
<td className="px-3 py-2 text-right font-mono text-xs font-semibold">
{v.computedScore.toFixed(2)}
</td>
<td className="px-3 py-2 text-right font-mono text-xs">{v.weight.toFixed(2)}</td>
<td className="px-3 py-2">
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded",
v.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
)}>
{v.isActive ? "启用" : "停用"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{data && data.analysis.length === 0 && (
<div className="p-8 text-center text-muted-foreground text-sm border rounded-lg bg-slate-50/40">
还没有 A/B 测试数据。先调用 <code className="px-1 bg-slate-100 rounded">node scripts/seed-persona-variants.mjs</code> 初始化变体,再等 bot 跑几轮后采集数据。
</div>
)}
<div className="text-xs text-muted-foreground text-center">
<Link href="/admin/bot-analytics" className="hover:underline">
← 返回 Bot 表现分析
</Link>
</div>
</div>
);
}
function SummaryCard({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent: string }) {
return (
<div className="border rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className={cn("text-2xl font-bold mt-1", accent)}>{value}</div>
</div>
);
}
function MetricCell({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) {
return (
<div className={cn("rounded px-2 py-1", highlight ? "bg-violet-50" : "bg-slate-50")}>
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className={cn("font-mono font-medium", highlight && "text-violet-700")}>{value}</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { Suspense } from "react";
import BotExperimentsClient from "./BotExperimentsClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot Persona A/B 看板 · 管理后台",
};
export default function BotExperimentsPage() {
return (
<Suspense
fallback={
<div className="p-6 lg:p-8 text-center text-sm text-muted-foreground">加载中...</div>
}
>
<BotExperimentsClient />
</Suspense>
);
}
+141
View File
@@ -0,0 +1,141 @@
import Link from "next/link";
import { Flag, MessageSquare, Sparkles, Lock, EyeOff, Activity, ShieldCheck, ArrowRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
export default async function AdminForumHome() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/admin/login");
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return (
<div className="rounded-2xl border border-rose-200/60 bg-rose-50 p-6 text-sm text-rose-700">
缺少 forum:moderate 权限
</div>
);
}
const [pendingReports, hiddenTopics, lockedTopics, todayNew, recentLogs] = await Promise.all([
prisma.forumReport.count({ where: { status: "pending" } }),
prisma.forumTopic.count({ where: { isHidden: true } }),
prisma.forumTopic.count({ where: { isLocked: true } }),
prisma.forumTopic.count({
where: { createdAt: { gte: new Date(new Date().setHours(0, 0, 0, 0)) } },
}),
prisma.forumModerationLog.findMany({
orderBy: { createdAt: "desc" },
take: 10,
include: { actor: { select: { id: true, name: true, email: true } } },
}),
]);
const stats = [
{ label: "待处理举报", value: pendingReports, icon: Flag, href: "/admin/forum/reports", color: "text-rose-600", bg: "bg-rose-50" },
{ label: "已隐藏话题", value: hiddenTopics, icon: EyeOff, href: "/admin/forum/topics?status=hidden", color: "text-slate-600", bg: "bg-slate-50" },
{ label: "已锁定话题", value: lockedTopics, icon: Lock, href: "/admin/forum/topics?status=locked", color: "text-slate-600", bg: "bg-slate-50" },
{ label: "今日新话题", value: todayNew, icon: Sparkles, href: "/admin/forum/topics", color: "text-emerald-600", bg: "bg-emerald-50" },
];
return (
<div>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
论坛管理
</h1>
<p className="mt-1 text-sm text-muted-foreground/70">
维护社区秩序:审核举报、控制话题状态、查看操作日志
</p>
</div>
</header>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
{stats.map((s) => (
<Link
key={s.label}
href={s.href}
className="rounded-2xl border border-border/50 bg-white p-4 shadow-[0_1px_3px_rgba(0,0,0,0.04)] hover:border-primary/40 hover:shadow-md transition-all"
>
<div className={`inline-flex items-center justify-center h-8 w-8 rounded-lg ${s.bg} mb-2`}>
<s.icon className={`h-4 w-4 ${s.color}`} />
</div>
<div className="text-2xl font-bold tabular-nums">{s.value}</div>
<div className="text-[11px] text-muted-foreground/60 mt-0.5">{s.label}</div>
</Link>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-3 border-b border-border/30 flex items-center justify-between">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Activity className="h-4 w-4 text-primary" />
快速入口
</h2>
</div>
<div className="p-3 space-y-1">
<Link
href="/admin/forum/reports"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-sky-50/40 transition-colors group"
>
<Flag className="h-4 w-4 text-rose-500" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">举报审核队列</div>
<div className="text-[11px] text-muted-foreground/60">处理用户举报,决定隐藏/删除/忽略</div>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-primary" />
</Link>
<Link
href="/admin/forum/topics"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-sky-50/40 transition-colors group"
>
<MessageSquare className="h-4 w-4 text-emerald-500" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">话题批量管理</div>
<div className="text-[11px] text-muted-foreground/60">置顶/精华/锁定/隐藏/删除</div>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-primary" />
</Link>
</div>
</div>
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-3 border-b border-border/30">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Activity className="h-4 w-4 text-amber-500" />
最近操作日志
</h2>
</div>
<div className="divide-y divide-border/30 max-h-[400px] overflow-y-auto">
{recentLogs.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground/50">暂无操作</div>
) : (
recentLogs.map((log) => (
<div key={log.id} className="px-5 py-2.5 text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono text-foreground/60">#{log.id}</span>
<span className="rounded bg-sky-50 text-sky-700 px-1.5 py-0.5 border border-sky-200/40 font-semibold">
{log.action}
</span>
<span className="text-muted-foreground/60">·</span>
<span className="text-muted-foreground/80">{log.targetType} #{log.targetId}</span>
</div>
<div className="text-[10px] text-muted-foreground/50 mt-0.5">
{log.actor.name || log.actor.email.split("@")[0]} · {new Date(log.createdAt).toLocaleString("zh-CN")}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,398 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { Flag, Loader2, Eye, EyeOff, Trash2, X, Check } from "lucide-react";
import { cn } from "@/lib/utils";
type ReportRow = {
id: number;
reporterId: number;
topicId: number | null;
postId: number | null;
reason: string;
detail: string | null;
status: string;
resolution: string | null;
resolvedBy: number | null;
resolvedAt: string | null;
createdAt: string;
reporter: { id: number; name: string | null; email: string; avatarUrl: string | null; level?: string; isBot?: boolean };
resolver: { id: number; name: string | null; email: string } | null;
topic: {
id: number;
title: string;
isHidden: boolean;
userId: number;
user: { id: number; name: string | null; email: string };
} | null;
post: {
id: number;
content: string;
isHidden: boolean;
topicId: number;
userId: number;
user: { id: number; name: string | null; email: string };
} | null;
};
const REASON_LABEL: Record<string, string> = {
spam: "垃圾广告",
abuse: "人身攻击",
inappropriate: "不当内容",
illegal: "违法违规",
duplicate: "重复内容",
other: "其他",
};
export default function ReportsClient() {
const params = useSearchParams();
const router = useRouter();
const status = params.get("status") || "pending";
const page = parseInt(params.get("page") || "1", 10);
const [reports, setReports] = useState<ReportRow[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [operating, setOperating] = useState<number | null>(null);
const [error, setError] = useState("");
const [preview, setPreview] = useState<ReportRow | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch(
`/api/admin/forum/reports?status=${status}&page=${page}&pageSize=20`
);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error || "加载失败");
setReports([]);
return;
}
const d = await res.json();
setReports(d.reports || []);
setTotalCount(d.totalCount || 0);
} catch {
setError("网络错误");
} finally {
setLoading(false);
}
}, [status, page]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleResolve = async (report: ReportRow, resolution: "hidden" | "dismissed" | "deleted") => {
if (resolution === "deleted" && !confirm("确认删除该内容?此操作不可恢复。")) return;
setOperating(report.id);
try {
const res = await fetch(`/api/admin/forum/reports/${report.id}/resolve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error || "处理失败");
return;
}
await fetchData();
} catch {
alert("网络错误");
} finally {
setOperating(null);
}
};
const handleTopicAction = async (topicId: number, action: string) => {
if (action === "delete" && !confirm("确认删除该话题?此操作不可恢复。")) return;
setOperating(-topicId);
try {
const res = await fetch(`/api/admin/forum/topics/${topicId}/moderate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, reason: "管理员操作" }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error || "操作失败");
return;
}
await fetchData();
} catch {
alert("网络错误");
} finally {
setOperating(null);
}
};
const statusTabs = [
{ key: "pending", label: "待处理" },
{ key: "resolved", label: "已处理" },
{ key: "all", label: "全部" },
];
return (
<div>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">举报队列</h1>
<p className="mt-1 text-sm text-muted-foreground/70">
审核用户举报的违规内容,决定隐藏/删除或忽略
</p>
</div>
<div className="text-sm text-muted-foreground">
共 <span className="font-semibold text-foreground/80 tabular-nums">{totalCount}</span> 条
</div>
</header>
<div className="mb-4 flex items-center gap-1.5">
{statusTabs.map((tab) => (
<button
key={tab.key}
onClick={() => {
const p = new URLSearchParams(params.toString());
p.set("status", tab.key);
p.delete("page");
router.push(`/admin/forum/reports?${p.toString()}`);
}}
className={cn(
"rounded-full px-3.5 h-8 inline-flex items-center text-xs font-medium transition-colors",
status === tab.key
? "bg-primary text-white"
: "bg-white border border-border/50 text-muted-foreground hover:border-primary/40 hover:text-primary"
)}
>
{tab.label}
</button>
))}
</div>
{error && (
<div className="mb-4 rounded-xl border border-rose-200/60 bg-rose-50 px-4 py-2.5 text-sm text-rose-700">
{error}
</div>
)}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground/60">
<Loader2 className="h-4 w-4 animate-spin" />
加载中…
</div>
) : reports.length === 0 ? (
<div className="py-16 text-center">
<Flag className="mx-auto h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground/60">
{status === "pending" ? "当前没有待处理的举报" : "暂无数据"}
</p>
</div>
) : (
<ul className="divide-y divide-border/30">
{reports.map((r) => {
const targetIsTopic = !!r.topic;
const target = r.topic || r.post;
const author = target?.user;
const isOperating = operating === r.id || (target && operating === -target.id);
return (
<li
key={r.id}
className={cn(
"px-5 py-4 hover:bg-sky-50/30 transition-colors",
r.status === "resolved" && "opacity-70"
)}
>
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-xl bg-gradient-to-br from-rose-50 to-amber-50 border border-rose-200/40 flex items-center justify-center text-base">
{targetIsTopic ? "📝" : "💬"}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground/60">
<span className="font-mono text-foreground/60">#{r.id}</span>
<span className="rounded-md bg-rose-50 border border-rose-200/60 px-1.5 py-0.5 text-rose-700 font-semibold">
{REASON_LABEL[r.reason] || r.reason}
</span>
{targetIsTopic ? (
<span className="text-muted-foreground/50">话题举报</span>
) : (
<span className="text-muted-foreground/50">回复举报</span>
)}
<span>·</span>
<span>举报人 {r.reporter.name || r.reporter.email.split("@")[0]}</span>
<span>·</span>
<span>{new Date(r.createdAt).toLocaleString("zh-CN")}</span>
{r.status === "resolved" && (
<span className="rounded-md bg-emerald-50 border border-emerald-200/60 px-1.5 py-0.5 text-emerald-700 font-semibold">
{r.resolution === "hidden" ? "已隐藏" : r.resolution === "deleted" ? "已删除" : "已忽略"}
</span>
)}
</div>
{r.detail && (
<div className="mt-2 rounded-lg bg-slate-50 border border-slate-200/60 px-3 py-2 text-xs text-foreground/80">
<span className="text-muted-foreground/50">说明:</span>
{r.detail}
</div>
)}
<div className="mt-2 rounded-lg border border-border/50 bg-white p-3">
{targetIsTopic ? (
<Link
href={`/community/topic/${r.topic!.id}`}
target="_blank"
className="block group"
>
<div className="text-[10px] text-muted-foreground/50 mb-0.5">
话题 · {author?.name || author?.email.split("@")[0]}
</div>
<div className="text-sm font-medium text-foreground/90 group-hover:text-sky-700">
{r.topic!.title}
{r.topic!.isHidden && (
<span className="ml-2 text-[10px] rounded bg-slate-100 text-slate-500 px-1.5 py-0.5">
已隐藏
</span>
)}
</div>
</Link>
) : (
<Link
href={`/community/topic/${r.post!.topicId}#post-${r.post!.id}`}
target="_blank"
className="block group"
>
<div className="text-[10px] text-muted-foreground/50 mb-0.5">
回复 · {author?.name || author?.email.split("@")[0]}
</div>
<div className="text-xs text-foreground/85 line-clamp-2 group-hover:text-sky-700">
{r.post!.content}
{r.post!.isHidden && (
<span className="ml-2 text-[10px] rounded bg-slate-100 text-slate-500 px-1.5 py-0.5">
已隐藏
</span>
)}
</div>
</Link>
)}
</div>
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
{r.status === "pending" ? (
<>
<button
onClick={() => handleResolve(r, "hidden")}
disabled={!!isOperating}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-white px-2.5 h-7 text-[11px] font-medium text-foreground/80 hover:border-amber-300 hover:text-amber-700 hover:bg-amber-50/50 disabled:opacity-50"
>
<EyeOff className="h-3 w-3" />
隐藏
</button>
<button
onClick={() => handleResolve(r, "deleted")}
disabled={!!isOperating}
className="inline-flex items-center gap-1 rounded-md border border-rose-200/60 bg-rose-50 px-2.5 h-7 text-[11px] font-medium text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<Trash2 className="h-3 w-3" />
删除
</button>
<button
onClick={() => handleResolve(r, "dismissed")}
disabled={!!isOperating}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-white px-2.5 h-7 text-[11px] font-medium text-muted-foreground hover:border-sky-300 hover:text-sky-700 disabled:opacity-50"
>
<Check className="h-3 w-3" />
忽略
</button>
</>
) : (
<span className="text-[10px] text-muted-foreground/50">
{r.resolver?.name || r.resolver?.email} 处理于 {r.resolvedAt && new Date(r.resolvedAt).toLocaleString("zh-CN")}
</span>
)}
<span className="ml-auto" />
<button
onClick={() => setPreview(r)}
className="inline-flex items-center gap-1 rounded-md text-[11px] text-muted-foreground/60 hover:text-sky-600 px-1.5 h-7"
>
<Eye className="h-3 w-3" />
预览
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* 预览抽屉 */}
{preview && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={() => setPreview(null)}
>
<div
className="relative z-10 w-full max-w-2xl max-h-[80vh] overflow-y-auto rounded-2xl bg-white shadow-2xl border border-border/60"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 flex items-center justify-between border-b border-border/40 px-5 py-3 bg-white">
<h2 className="text-sm font-semibold">举报详情 · #{preview.id}</h2>
<button
onClick={() => setPreview(null)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted/50"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5 space-y-3 text-sm">
<div>
<div className="text-xs text-muted-foreground/60 mb-1">举报原因</div>
<div className="rounded-lg bg-rose-50 border border-rose-200/60 px-3 py-2 text-rose-700">
{REASON_LABEL[preview.reason] || preview.reason}
</div>
</div>
{preview.detail && (
<div>
<div className="text-xs text-muted-foreground/60 mb-1">补充说明</div>
<div className="rounded-lg bg-slate-50 border border-slate-200/60 px-3 py-2 text-foreground/80">
{preview.detail}
</div>
</div>
)}
{preview.topic && (
<div>
<div className="text-xs text-muted-foreground/60 mb-1">被举报话题</div>
<Link
href={`/community/topic/${preview.topic.id}`}
target="_blank"
className="block rounded-lg border border-border/50 bg-white px-3 py-2 hover:border-sky-300 hover:bg-sky-50/30"
>
<div className="font-medium">{preview.topic.title}</div>
</Link>
</div>
)}
{preview.post && (
<div>
<div className="text-xs text-muted-foreground/60 mb-1">被举报回复</div>
<Link
href={`/community/topic/${preview.post.topicId}#post-${preview.post.id}`}
target="_blank"
className="block rounded-lg border border-border/50 bg-white px-3 py-2 hover:border-sky-300 hover:bg-sky-50/30 whitespace-pre-wrap"
>
{preview.post.content}
</Link>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,12 @@
import { Suspense } from "react";
import ReportsClient from "./ReportsClient";
export const dynamic = "force-dynamic";
export default function ReportsPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground/60">加载中…</div>}>
<ReportsClient />
</Suspense>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { Pin, PinOff, Sparkles, Lock, Unlock, EyeOff, Eye, Trash2, Loader2, Search, MessageSquare, Flag } from "lucide-react";
import { cn } from "@/lib/utils";
type TopicRow = {
id: number;
title: string;
isPinned: boolean;
isLocked: boolean;
isFeatured: boolean;
isHidden: boolean;
viewCount: number;
replyCount: number;
likeCount: number;
updatedAt: string;
createdAt: string;
category: { name: string; slug: string; icon: string | null };
user: { id: number; name: string | null; email: string; level?: string; isBot?: boolean };
_count: { posts: number; likes: number; reports: number };
};
export default function TopicsClient() {
const params = useSearchParams();
const router = useRouter();
const status = params.get("status") || "all";
const page = parseInt(params.get("page") || "1", 10);
const q = params.get("q") || "";
const [topics, setTopics] = useState<TopicRow[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [operating, setOperating] = useState<number | null>(null);
const [searchInput, setSearchInput] = useState(q);
const [error, setError] = useState("");
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch(
`/api/admin/forum/topics?status=${status}&page=${page}&pageSize=20&q=${encodeURIComponent(q)}`
);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error || "加载失败");
return;
}
const d = await res.json();
setTopics(d.topics || []);
setTotalCount(d.totalCount || 0);
} catch {
setError("网络错误");
} finally {
setLoading(false);
}
}, [status, page, q]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleAction = async (topicId: number, action: string) => {
if (action === "delete" && !confirm("确认删除该话题?此操作不可恢复。")) return;
setOperating(topicId);
try {
const res = await fetch(`/api/admin/forum/topics/${topicId}/moderate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, reason: "管理员操作" }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error || "操作失败");
return;
}
await fetchData();
} catch {
alert("网络错误");
} finally {
setOperating(null);
}
};
const statusTabs = [
{ key: "all", label: "全部" },
{ key: "visible", label: "正常" },
{ key: "hidden", label: "已隐藏" },
{ key: "pinned", label: "置顶" },
{ key: "featured", label: "精华" },
{ key: "locked", label: "锁定" },
];
return (
<div>
<header className="mb-6 flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">话题管理</h1>
<p className="mt-1 text-sm text-muted-foreground/70">
隐藏、置顶、精华、加锁、删除等管理操作
</p>
</div>
<div className="text-sm text-muted-foreground">
共 <span className="font-semibold text-foreground/80 tabular-nums">{totalCount}</span> 个话题
</div>
</header>
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
{statusTabs.map((tab) => (
<button
key={tab.key}
onClick={() => {
const p = new URLSearchParams(params.toString());
p.set("status", tab.key);
p.delete("page");
router.push(`/admin/forum/topics?${p.toString()}`);
}}
className={cn(
"rounded-full px-3.5 h-8 inline-flex items-center text-xs font-medium transition-colors",
status === tab.key
? "bg-primary text-white"
: "bg-white border border-border/50 text-muted-foreground hover:border-primary/40 hover:text-primary"
)}
>
{tab.label}
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const p = new URLSearchParams(params.toString());
if (searchInput.trim()) p.set("q", searchInput.trim());
else p.delete("q");
p.delete("page");
router.push(`/admin/forum/topics?${p.toString()}`);
}}
className="flex items-center gap-1.5 rounded-full border border-border/50 bg-white px-3 h-8 w-64"
>
<Search className="h-3.5 w-3.5 text-muted-foreground/50" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索标题或正文…"
className="flex-1 min-w-0 bg-transparent text-xs focus:outline-none"
/>
</form>
</div>
{error && (
<div className="mb-4 rounded-xl border border-rose-200/60 bg-rose-50 px-4 py-2.5 text-sm text-rose-700">
{error}
</div>
)}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground/60">
<Loader2 className="h-4 w-4 animate-spin" />
加载中…
</div>
) : topics.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground/60">
没有符合条件的话题
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50/60 border-b border-border/40 text-[11px] text-muted-foreground/60 uppercase tracking-wider">
<tr>
<th className="px-4 py-2.5 text-left font-semibold">话题</th>
<th className="px-4 py-2.5 text-left font-semibold w-24">状态</th>
<th className="px-4 py-2.5 text-left font-semibold w-32">作者</th>
<th className="px-4 py-2.5 text-left font-semibold w-24 text-right">数据</th>
<th className="px-4 py-2.5 text-left font-semibold w-24 text-right">举报</th>
<th className="px-4 py-2.5 text-left font-semibold w-72">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{topics.map((t) => {
const authorName = t.user.name || t.user.email.split("@")[0];
const isOperating = operating === t.id;
return (
<tr key={t.id} className={cn("hover:bg-sky-50/30 transition-colors", t.isHidden && "opacity-60")}>
<td className="px-4 py-3 align-top">
<Link
href={`/community/topic/${t.id}`}
target="_blank"
className="block group"
>
<div className="text-xs text-muted-foreground/50 mb-0.5">
{t.category.icon} {t.category.name} · #{t.id}
</div>
<div className="text-sm font-medium text-foreground/90 group-hover:text-sky-700 line-clamp-1">
{t.title}
</div>
</Link>
</td>
<td className="px-4 py-3 align-top">
<div className="flex flex-wrap items-center gap-1">
{t.isPinned && <span className="text-[10px] rounded bg-amber-50 text-amber-700 px-1.5 py-0.5 border border-amber-200/40">置顶</span>}
{t.isFeatured && <span className="text-[10px] rounded bg-rose-50 text-rose-700 px-1.5 py-0.5 border border-rose-200/40">精华</span>}
{t.isLocked && <span className="text-[10px] rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 border border-slate-200/40">锁定</span>}
{t.isHidden && <span className="text-[10px] rounded bg-slate-200 text-slate-700 px-1.5 py-0.5">隐藏</span>}
{!t.isPinned && !t.isFeatured && !t.isLocked && !t.isHidden && (
<span className="text-[10px] text-muted-foreground/40">普通</span>
)}
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="text-xs truncate max-w-[120px]">{authorName}</div>
{t.user.isBot && (
<span className="text-[9px] rounded bg-violet-50 text-violet-600 px-1 border border-violet-200/40">BOT</span>
)}
</td>
<td className="px-4 py-3 align-top text-[11px] text-muted-foreground/60 text-right tabular-nums">
<div className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{t._count.posts}
</div>
<div className="text-[10px]">浏览 {t.viewCount}</div>
</td>
<td className="px-4 py-3 align-top text-right">
{t._count.reports > 0 ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200/60 px-1.5 py-0.5 text-[10px] font-semibold">
<Flag className="h-2.5 w-2.5" />
{t._count.reports}
</span>
) : (
<span className="text-[10px] text-muted-foreground/40">0</span>
)}
</td>
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-1 flex-wrap">
<button
onClick={() => handleAction(t.id, t.isPinned ? "unpin" : "pin")}
disabled={!!isOperating}
title={t.isPinned ? "取消置顶" : "置顶"}
className="inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white px-1.5 h-6 text-[10px] font-medium text-muted-foreground hover:border-amber-300 hover:text-amber-700 hover:bg-amber-50/50 disabled:opacity-50"
>
{t.isPinned ? <PinOff className="h-2.5 w-2.5" /> : <Pin className="h-2.5 w-2.5" />}
</button>
<button
onClick={() => handleAction(t.id, t.isFeatured ? "unfeature" : "feature")}
disabled={!!isOperating}
title={t.isFeatured ? "取消精华" : "加精华"}
className="inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white px-1.5 h-6 text-[10px] font-medium text-muted-foreground hover:border-rose-300 hover:text-rose-700 hover:bg-rose-50/50 disabled:opacity-50"
>
<Sparkles className="h-2.5 w-2.5" />
</button>
<button
onClick={() => handleAction(t.id, t.isLocked ? "unlock" : "lock")}
disabled={!!isOperating}
title={t.isLocked ? "解锁" : "锁定"}
className="inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white px-1.5 h-6 text-[10px] font-medium text-muted-foreground hover:border-slate-400 hover:text-slate-700 disabled:opacity-50"
>
{t.isLocked ? <Unlock className="h-2.5 w-2.5" /> : <Lock className="h-2.5 w-2.5" />}
</button>
<button
onClick={() => handleAction(t.id, t.isHidden ? "unhide" : "hide")}
disabled={!!isOperating}
title={t.isHidden ? "取消隐藏" : "隐藏"}
className="inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white px-1.5 h-6 text-[10px] font-medium text-muted-foreground hover:border-slate-400 hover:text-slate-700 disabled:opacity-50"
>
{t.isHidden ? <Eye className="h-2.5 w-2.5" /> : <EyeOff className="h-2.5 w-2.5" />}
</button>
<button
onClick={() => handleAction(t.id, "delete")}
disabled={!!isOperating}
title="删除"
className="inline-flex items-center gap-0.5 rounded-md border border-rose-200/60 bg-rose-50 px-1.5 h-6 text-[10px] font-medium text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{totalCount > 20 && (
<div className="mt-4 flex items-center justify-center gap-1.5">
{Array.from({ length: Math.min(Math.ceil(totalCount / 20), 10) }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => {
const url = new URLSearchParams(params.toString());
url.set("page", String(p));
router.push(`/admin/forum/topics?${url.toString()}`);
}}
className={cn(
"rounded-lg min-w-[32px] h-8 inline-flex items-center justify-center text-xs font-medium transition-colors",
p === page
? "bg-primary text-white"
: "border border-border/50 bg-white text-muted-foreground hover:border-primary/40 hover:text-primary"
)}
>
{p}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,12 @@
import { Suspense } from "react";
import TopicsClient from "./TopicsClient";
export const dynamic = "force-dynamic";
export default function AdminTopicsPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground/60">加载中…</div>}>
<TopicsClient />
</Suspense>
);
}
+20
View File
@@ -89,6 +89,26 @@ const menuGroups: MenuGroup[] = [
{
title: "内容管理",
items: [
{
label: "论坛管理",
href: "/admin/forum",
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
},
{
label: "Bot 表现分析",
href: "/admin/bot-analytics",
icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
},
{
label: "Bot A/B 看板",
href: "/admin/bot-experiments",
icon: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
},
{
label: "Bot 对抗学习",
href: "/admin/bot-adversarial-learning",
icon: "M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z",
},
{
label: "AI日报管理",
href: "/admin/daily-reports",
+65 -1
View File
@@ -158,7 +158,11 @@ export default function TasksPage() {
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">核心定时任务</span>
<span className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-4">
{["task1-discover-tools", "task3-check-tools", "task4-discover-skills", "task5-update-stars", "task6-review-hot", "daily-news"].map((key) => {
const t = tasks.find((x) => x.taskKey === key);
const lastLog = getLastRun(logs, key);
@@ -178,6 +182,66 @@ export default function TasksPage() {
})}
</div>
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-amber-600">Bot 体系</span>
<span className="h-px flex-1 bg-border/40" />
<span className="text-[10px] text-muted-foreground/50">数据由 deepseek 驱动,按活跃度分层调度</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
{["bot-activity", "bot-affinity-update", "bot-feedback-loop", "bot-skill-crystallize", "bot-weekly-review"].map((key) => {
const t = tasks.find((x) => x.taskKey === key);
const lastLog = getLastRun(logs, key);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
return (
<Card key={key} className="border-amber-200/40 bg-gradient-to-br from-amber-50/40 to-white shadow-sm">
<CardContent className="p-3">
<div className="text-xs text-foreground/80 mb-1 font-medium">{t?.taskName || key}</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${t?.enabled ? "bg-emerald-500" : "bg-gray-300"}`} />
<span className="text-xs font-medium">{t?.enabled ? "启用" : "停用"}</span>
{st && <span className={`text-[10px] ml-auto ${st.color}`}>{st.label}</span>}
</div>
{lastLog && (
<div className="mt-1 text-[9px] text-muted-foreground/60">
{new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-slate-500">维护</span>
<span className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
{["task7-news-to-community", "cleanup-task-logs"].map((key) => {
const t = tasks.find((x) => x.taskKey === key);
const lastLog = getLastRun(logs, key);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
if (!t) return null;
return (
<Card key={key} className="border-slate-200/60 bg-slate-50/30 shadow-sm">
<CardContent className="p-3">
<div className="text-xs text-foreground/80 mb-1 font-medium">{t.taskName}</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${t.enabled ? "bg-emerald-500" : "bg-gray-300"}`} />
<span className="text-xs font-medium">{t.enabled ? "启用" : "停用"}</span>
{st && <span className={`text-[10px] ml-auto ${st.color}`}>{st.label}</span>}
</div>
{lastLog && (
<div className="mt-1 text-[9px] text-muted-foreground/60">
{new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="space-y-3">
{tasks.map((task) => {
const lastLog = getLastRun(logs, task.taskKey);
+3 -16
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
interface Tool {
id: number;
@@ -42,20 +43,6 @@ interface HomePageClientProps {
totalSkills: number;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePageClient({
flatCategories: categories,
parentCategories,
@@ -349,8 +336,8 @@ export default function HomePageClient({
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
<span className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-medium ${PRICING_COLOR[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Bot 对抗学习看板数据
// 返回:最近 N 周的所有 bot adversarial learning 记录,含 source post 摘要、LLM 洞察、关联度
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const weeks = Math.min(Math.max(parseInt(searchParams.get("weeks") || "4", 10), 1), 26);
const botParam = searchParams.get("botUserId");
const since = new Date(Date.now() - weeks * 7 * 24 * 60 * 60 * 1000);
// 1) 拉所有 bot 基础信息
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, avatarUrl: true },
});
const botUserById = new Map(botUsers.map((u) => [u.id, u]));
// 2) 拉所有 botConfig
const botConfigs = await prisma.botConfig.findMany({
select: { id: true, userId: true, displayName: true, primaryForums: true },
});
const configByBotConfigId = new Map(botConfigs.map((c) => [c.id, c]));
// 3) 拉 learnings
const targetBotConfigIds = botParam
? botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id)
: null;
const learnings = await prisma.botAdversarialLearning.findMany({
where: {
learnedAt: { gte: since },
...(targetBotConfigIds ? { botId: { in: targetBotConfigIds } } : {}),
},
orderBy: { learnedAt: "desc" },
});
// 4) 关联 source topic 信息
const topicIds = Array.from(new Set(learnings.filter((l) => l.sourceRefType === "topic").map((l) => l.sourceRefId)));
const topics = topicIds.length
? await prisma.forumTopic.findMany({
where: { id: { in: topicIds } },
include: {
user: { select: { id: true, name: true, avatarUrl: true, isBot: true } },
category: { select: { name: true, slug: true } },
},
})
: [];
const topicById = new Map(topics.map((t) => [t.id, t]));
// 5) enrich
interface Enriched {
id: number;
botId: number;
botUserId: number | null;
botName: string;
botAvatar: string | null;
weekKey: string;
sourceRefType: string;
sourceRefId: number;
sourceTitle: string | null;
sourceUserName: string | null;
sourceUserAvatar: string | null;
sourceMetrics: { replyCount: number; likeCount: number; viewCount: number; humanReplies: number; engagementScore: number };
relevanceScore: number;
learnedInsight: string;
learnCategories: string[];
status: string;
usedCount: number;
learnedAt: string;
expiresAt: string | null;
forumSlug: string | null;
categoryName: string | null;
}
const enriched: Enriched[] = learnings.map((l) => {
const config = configByBotConfigId.get(l.botId);
const userInfo = config ? botUserById.get(config.userId) : null;
const topic = topicById.get(l.sourceRefId);
const m = (l.sourceMetrics as { replyCount?: number; likeCount?: number; viewCount?: number; humanReplies?: number; engagementScore?: number }) || {};
return {
id: l.id,
botId: l.botId,
botUserId: config?.userId ?? null,
botName: userInfo?.name || config?.displayName || `bot#${l.botId}`,
botAvatar: userInfo?.avatarUrl || null,
weekKey: l.weekKey,
sourceRefType: l.sourceRefType,
sourceRefId: l.sourceRefId,
sourceTitle: l.sourceTitle || topic?.title || null,
sourceUserName: l.sourceUserName || topic?.user?.name || null,
sourceUserAvatar: topic?.user?.avatarUrl || null,
sourceMetrics: {
replyCount: m.replyCount || 0,
likeCount: m.likeCount || 0,
viewCount: m.viewCount || 0,
humanReplies: m.humanReplies || 0,
engagementScore: m.engagementScore || 0,
},
relevanceScore: l.relevanceScore,
learnedInsight: l.learnedInsight,
learnCategories: (l.learnCategories as string[]) || [],
status: l.status,
usedCount: l.usedCount,
learnedAt: l.learnedAt.toISOString(),
expiresAt: l.expiresAt?.toISOString() || null,
forumSlug: l.forumSlug || topic?.category?.slug || null,
categoryName: topic?.category?.name || null,
};
});
// 6) 汇总
const summary = {
totalLearnings: learnings.length,
activeCount: learnings.filter((l) => l.status === "active").length,
usedCount: learnings.reduce((s, l) => s + l.usedCount, 0),
avgRelevance:
learnings.length > 0
? Math.round((learnings.reduce((s, l) => s + l.relevanceScore, 0) / learnings.length) * 100) / 100
: 0,
botCount: new Set(learnings.map((l) => l.botId)).size,
weeks,
};
return NextResponse.json({ summary, learnings: enriched });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "对抗学习数据获取失败" }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { spawn } from "child_process";
// 手动触发对抗学习调度
// POST /api/admin/bots/adversarial-learning/run
// body: { weekKey?: string, force?: boolean, lookback?: number, bot?: string }
export async function POST(req: NextRequest) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await req.json().catch(() => ({}));
const weekKey = body.weekKey || null;
const force = !!body.force;
const lookback = Math.min(Math.max(parseInt(body.lookback || "7", 10), 1), 30);
const bot = body.bot || null;
const startTime = Date.now();
const args = ["scripts/bot-adversarial-learning-run.mjs", "--lookback=" + lookback];
if (weekKey) args.push("--week=" + weekKey);
if (force) args.push("--force");
if (bot) args.push("--bot=" + bot);
const cwd = process.cwd();
const child = spawn("node", args, {
cwd,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => (stdout += d.toString()));
child.stderr.on("data", (d) => (stderr += d.toString()));
const exitCode: number = await new Promise((resolveP) => {
child.on("close", (code) => resolveP(code ?? -1));
});
const duration = Date.now() - startTime;
await prisma.taskLog.create({
data: {
taskKey: "bot-adversarial-learning-run",
taskName: "Bot 对抗学习(manual)",
status: exitCode === 0 ? "success" : "failed",
startedAt: new Date(startTime),
finishedAt: new Date(),
triggerBy: "admin",
result: { weekKey, force, lookback, bot },
error: exitCode === 0 ? null : stderr.slice(0, 1000),
},
});
return NextResponse.json({
ok: exitCode === 0,
exitCode,
duration,
weekKey,
force,
lookback,
bot,
stdout: stdout.slice(-2000),
stderr: stderr.slice(-1000),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 });
}
}
+262
View File
@@ -0,0 +1,262 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Bot Persona A/B 测试看板数据
// 返回:所有变体、最近 7 天每日聚合、实验状态
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "7", 10), 1), 60);
const botParam = searchParams.get("botUserId");
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// 1. 拉所有 bot 基础信息
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, avatarUrl: true },
});
const botUserById = new Map(botUsers.map((u) => [u.id, u]));
// 2. 拉所有 botConfig
const botConfigs = await prisma.botConfig.findMany({
select: { id: true, userId: true, displayName: true, primaryForums: true },
});
const configByBotConfigId = new Map(botConfigs.map((c) => [c.id, c]));
// 3. 拉所有变体
const variantsWhere = botParam
? { botId: { in: botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id) } }
: undefined;
const variants = await prisma.botPersonaVariant.findMany({
where: variantsWhere,
orderBy: [{ botId: "asc" }, { sampleCount: "desc" }],
});
// 4. 拉所有实验
const experiments = await prisma.botPersonaExperiment.findMany({
where: botParam
? { botId: { in: botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id) } }
: undefined,
orderBy: { startedAt: "desc" },
});
// 5. 拉所有 metric(按变体按天)
const variantIds = variants.map((v) => v.id);
const metrics = await prisma.botPersonaMetric.findMany({
where: {
variantId: { in: variantIds.length > 0 ? variantIds : [-1] },
date: { gte: new Date(since.getFullYear(), since.getMonth(), since.getDate()) },
},
orderBy: { date: "asc" },
});
// 6. 拉所有 assignment(用于统计真实样本数 / 总互动)
const assignments = await prisma.botPersonaAssignment.findMany({
where: {
variantId: { in: variantIds.length > 0 ? variantIds : [-1] },
createdAt: { gte: since },
},
});
// 7. 汇总每个变体数据
const enrichedVariants = variants.map((v) => {
const config = configByBotConfigId.get(v.botId);
const userInfo = config ? botUserById.get(config.userId) : null;
const variantMetrics = metrics.filter((m) => m.variantId === v.id);
const variantAssignments = assignments.filter((a) => a.variantId === v.id);
const totals = variantAssignments.reduce(
(acc, a) => {
acc.replies += a.replies;
acc.likes += a.likes;
acc.humanReplies += a.humanReplies;
acc.impressions += a.impressions;
return acc;
},
{ replies: 0, likes: 0, humanReplies: 0, impressions: 0 }
);
// 综合分(与 lib 中一致:likes*3 + humanReplies*5 + replies)
const computedScore = v.sampleCount > 0
? (v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / v.sampleCount
: 0;
return {
id: v.id,
botId: v.botId,
botConfigId: v.botId,
botUserId: config?.userId,
botName: userInfo?.name || config?.displayName || `bot#${v.botId}`,
botAvatar: userInfo?.avatarUrl || null,
primaryForums: (config?.primaryForums as string[]) || [],
variantKey: v.variantKey,
label: v.label,
description: v.description,
isControl: v.isControl,
isActive: v.isActive,
weight: v.weight,
experimentId: v.experimentId,
sampleCount: v.sampleCount,
replyCount: v.replyCount,
likeCount: v.likeCount,
humanReplyCount: v.humanReplyCount,
engagementScore: v.engagementScore,
computedScore: Math.round(computedScore * 100) / 100,
styleHints: v.styleHints,
daily: variantMetrics.map((m) => ({
date: m.date.toISOString().slice(0, 10),
contentCount: m.contentCount,
replies: m.replies,
likes: m.likes,
humanReplies: m.humanReplies,
avgReplies: m.avgReplies,
avgLikes: m.avgLikes,
computedScore: m.computedScore,
})),
totalCounts: totals,
createdAt: v.createdAt,
updatedAt: v.updatedAt,
};
});
// 8. 显著性分析:每个 bot 的 control vs 其它
const byBot = new Map<number, typeof enrichedVariants>();
for (const v of enrichedVariants) {
if (!byBot.has(v.botId)) byBot.set(v.botId, []);
byBot.get(v.botId)!.push(v);
}
const analysis: Array<{
botId: number;
botUserId: number | null;
botName: string;
botAvatar: string | null;
control: { variantKey: string; label: string; sampleCount: number; computedScore: number };
comparisons: Array<{
variantKey: string;
label: string;
computedScore: number;
controlRate?: number;
sampleCount: number;
zScore?: number;
pValue?: number;
significant?: boolean;
winner?: boolean;
status: "winning" | "losing" | "monitoring" | "no_data";
}>;
winner: { variantKey: string; pValue: number; sampleCount: number } | null;
status: "winner_found" | "no_significant_winner" | "collecting_data";
}> = [];
for (const [botId, list] of Array.from(byBot.entries())) {
const control = list.find((v) => v.isControl) || list[0];
if (!control || control.sampleCount < 1) continue;
const controlRate = control.computedScore;
const comparisons: Array<{
variantKey: string;
label: string;
computedScore: number;
controlRate?: number;
sampleCount: number;
zScore?: number;
pValue?: number;
significant?: boolean;
winner?: boolean;
status: "winning" | "losing" | "monitoring" | "no_data";
}> = list
.filter((v) => v.id !== control.id)
.map((v): {
variantKey: string;
label: string;
computedScore: number;
controlRate?: number;
sampleCount: number;
zScore?: number;
pValue?: number;
significant?: boolean;
winner?: boolean;
status: "winning" | "losing" | "monitoring" | "no_data";
} => {
if (v.sampleCount < 1) {
return { variantKey: v.variantKey, label: v.label, computedScore: 0, significant: false, sampleCount: 0, status: "no_data" as const };
}
const rate = v.computedScore;
// 简化 z 检验:使用每内容互动分(连续值)的 pooled SE
const n1 = control.sampleCount;
const n2 = v.sampleCount;
const m1 = controlRate;
const m2 = rate;
const v1 = Math.max(0.0001, control.likeCount + control.humanReplyCount + control.replyCount);
const v2 = Math.max(0.0001, v.likeCount + v.humanReplyCount + v.replyCount);
const pooledVar = ((n1 - 1) * v1 + (n2 - 1) * v2) / Math.max(1, n1 + n2 - 2);
const se = Math.sqrt(pooledVar * (1 / n1 + 1 / n2));
const z = se > 0 ? (m2 - m1) / se : 0;
const p = 2 * (1 - normalCdf(Math.abs(z)));
const significant = p < 0.1 && n2 >= 20;
return {
variantKey: v.variantKey,
label: v.label,
computedScore: rate,
controlRate: controlRate,
sampleCount: v.sampleCount,
zScore: Math.round(z * 100) / 100,
pValue: Math.round(p * 1000) / 1000,
significant,
winner: significant && rate > controlRate,
status: (significant ? (rate > controlRate ? "winning" : "losing") : "monitoring") as "winning" | "losing" | "monitoring",
};
});
const winner = comparisons.find((c) => c.winner);
analysis.push({
botId,
botUserId: list[0].botUserId ?? null,
botName: list[0].botName,
botAvatar: list[0].botAvatar,
control: {
variantKey: control.variantKey,
label: control.label,
sampleCount: control.sampleCount,
computedScore: control.computedScore,
},
comparisons,
winner: winner ? { variantKey: winner.variantKey, pValue: winner.pValue ?? 1, sampleCount: winner.sampleCount } : null,
status: winner ? "winner_found" : (list.every((v) => v.sampleCount >= 20) ? "no_significant_winner" : "collecting_data"),
});
}
// 9. 全局汇总
const summary = {
totalBots: byBot.size,
totalVariants: variants.length,
totalExperiments: experiments.length,
activeExperiments: experiments.filter((e) => e.status === "active").length,
completedExperiments: experiments.filter((e) => e.status === "completed").length,
winnersFound: analysis.filter((a) => a.winner).length,
totalAssignments: assignments.length,
totalImpressions: assignments.reduce((s, a) => s + a.impressions, 0),
totalReplies: assignments.reduce((s, a) => s + a.replies, 0),
totalLikes: assignments.reduce((s, a) => s + a.likes, 0),
totalHumanReplies: assignments.reduce((s, a) => s + a.humanReplies, 0),
days,
};
return NextResponse.json({
summary,
variants: enrichedVariants,
experiments,
analysis,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "A/B 数据获取失败" }, { status: 500 });
}
}
// 标准正态分布 CDF
function normalCdf(x: number): number {
const t = 1 / (1 + 0.2316419 * x);
const d = 0.3989422804014327 * Math.exp(-x * x / 2);
const p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
return 1 - p;
}
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { spawn } from "child_process";
import { resolve, dirname } from "path";
// 手动触发 A/B 调度
// POST /api/admin/bots/experiments/run
// body: { lookback?: number, mode?: "all"|"metrics"|"analyze", noSwitch?: boolean }
export async function POST(req: NextRequest) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await req.json().catch(() => ({}));
const lookback = Math.min(Math.max(parseInt(body.lookback || "7", 10), 1), 30);
const mode = body.mode || "all";
const noSwitch = !!body.noSwitch;
const startTime = Date.now();
const args = ["scripts/bot-persona-experiment-run.mjs", "--lookback=" + lookback];
if (noSwitch) args.push("--no-switch");
if (mode === "metrics") args.push("--metrics-only");
if (mode === "analyze") args.push("--analyze-only");
const projectRoot = resolve(dirname(process.cwd()));
const cwd = process.cwd();
const child = spawn("node", args, {
cwd,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => (stdout += d.toString()));
child.stderr.on("data", (d) => (stderr += d.toString()));
const exitCode: number = await new Promise((resolveP) => {
child.on("close", (code) => resolveP(code ?? -1));
});
const duration = Date.now() - startTime;
await prisma.taskLog.create({
data: {
taskKey: "bot-persona-experiment-run",
taskName: "Bot Persona A/B 调度 (manual)",
status: exitCode === 0 ? "success" : "failed",
startedAt: new Date(startTime),
finishedAt: new Date(),
triggerBy: "admin",
result: { lookback, mode, noSwitch },
error: exitCode === 0 ? null : stderr.slice(0, 1000),
},
});
return NextResponse.json({
ok: exitCode === 0,
exitCode,
duration,
lookback,
mode,
noSwitch,
stdout: stdout.slice(-2000),
stderr: stderr.slice(-1000),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 });
}
}
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// 变体管理:更新权重、启用/停用、重置样本、删除
// PATCH /api/admin/bots/experiments/variants
// body: { variantId: number, weight?: number, isActive?: boolean, resetStats?: boolean }
// DELETE /api/admin/bots/experiments/variants?variantId=X
export async function PATCH(req: NextRequest) {
try {
const body = await req.json();
const { variantId, weight, isActive, resetStats } = body;
if (!variantId) {
return NextResponse.json({ error: "variantId 必填" }, { status: 400 });
}
const update: Record<string, unknown> = {};
if (typeof weight === "number" && weight >= 0 && weight <= 1) {
update.weight = weight;
}
if (typeof isActive === "boolean") {
update.isActive = isActive;
}
if (resetStats) {
update.sampleCount = 0;
update.replyCount = 0;
update.likeCount = 0;
update.humanReplyCount = 0;
update.engagementScore = 0;
}
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: "没有要更新的字段" }, { status: 400 });
}
const v = await prisma.botPersonaVariant.update({
where: { id: Number(variantId) },
data: update,
});
return NextResponse.json({ ok: true, variant: v });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "更新失败" }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const variantId = searchParams.get("variantId");
if (!variantId) {
return NextResponse.json({ error: "variantId 必填" }, { status: 400 });
}
await prisma.botPersonaVariant.delete({
where: { id: Number(variantId) },
});
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "删除失败" }, { status: 500 });
}
}
+229
View File
@@ -0,0 +1,229 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Bot 表现统计:拉近 N 天的每日统计 + 总览指标
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "14", 10), 1), 60);
const botParam = searchParams.get("botUserId");
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate());
// 1. 全部 bot 基础信息
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true, createdAt: true },
});
if (botUsers.length === 0) {
return NextResponse.json({ stats: [], summary: {}, trends: [] });
}
const botById = new Map(botUsers.map((u) => [u.id, u]));
const botIds = botUsers.map((u) => u.id);
// 2. BotConfig 拿到 botKey 和 primaryForums
const botConfigs = await prisma.botConfig.findMany({
where: { userId: { in: botIds } },
select: { id: true, userId: true, primaryForums: true, displayName: true },
});
const configByUserId = new Map(botConfigs.map((c) => [c.userId, c]));
// 3. 周期内 BotDailyStat 聚合
const dailyStats = await prisma.botDailyStat.groupBy({
by: ["botId"],
where: { date: { gte: sinceDate } },
_sum: {
topicCreated: true,
replySent: true,
followUpSent: true,
repliesReceived: true,
likesReceived: true,
humanInteractions: true,
feedbackProcessed: true,
},
});
const statMap = new Map(
dailyStats.map((s) => [
s.botId,
{
topicCreated: s._sum.topicCreated || 0,
replySent: s._sum.replySent || 0,
followUpSent: s._sum.followUpSent || 0,
repliesReceived: s._sum.repliesReceived || 0,
likesReceived: s._sum.likesReceived || 0,
humanInteractions: s._sum.humanInteractions || 0,
feedbackProcessed: s._sum.feedbackProcessed || 0,
},
])
);
// 4. Memory 数量
const memCounts = await prisma.botMemory.groupBy({
by: ["botId"],
_count: { _all: true },
_avg: { importance: true },
});
const memMap = new Map(
memCounts.map((m) => [
m.botId,
{ count: m._count._all, avgImportance: m._avg.importance || 0 },
])
);
// 5. Persona + 跨板块亲和度
const personas = await prisma.botPersona.findMany({
select: {
botId: true,
avgReplyLength: true,
questionRatio: true,
postSampleCount: true,
stanceKeywords: true,
},
});
const personaMap = new Map(personas.map((p) => [p.botId, p]));
const affinities = await prisma.botCrossForumAffinity.findMany({
select: { botId: true, forumSlug: true, affinity: true, sampleCount: true },
});
const affinityMap = new Map<number, typeof affinities>();
for (const a of affinities) {
if (!affinityMap.has(a.botId)) affinityMap.set(a.botId, []);
affinityMap.get(a.botId)!.push(a);
}
// 6. 计算每个 bot 的"健康分"
const stats = botUsers.map((u) => {
const config = configByUserId.get(u.id);
const cfgId = config?.id ?? 0;
const d = statMap.get(cfgId) || {
topicCreated: 0,
replySent: 0,
followUpSent: 0,
repliesReceived: 0,
likesReceived: 0,
humanInteractions: 0,
feedbackProcessed: 0,
};
const mem = memMap.get(cfgId) || { count: 0, avgImportance: 0 };
const persona = personaMap.get(cfgId);
const affs = affinityMap.get(cfgId) || [];
// 综合健康分:发帖×3 + 回复×1 + 收赞×2 + 真人互动×5
const healthScore =
d.topicCreated * 3 +
d.replySent * 1 +
d.likesReceived * 2 +
d.humanInteractions * 5;
// 平均亲和度
const avgAffinity = affs.length > 0
? affs.reduce((s, a) => s + a.affinity, 0) / affs.length
: 0;
// 状态判定
let status: "active" | "warming" | "fresh" | "idle" = "idle";
if (healthScore >= 50) status = "active";
else if (healthScore >= 10) status = "warming";
else if (d.humanInteractions > 0 || d.likesReceived > 0) status = "fresh";
else status = "idle";
return {
userId: u.id,
name: u.name,
avatarUrl: u.avatarUrl,
points: u.points,
level: u.level,
botConfigId: config?.id || null,
primaryForums: (config?.primaryForums as string[]) || [],
topicCreated: d.topicCreated,
replySent: d.replySent,
followUpSent: d.followUpSent,
repliesReceived: d.repliesReceived,
likesReceived: d.likesReceived,
humanInteractions: d.humanInteractions,
feedbackProcessed: d.feedbackProcessed,
memoryCount: mem.count,
avgImportance: mem.avgImportance,
personaQuestionRatio: persona?.questionRatio || 0,
personaSampleCount: persona?.postSampleCount || 0,
affinityCount: affs.length,
avgAffinity: Math.round(avgAffinity * 100) / 100,
healthScore,
status,
};
});
// 按健康分排序
stats.sort((a, b) => b.healthScore - a.healthScore);
// 7. 时间趋势(按天聚合)
const trendRaw = await prisma.botDailyStat.groupBy({
by: ["date"],
where: { date: { gte: sinceDate } },
_sum: {
topicCreated: true,
replySent: true,
likesReceived: true,
humanInteractions: true,
},
orderBy: { date: "asc" },
});
const trends = trendRaw.map((t) => ({
date: t.date.toISOString().split("T")[0],
topicCreated: t._sum.topicCreated || 0,
replySent: t._sum.replySent || 0,
likesReceived: t._sum.likesReceived || 0,
humanInteractions: t._sum.humanInteractions || 0,
}));
// 7b. 如果指定了 botUserId,额外返回该 bot 的每日趋势
let perBotTrend: typeof trends = [];
if (botParam) {
const botUserId = Number(botParam);
if (!Number.isNaN(botUserId)) {
const botConfig = configByUserId.get(botUserId);
if (botConfig) {
const perBot = await prisma.botDailyStat.findMany({
where: { botId: botConfig.id, date: { gte: sinceDate } },
orderBy: { date: "asc" },
});
perBotTrend = perBot.map((d) => ({
date: d.date.toISOString().split("T")[0],
topicCreated: d.topicCreated,
replySent: d.replySent,
likesReceived: d.likesReceived,
humanInteractions: d.humanInteractions,
}));
}
}
}
// 8. 全局汇总
const summary = {
totalBots: stats.length,
activeBots: stats.filter((s) => s.status === "active").length,
warmingBots: stats.filter((s) => s.status === "warming").length,
idleBots: stats.filter((s) => s.status === "idle").length,
totalTopics: stats.reduce((s, b) => s + b.topicCreated, 0),
totalReplies: stats.reduce((s, b) => s + b.replySent, 0),
totalLikes: stats.reduce((s, b) => s + b.likesReceived, 0),
totalHumanInteractions: stats.reduce((s, b) => s + b.humanInteractions, 0),
totalMemories: stats.reduce((s, b) => s + b.memoryCount, 0),
avgHealthScore: stats.length > 0
? Math.round(stats.reduce((s, b) => s + b.healthScore, 0) / stats.length)
: 0,
days,
};
return NextResponse.json({
stats: stats.slice(0, 50), // 最多 50 个 bot(已排序)
summary,
trends,
perBotTrend,
botById: Object.fromEntries(botUsers.map((u) => [u.id, u.name])),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "Bot 统计失败" }, { status: 500 });
}
}
@@ -11,6 +11,8 @@ export async function PUT(
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
const body = await request.json();
const data: Record<string, unknown> = {};
@@ -41,6 +43,7 @@ export async function DELETE(
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
const toolCount = await prisma.tool.count({ where: { categoryId: id } });
if (toolCount > 0) {
@@ -3,10 +3,21 @@ import { requireAdmin } from "@/lib/admin-auth";
import { extractJSON } from "@/lib/utils";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
// 必须 force-dynamic,否则 build 期的 page data collection 会触发模块求值
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
// 懒加载 OpenAI client:build 期模块求值时不会触发 SDK 构造
let _openai: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!_openai) {
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
}
return _openai;
}
const DAILY_PROMPT = `你是AI行业资深分析师。请为{date}生成一份AI日报,包含6-8条当日最重要的AI行业热点新闻。
@@ -51,7 +62,7 @@ export async function POST(request: Request) {
const prompt = DAILY_PROMPT.replace(/\{date\}/g, date);
const completion = await openai.chat.completions.create({
const completion = await getOpenAI().chat.completions.create({
model: "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
/**
* POST /api/admin/forum/posts/[id]/moderate
* Body: { action, reason? }
* action: hide | unhide | delete
*/
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return NextResponse.json({ error: "无权限" }, { status: 403 });
}
const userId = parseInt((session.user as { id: string }).id);
const postId = parseInt(params.id);
if (isNaN(postId)) {
return NextResponse.json({ error: "无效的 postId" }, { status: 400 });
}
const body = await req.json().catch(() => ({}));
const { action, reason } = body as { action?: string; reason?: string };
if (!action || !["hide", "unhide", "delete"].includes(action)) {
return NextResponse.json({ error: "无效的 action" }, { status: 400 });
}
const post = await prisma.forumPost.findUnique({
where: { id: postId },
select: { id: true, isHidden: true },
});
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (action === "delete") {
await prisma.$transaction([
prisma.forumPost.delete({ where: { id: postId } }),
prisma.forumModerationLog.create({
data: {
actorId: userId,
targetType: "post",
targetId: postId,
action: "delete",
reason: reason?.slice(0, 200) || null,
},
}),
]);
return NextResponse.json({ success: true, action });
}
const data = action === "hide" ? { isHidden: true } : { isHidden: false };
await prisma.$transaction([
prisma.forumPost.update({ where: { id: postId }, data }),
prisma.forumModerationLog.create({
data: {
actorId: userId,
targetType: "post",
targetId: postId,
action,
reason: reason?.slice(0, 200) || null,
},
}),
]);
return NextResponse.json({ success: true, action });
}
@@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
const VALID_RESOLUTIONS = new Set(["hidden", "dismissed", "deleted"]);
/**
* POST /api/admin/forum/reports/[id]/resolve
* Body: { resolution: "hidden" | "dismissed" | "deleted" }
*
* 同时联动 target:
* - hidden: 隐藏对应的 topic/post
* - dismissed: 标记为已忽略
* - deleted: 删除对应的 topic/post
*/
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return NextResponse.json({ error: "无权限" }, { status: 403 });
}
const userId = parseInt((session.user as { id: string }).id);
const reportId = parseInt(params.id);
if (isNaN(reportId)) {
return NextResponse.json({ error: "无效的 reportId" }, { status: 400 });
}
const body = await req.json().catch(() => ({}));
const { resolution } = body as { resolution?: string };
if (!resolution || !VALID_RESOLUTIONS.has(resolution)) {
return NextResponse.json({ error: "无效的 resolution" }, { status: 400 });
}
const report = await prisma.forumReport.findUnique({
where: { id: reportId },
});
if (!report) {
return NextResponse.json({ error: "举报不存在" }, { status: 404 });
}
if (report.status !== "pending") {
return NextResponse.json({ error: "该举报已处理" }, { status: 409 });
}
// 事务:更新举报状态 + 联动 target + 写日志
await prisma.$transaction(async (tx) => {
await tx.forumReport.update({
where: { id: reportId },
data: {
status: "resolved",
resolution,
resolvedBy: userId,
resolvedAt: new Date(),
},
});
if (report.topicId) {
if (resolution === "hidden") {
await tx.forumTopic.update({
where: { id: report.topicId },
data: { isHidden: true },
});
} else if (resolution === "deleted") {
await tx.forumTopic.delete({ where: { id: report.topicId } });
}
}
if (report.postId) {
if (resolution === "hidden") {
await tx.forumPost.update({
where: { id: report.postId },
data: { isHidden: true },
});
} else if (resolution === "deleted") {
await tx.forumPost.delete({ where: { id: report.postId } });
}
}
await tx.forumModerationLog.create({
data: {
actorId: userId,
targetType: "report",
targetId: reportId,
action: resolution === "dismissed" ? "dismiss" : "resolve",
reason: resolution,
},
});
// 联动 targetType=topic/post 的额外日志
if (report.topicId) {
await tx.forumModerationLog.create({
data: {
actorId: userId,
targetType: "topic",
targetId: report.topicId,
action: resolution === "hidden" ? "hide" : resolution === "deleted" ? "delete" : "dismiss",
reason: `via report #${reportId}`,
},
});
}
if (report.postId) {
await tx.forumModerationLog.create({
data: {
actorId: userId,
targetType: "post",
targetId: report.postId,
action: resolution === "hidden" ? "hide" : resolution === "deleted" ? "delete" : "dismiss",
reason: `via report #${reportId}`,
},
});
}
});
return NextResponse.json({ success: true, resolution });
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
/**
* GET /api/admin/forum/reports?status=pending&page=1&pageSize=20
*/
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return NextResponse.json({ error: "无权限" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const status = (searchParams.get("status") || "pending").trim();
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "20", 10));
const where: any = {};
if (status !== "all") {
where.status = status;
}
const [reports, totalCount] = await Promise.all([
prisma.forumReport.findMany({
where,
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
reporter: {
select: { id: true, name: true, email: true, avatarUrl: true, level: true, isBot: true },
},
resolver: {
select: { id: true, name: true, email: true },
},
topic: {
select: {
id: true,
title: true,
isHidden: true,
userId: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
}),
prisma.forumReport.count({ where }),
]);
// 如果是 post 举报,单独拉一次 post 信息
const postIds = reports.filter((r) => r.postId && !r.topic).map((r) => r.postId!) as number[];
const posts = postIds.length
? await prisma.forumPost.findMany({
where: { id: { in: postIds } },
select: {
id: true,
content: true,
isHidden: true,
topicId: true,
userId: true,
user: { select: { id: true, name: true, email: true } },
},
})
: [];
const postMap = new Map(posts.map((p) => [p.id, p]));
const enriched = reports.map((r) => ({
...r,
post: r.postId ? postMap.get(r.postId) || null : null,
}));
return NextResponse.json({
reports: enriched,
totalCount,
page,
pageSize,
});
}
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
const VALID_ACTIONS = new Set([
"pin",
"unpin",
"feature",
"unfeature",
"lock",
"unlock",
"hide",
"unhide",
"delete",
]);
/**
* POST /api/admin/forum/topics/[id]/moderate
* Body: { action, reason? }
*/
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return NextResponse.json({ error: "无权限" }, { status: 403 });
}
const userId = parseInt((session.user as { id: string }).id);
const topicId = parseInt(params.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的 topicId" }, { status: 400 });
}
const body = await req.json().catch(() => ({}));
const { action, reason } = body as { action?: string; reason?: string };
if (!action || !VALID_ACTIONS.has(action)) {
return NextResponse.json({ error: "无效的 action" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: {
id: true,
isPinned: true,
isFeatured: true,
isLocked: true,
isHidden: true,
},
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
const data: any = {};
switch (action) {
case "pin":
data.isPinned = true;
break;
case "unpin":
data.isPinned = false;
break;
case "feature":
data.isFeatured = true;
break;
case "unfeature":
data.isFeatured = false;
break;
case "lock":
data.isLocked = true;
break;
case "unlock":
data.isLocked = false;
break;
case "hide":
data.isHidden = true;
break;
case "unhide":
data.isHidden = false;
break;
case "delete":
await prisma.$transaction([
prisma.forumTopic.delete({ where: { id: topicId } }),
prisma.forumModerationLog.create({
data: {
actorId: userId,
targetType: "topic",
targetId: topicId,
action: "delete",
reason: reason?.slice(0, 200) || null,
},
}),
]);
return NextResponse.json({ success: true, action });
}
await prisma.$transaction([
prisma.forumTopic.update({ where: { id: topicId }, data }),
prisma.forumModerationLog.create({
data: {
actorId: userId,
targetType: "topic",
targetId: topicId,
action,
reason: reason?.slice(0, 200) || null,
},
}),
]);
return NextResponse.json({ success: true, action });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
/**
* GET /api/admin/forum/topics?status=all&page=1&pageSize=20
* status: all | visible | hidden | pinned | featured | locked
*/
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const perms = await getAllUserPermissions();
const userRole = (session.user as { role?: string }).role || "";
if (userRole !== "admin" && !perms.keys.has("forum:moderate")) {
return NextResponse.json({ error: "无权限" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const status = (searchParams.get("status") || "all").trim();
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "20", 10));
const q = (searchParams.get("q") || "").trim();
const where: any = {};
if (status === "visible") where.isHidden = false;
else if (status === "hidden") where.isHidden = true;
else if (status === "pinned") where.isPinned = true;
else if (status === "featured") where.isFeatured = true;
else if (status === "locked") where.isLocked = true;
if (q) {
const ors: any[] = [{ title: { contains: q } }, { content: { contains: q } }];
where.OR = ors;
}
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where,
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, level: true, isBot: true } },
_count: { select: { posts: true, likes: true, reports: true } },
},
orderBy: [{ isPinned: "desc" }, { updatedAt: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where }),
]);
return NextResponse.json({ topics, totalCount, page, pageSize });
}
@@ -3,10 +3,20 @@ import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
// 必须 force-dynamic,避免 build 期触发 OpenAI SDK 构造抛错
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
let _openai: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!_openai) {
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
}
return _openai;
}
const LONG_ARTICLE_PROMPT = `你是一个技术内容创作者。请基于以下技能评测数据,生成一篇适合微信公众号发布的深度评测长文。
@@ -98,7 +108,7 @@ export async function POST(
const prompt = promptMap[type].replace("{reviewData}", reviewData);
const completion = await openai.chat.completions.create({
const completion = await getOpenAI().chat.completions.create({
model: "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
+20 -1
View File
@@ -8,8 +8,25 @@ export async function PUT(request: Request, { params }: { params: { id: string }
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
const body = await request.json();
const skill = await prisma.skill.update({ where: { id }, data: body });
const data: Record<string, unknown> = {};
if (body.name !== undefined) data.name = body.name;
if (body.slug !== undefined) data.slug = body.slug;
if (body.description !== undefined) data.description = body.description;
if (body.categoryId !== undefined) data.categoryId = body.categoryId;
if (body.githubUrl !== undefined) data.githubUrl = body.githubUrl;
if (body.logoUrl !== undefined) data.logoUrl = body.logoUrl;
if (body.websiteUrl !== undefined) data.websiteUrl = body.websiteUrl;
if (body.stars !== undefined) data.stars = body.stars;
if (body.status !== undefined) data.status = body.status;
if (body.language !== undefined) data.language = body.language;
if (body.license !== undefined) data.license = body.license;
if (body.tags !== undefined) data.tags = body.tags;
if (body.features !== undefined) data.features = body.features;
const skill = await prisma.skill.update({ where: { id }, data });
return NextResponse.json({ skill });
} catch (error: unknown) {
const err = error as { message?: string };
@@ -23,6 +40,8 @@ export async function DELETE(_request: Request, { params }: { params: { id: stri
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
await prisma.skill.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
+16
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
export async function GET() {
try {
@@ -11,6 +12,12 @@ export async function GET() {
{ taskKey: "task6-review-hot", taskName: "评测热门", cronExpr: "0 4 * * *", enabled: true, desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布" },
{ taskKey: "daily-news", taskName: "AI日报生成", cronExpr: "30 7 * * *", enabled: true, desc: "搜索真实新闻素材,AI整理格式生成当日AI日报" },
{ taskKey: "task7-news-to-community", taskName: "新闻分发到社区", cronExpr: "0 8 * * *", enabled: true, desc: "将AI日报条目自动分发到社区对应板块创建话题" },
{ taskKey: "bot-activity", taskName: "Bot活动引擎", cronExpr: "0 9-23 * * *", enabled: true, desc: "Bot发帖/回复主循环:每小时从活跃时段挑选bot在主板块生成内容" },
{ taskKey: "bot-affinity-update", taskName: "Bot亲和度与画像", cronExpr: "0 */6 * * *", enabled: true, desc: "每6小时统计bot跨板块互动,更新画像(话题/回复长度/立场关键词)" },
{ taskKey: "bot-feedback-loop", taskName: "Bot反馈循环", cronExpr: "0 */3 * * *", enabled: true, desc: "每3小时基于bot历史回复生成follow-up,跟进讨论" },
{ taskKey: "bot-skill-crystallize", taskName: "Bot技能沉淀", cronExpr: "0 */12 * * *", enabled: true, desc: "把高互动话题沉淀为bot的'技能经验'写入botMemory" },
{ taskKey: "bot-weekly-review", taskName: "Bot周复盘", cronExpr: "30 2 * * 1", enabled: true, desc: "每周一凌晨生成本周bot表现报告,调整活跃度配额" },
{ taskKey: "cleanup-task-logs", taskName: "清理任务日志", cronExpr: "30 6 * * 0", enabled: true, desc: "每周日凌晨清理 30 天前的 task_log 数据,避免表无限膨胀" },
];
const configs = await prisma.taskConfig.findMany();
@@ -37,6 +44,8 @@ export async function GET() {
}
export async function PUT(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const taskKey = body.taskKey as string;
@@ -66,6 +75,13 @@ export async function PUT(request: Request) {
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报生成",
"task7-news-to-community": "新闻分发到社区",
"bot-activity": "Bot活动引擎",
"bot-affinity-update": "Bot亲和度与画像",
"bot-feedback-loop": "Bot反馈循环",
"bot-skill-crystallize": "Bot技能沉淀",
"bot-weekly-review": "Bot周复盘",
"cleanup-task-logs": "清理任务日志",
};
result = await prisma.taskConfig.create({
data: {
+48 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { taskRunSchema } from "@/lib/validation";
import path from "path";
@@ -15,6 +16,12 @@ const SCRIPT_MAP: Record<string, string> = {
"task6-review-hot": "scripts/task6-review-hot.mjs",
"daily-news": "scripts/daily-news.mjs",
"task7-news-to-community": "scripts/task7-news-to-community.mjs",
"bot-activity": "scripts/bot-activity.mjs",
"bot-affinity-update": "scripts/bot-affinity-update.mjs",
"bot-feedback-loop": "scripts/bot-feedback-loop.mjs",
"bot-skill-crystallize": "scripts/bot-skill-crystallize.mjs",
"bot-weekly-review": "scripts/bot-weekly-review.mjs",
"cleanup-task-logs": "scripts/cleanup-task-logs.mjs",
};
const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd();
@@ -38,6 +45,7 @@ export async function POST(request: Request) {
}
const fullPath = path.join(PROJECT_ROOT, scriptPath);
const startedAt = new Date();
const result: { taskKey: string; status: string; pid: number; output: string } = {
taskKey,
status: "started",
@@ -45,11 +53,15 @@ export async function POST(request: Request) {
output: "",
};
// Bot 任务涉及多次 AI 生成,给 5 分钟;普通任务 2 分钟
const isBotTask = taskKey.startsWith("bot-");
const TIMEOUT_MS = isBotTask ? 300000 : 120000;
try {
const { stdout, stderr } = await execAsync(
`node "${fullPath}" 2>&1`,
{
timeout: 120000,
timeout: TIMEOUT_MS,
maxBuffer: 1024 * 1024 * 2,
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" },
}
@@ -62,6 +74,41 @@ export async function POST(request: Request) {
result.output = (e.stdout || "") + "\n" + (e.stderr || "") + "\n" + (e.message || "");
}
// 不管成功失败都写一条 taskLog,方便后台统一追溯
try {
const TASK_NAME_MAP: Record<string, string> = {
"task1-discover-tools": "发现AI工具",
"task3-check-tools": "工具巡检",
"task4-discover-skills": "发现AI技能",
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报生成",
"task7-news-to-community": "新闻分发到社区",
"bot-activity": "Bot活动引擎",
"bot-affinity-update": "Bot亲和度与画像",
"bot-feedback-loop": "Bot反馈循环",
"bot-skill-crystallize": "Bot技能沉淀",
"bot-weekly-review": "Bot周复盘",
"cleanup-task-logs": "清理任务日志",
};
await prisma.taskLog.create({
data: {
taskKey,
taskName: TASK_NAME_MAP[taskKey] || taskKey,
status: result.status,
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "manual",
error: result.status === "failed" ? result.output.slice(0, 1000) : null,
result: { output: result.output.slice(-2000) },
},
});
} catch (logErr) {
// 写日志失败不影响主流程
console.error("Failed to write task log:", logErr);
}
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
+4
View File
@@ -11,6 +11,8 @@ export async function PUT(
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
const body = await request.json();
const data: Record<string, unknown> = {};
@@ -50,6 +52,8 @@ export async function DELETE(
try {
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 });
await prisma.tool.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// 返回按"周"统计的 bot 表现排行
// 维度:发帖数、回复数、获赞数、被@次数(按 TOPIC_REPLY 通知的 fromBotId 计算)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "7", 10), 1), 30);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "12", 10), 1), 30);
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// 1. 取所有 bot 用户
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true },
});
if (botUsers.length === 0) {
return NextResponse.json({ leaders: [], topTopics: [], topLiked: [], days, generatedAt: new Date().toISOString() });
}
const botIds = botUsers.map((u) => u.id);
const userById = new Map(botUsers.map((u) => [u.id, u]));
// 2. 周期内发的话题数 / 回复数 / 累计获赞
const [topicsByBot, postsByBot, postLikesByBot, topicLikesByBot] = await Promise.all([
prisma.forumTopic.groupBy({
by: ["userId"],
where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false },
_count: { _all: true },
_sum: { likeCount: true, viewCount: true },
}),
prisma.forumPost.groupBy({
by: ["userId"],
where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false },
_count: { _all: true },
_sum: { likeCount: true },
}),
prisma.forumPost.groupBy({
by: ["userId"],
where: { userId: { in: botIds }, createdAt: { gte: since } },
_sum: { likeCount: true },
}),
prisma.forumTopic.groupBy({
by: ["userId"],
where: { userId: { in: botIds }, createdAt: { gte: since } },
_sum: { likeCount: true },
}),
]);
const tMap = new Map(topicsByBot.map((r) => [r.userId, r]));
const pMap = new Map(postsByBot.map((r) => [r.userId, r]));
const plMap = new Map(postLikesByBot.map((r) => [r.userId, r._sum.likeCount || 0]));
const tlMap = new Map(topicLikesByBot.map((r) => [r.userId, r._sum.likeCount || 0]));
// 3. 给每个 bot 计算综合得分
const leaders = botUsers
.map((u) => {
const t = tMap.get(u.id);
const p = pMap.get(u.id);
const topicCount = t?._count?._all || 0;
const postCount = p?._count?._all || 0;
const topicLikes = tlMap.get(u.id) || 0;
const postLikes = plMap.get(u.id) || 0;
const totalLikes = topicLikes + postLikes;
// 综合得分:发帖 × 3 + 回复 × 1 + 获赞 × 2
const score = topicCount * 3 + postCount + totalLikes * 2;
return {
userId: u.id,
name: u.name,
avatarUrl: u.avatarUrl,
points: u.points,
level: u.level,
topicCount,
postCount,
totalLikes,
score,
};
})
.filter((b) => b.topicCount > 0 || b.postCount > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
// 4. 本周最热话题(按 bots 发的话题里互动最多的)
const topTopics = await prisma.forumTopic.findMany({
where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false },
include: {
user: { select: { id: true, name: true, avatarUrl: true } },
category: { select: { name: true, slug: true, icon: true } },
_count: { select: { posts: true } },
},
orderBy: [{ likeCount: "desc" }, { viewCount: "desc" }, { createdAt: "desc" }],
take: 5,
});
// 5. 获赞最多的回复
const topLiked = await prisma.forumPost.findMany({
where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gt: 0 } },
include: {
user: { select: { id: true, name: true, avatarUrl: true } },
topic: { select: { id: true, title: true, category: { select: { name: true, slug: true } } } },
},
orderBy: [{ likeCount: "desc" }, { createdAt: "desc" }],
take: 5,
});
// 引用一下避免 lint 警告
void userById;
return NextResponse.json({
leaders,
topTopics: topTopics.map((t) => ({
id: t.id,
title: t.title,
likeCount: t.likeCount,
viewCount: t.viewCount,
replyCount: t._count.posts,
createdAt: t.createdAt,
category: t.category,
user: t.user,
})),
topLiked: topLiked.map((p) => ({
id: p.id,
content: p.content.substring(0, 150),
likeCount: p.likeCount,
createdAt: p.createdAt,
user: p.user,
topic: p.topic,
})),
days,
generatedAt: new Date().toISOString(),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "Bot 排行获取失败" }, { status: 500 });
}
}
+191
View File
@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync } from "fs";
import { resolve } from "path";
import { prisma } from "@/lib/prisma";
const BOT_DATA_PATH = resolve(process.cwd(), "data", "bot-characters.json");
const FORUM_NAMES: Record<string, string> = {
"ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商",
"ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链",
"ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海",
"ai-hardware": "智能硬件", "ai-llm": "大模型与开源",
"ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容",
"ct-mcn": "MCN机构", "ct-podcast": "播客与音频",
"fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财",
"fi-pe": "PE与VC", "fi-forex": "外汇与期货",
"cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流",
"cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC",
"re-residential": "住宅买卖", "re-commercial": "商业地产",
"re-renovation": "装修与家居", "re-property": "物业与租赁",
"fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工",
"fd-supply": "食品供应链", "fd-delivery": "外卖运营",
"ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济",
"ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培",
"hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务",
"hl-rehab": "运动康复", "hl-mental": "心理健康",
"edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言",
"edu-corporate": "企业培训", "edu-k12": "K12与家庭",
};
interface BotCharacter {
key: string;
displayName: string;
email: string;
role?: string;
avatarPrompt: string;
personality: {
identity: string;
expertise: string[];
weakness: string[];
stance: string;
speakingStyle: string;
catchphrase: string;
forbiddenPatterns: string[];
wordLimit: { min: number; max: number };
passerbyType?: string;
};
primaryForums: string[];
activeHours: { weekday: string[]; weekend: string[] };
activityLevel: string;
replyChance: number;
}
function loadBotCharacters(): BotCharacter[] {
try {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const data = JSON.parse(raw);
return (data.characters || []) as BotCharacter[];
} catch {
return [];
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const role = searchParams.get("role");
const forumSlug = searchParams.get("forum");
const search = (searchParams.get("q") || "").toLowerCase().trim();
const characters = loadBotCharacters();
let filtered = characters;
if (role === "passerby") filtered = filtered.filter(c => c.role === "passerby");
if (role === "expert") filtered = filtered.filter(c => c.role !== "passerby");
if (forumSlug) filtered = filtered.filter(c => (c.primaryForums || []).includes(forumSlug));
if (search) {
filtered = filtered.filter(c => {
const p = c.personality || {};
const text = [
c.displayName || "",
c.key || "",
p.identity || "",
p.stance || "",
(p.expertise || []).join(" "),
].join(" ").toLowerCase();
return text.includes(search);
});
}
const emails = filtered.map(c => `bot_${c.key}@zhuiguang.ai`);
const userRows = emails.length > 0
? await prisma.user.findMany({
where: { email: { in: emails } },
select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true, createdAt: true },
})
: [];
const userByEmail = new Map(userRows.map(u => [u.email, u]));
const userIds = userRows.map(u => u.id);
const [topicGroups, postGroups, recentTopics] = await Promise.all([
userIds.length > 0
? prisma.forumTopic.groupBy({
by: ["userId"],
where: { userId: { in: userIds }, isHidden: false },
_count: { _all: true },
})
: Promise.resolve([] as Array<{ userId: number; _count: { _all: number } }>),
userIds.length > 0
? prisma.forumPost.groupBy({
by: ["userId"],
where: { userId: { in: userIds }, isHidden: false },
_count: { _all: true },
})
: Promise.resolve([] as Array<{ userId: number; _count: { _all: number } }>),
userIds.length > 0
? prisma.forumTopic.findMany({
where: {
userId: { in: userIds },
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
isHidden: false,
},
select: {
userId: true,
createdAt: true,
title: true,
id: true,
category: { select: { name: true, slug: true } },
},
orderBy: { createdAt: "desc" },
take: 200,
})
: Promise.resolve([] as Array<any>),
]);
const topicMap = new Map(topicGroups.map(t => [t.userId, t._count._all]));
const postMap = new Map(postGroups.map(p => [p.userId, p._count._all]));
const recentMap = new Map<number, any[]>();
for (const t of recentTopics) {
const arr = recentMap.get(t.userId) || [];
arr.push(t);
recentMap.set(t.userId, arr);
}
const enriched = filtered.map(c => {
const u = userByEmail.get(`bot_${c.key}@zhuiguang.ai`);
const isPasserby = c.role === "passerby";
return {
key: c.key,
displayName: c.displayName,
role: isPasserby ? "passerby" : "expert",
avatarUrl: u?.avatarUrl || null,
avatarPrompt: c.avatarPrompt,
primaryForums: c.primaryForums || [],
primaryForumNames: (c.primaryForums || []).map(s => FORUM_NAMES[s] || s),
activityLevel: c.activityLevel,
activeHours: c.activeHours,
expertise: c.personality?.expertise || [],
identity: c.personality?.identity || "",
catchphrase: c.personality?.catchphrase || "",
stance: c.personality?.stance || "",
speakingStyle: c.personality?.speakingStyle || "",
passerbyType: c.personality?.passerbyType || null,
userId: u?.id || null,
points: u?.points || 0,
level: u?.level || null,
createdAt: u?.createdAt || null,
stats: {
topics: u ? topicMap.get(u.id) || 0 : 0,
posts: u ? postMap.get(u.id) || 0 : 0,
},
recentTopics: u ? (recentMap.get(u.id) || []).slice(0, 3) : [],
};
});
enriched.sort((a, b) => {
const aa = a.stats.topics + a.stats.posts;
const bb = b.stats.topics + b.stats.posts;
if (bb !== aa) return bb - aa;
return a.displayName.localeCompare(b.displayName);
});
return NextResponse.json({
bots: enriched,
total: enriched.length,
forums: Object.entries(FORUM_NAMES).map(([slug, name]) => ({ slug, name })),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取Bot列表失败" }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { getUserAchievements, evaluateAchievements } from "@/lib/achievements";
export const dynamic = "force-dynamic";
/**
* GET /api/forum/achievements?userId=xxx
* 获取指定用户(或当前用户)的勋章列表
*/
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const targetUserId = searchParams.get("userId");
const session = await getServerSession(authOptions);
const meId = session?.user?.id ? parseInt(session.user.id) : null;
// 如果没传 userId 默认查自己;查他人需登录(匿名也能看公开勋章)
const userId = targetUserId ? parseInt(targetUserId) : meId;
if (!userId) {
return NextResponse.json({ error: "缺少 userId" }, { status: 400 });
}
// 重新评估一遍(轻量),保证数据最新
await evaluateAchievements(userId);
const data = await getUserAchievements(userId);
return NextResponse.json(data);
}
+31
View File
@@ -0,0 +1,31 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const bookmarks = await prisma.forumTopicBookmark.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 100,
include: {
topic: {
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
},
},
});
const valid = bookmarks.filter((b) => b.topic && !b.topic.isHidden);
return NextResponse.json({ bookmarks: valid });
}
@@ -0,0 +1,101 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const categoryId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(categoryId)) {
return NextResponse.json({ error: "无效的板块ID" }, { status: 400 });
}
const category = await prisma.forumCategory.findUnique({
where: { id: categoryId },
select: { id: true },
});
if (!category) {
return NextResponse.json({ error: "板块不存在" }, { status: 404 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumCategoryFollow.findUnique({
where: { categoryId_userId: { categoryId, userId } },
});
if (existing) {
return { alreadyFollowing: true };
}
await tx.forumCategoryFollow.create({
data: { categoryId, userId },
});
const updated = await tx.forumCategory.update({
where: { id: categoryId },
data: { followerCount: { increment: 1 } },
select: { followerCount: true },
});
return { alreadyFollowing: false, followerCount: updated.followerCount };
});
return NextResponse.json({
success: true,
followed: true,
followerCount: result.followerCount ?? undefined,
});
} catch (err: any) {
console.error("关注失败", err);
return NextResponse.json({ error: "关注失败,请稍后重试" }, { status: 500 });
}
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const categoryId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(categoryId)) {
return NextResponse.json({ error: "无效的板块ID" }, { status: 400 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumCategoryFollow.findUnique({
where: { categoryId_userId: { categoryId, userId } },
});
if (!existing) {
return { notFollowing: true };
}
await tx.forumCategoryFollow.delete({
where: { categoryId_userId: { categoryId, userId } },
});
const updated = await tx.forumCategory.update({
where: { id: categoryId },
data: { followerCount: { decrement: 1 } },
select: { followerCount: true },
});
return {
notFollowing: false,
followerCount: Math.max(0, updated.followerCount),
};
});
return NextResponse.json({
success: true,
followed: false,
followerCount: result.followerCount,
});
} catch (err: any) {
console.error("取消关注失败", err);
return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 });
}
}
+10 -1
View File
@@ -1,18 +1,27 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
// 强制 SSR(build 期不预渲染,避免无 DATABASE_URL 时 Prisma 报错)
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
const res = NextResponse.json({ categories });
res.headers.set("Cache-Control", "public, s-maxage=300, stale-while-revalidate=120");
res.headers.set("Cache-Control", "no-store, max-age=0");
return res;
}
+174
View File
@@ -0,0 +1,174 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { triggerAchievementCheck } from "@/lib/achievements";
export const dynamic = "force-dynamic";
/**
* 计算连续签到奖励(积分)。
* - 1 天: 5 分
* - 2 天: 6 分
* - 3 天: 7 分
* - 4 天: 8 分
* - 5 天: 9 分
* - 6 天: 10 分
* - 7+ 天: 15 分(封顶)
*/
function calcCheckInPoints(streak: number) {
if (streak >= 7) return 15;
return 5 + Math.max(0, streak - 1);
}
function startOfDay(d: Date) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function diffDays(a: Date, b: Date) {
const ms = startOfDay(a).getTime() - startOfDay(b).getTime();
return Math.round(ms / (24 * 60 * 60 * 1000));
}
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: "请先登录" },
{ status: 401 }
);
}
const userId = parseInt(session.user.id);
const today = startOfDay(new Date());
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const [todayCheckIn, lastCheckIn, totalCount] = await Promise.all([
prisma.forumCheckIn.findUnique({
where: { userId_checkInDate: { userId, checkInDate: today } },
}),
prisma.forumCheckIn.findFirst({
where: { userId },
orderBy: { checkInDate: "desc" },
}),
prisma.forumCheckIn.count({ where: { userId } }),
]);
// 当前连续天数:最后一次签到若是今天/昨天,则 streak 有效;否则重置为 0
let currentStreak = 0;
if (lastCheckIn) {
const gap = diffDays(today, lastCheckIn.checkInDate);
if (gap === 0) {
currentStreak = lastCheckIn.streak;
} else if (gap === 1) {
// 昨天签到,今天还没签
currentStreak = lastCheckIn.streak;
}
}
// 本周签到日历(最近 7 天)
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(today.getDate() - 6);
const recent = await prisma.forumCheckIn.findMany({
where: {
userId,
checkInDate: { gte: sevenDaysAgo },
},
select: { checkInDate: true, streak: true, pointsEarned: true },
orderBy: { checkInDate: "asc" },
});
const calendar = Array.from({ length: 7 }, (_, i) => {
const d = new Date(sevenDaysAgo);
d.setDate(sevenDaysAgo.getDate() + i);
const hit = recent.find(
(r) => startOfDay(r.checkInDate).getTime() === d.getTime()
);
return {
date: d.toISOString().slice(0, 10),
checked: !!hit,
streak: hit?.streak || 0,
pointsEarned: hit?.pointsEarned || 0,
isToday: d.getTime() === today.getTime(),
};
});
return NextResponse.json({
checkedIn: !!todayCheckIn,
todayPoints: todayCheckIn?.pointsEarned || 0,
currentStreak,
totalCheckIns: totalCount,
nextStreak: currentStreak + 1,
nextPoints: calcCheckInPoints(currentStreak + 1),
calendar,
});
}
export async function POST() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const today = startOfDay(new Date());
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
// 幂等:今天已签到,直接返回
const existing = await prisma.forumCheckIn.findUnique({
where: { userId_checkInDate: { userId, checkInDate: today } },
});
if (existing) {
return NextResponse.json({
success: true,
alreadyChecked: true,
streak: existing.streak,
pointsEarned: existing.pointsEarned,
});
}
// 找昨天签到记录,决定 streak 是否延续
const last = await prisma.forumCheckIn.findUnique({
where: { userId_checkInDate: { userId, checkInDate: yesterday } },
});
const newStreak = last ? last.streak + 1 : 1;
const points = calcCheckInPoints(newStreak);
const result = await prisma.$transaction(async (tx) => {
const ci = await tx.forumCheckIn.create({
data: {
userId,
checkInDate: today,
streak: newStreak,
pointsEarned: points,
},
});
await tx.user.update({
where: { id: userId },
data: { points: { increment: points } },
});
await tx.pointsHistory.create({
data: {
userId,
points,
reason: "daily_login",
detail: `每日签到 · 连续 ${newStreak} 天`,
refType: "checkin",
refId: ci.id,
},
});
return ci;
});
triggerAchievementCheck(userId);
return NextResponse.json({
success: true,
alreadyChecked: false,
streak: result.streak,
pointsEarned: result.pointsEarned,
});
}
+86
View File
@@ -0,0 +1,86 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const drafts = await prisma.forumDraft.findMany({
where: { userId },
orderBy: { updatedAt: "desc" },
take: 10,
});
return NextResponse.json({ drafts });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const body = await req.json().catch(() => ({}));
const { title, content, categoryId } = body as {
title?: string;
content?: string;
categoryId?: number | null;
};
if (!content || !content.trim()) {
return NextResponse.json({ error: "内容不能为空" }, { status: 400 });
}
if (content.length > 20000) {
return NextResponse.json({ error: "内容过长" }, { status: 400 });
}
if (title && title.length > 100) {
return NextResponse.json({ error: "标题过长" }, { status: 400 });
}
// 取最近的一条草稿覆盖;否则新建
const existing = await prisma.forumDraft.findFirst({
where: { userId },
orderBy: { updatedAt: "desc" },
});
let draft;
if (existing) {
draft = await prisma.forumDraft.update({
where: { id: existing.id },
data: {
title: title?.trim() || null,
content: content.trim(),
categoryId: categoryId ?? null,
},
});
} else {
draft = await prisma.forumDraft.create({
data: {
userId,
title: title?.trim() || null,
content: content.trim(),
categoryId: categoryId ?? null,
},
});
}
return NextResponse.json({ success: true, draft });
}
export async function DELETE() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
await prisma.forumDraft.deleteMany({ where: { userId } });
return NextResponse.json({ success: true });
}
+91
View File
@@ -0,0 +1,91 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
const VALID_REASONS = new Set([
"spam",
"abuse",
"inappropriate",
"illegal",
"duplicate",
"other",
]);
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const body = await req.json().catch(() => ({}));
const { topicId, postId, reason, detail } = body as {
topicId?: number;
postId?: number;
reason?: string;
detail?: string;
};
if (!topicId && !postId) {
return NextResponse.json({ error: "缺少举报目标" }, { status: 400 });
}
if (!reason || !VALID_REASONS.has(reason)) {
return NextResponse.json({ error: "请选择举报原因" }, { status: 400 });
}
if (detail && detail.length > 500) {
return NextResponse.json({ error: "说明不能超过 500 字" }, { status: 400 });
}
// 验证目标存在
if (topicId) {
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true, userId: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId === userId) {
return NextResponse.json({ error: "不能举报自己发布的内容" }, { status: 400 });
}
}
if (postId) {
const post = await prisma.forumPost.findUnique({
where: { id: postId },
select: { id: true, userId: true },
});
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId === userId) {
return NextResponse.json({ error: "不能举报自己发布的回复" }, { status: 400 });
}
}
// 去重:同一用户对同一目标 24h 内只能举报一次
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const dupe = await prisma.forumReport.findFirst({
where: {
reporterId: userId,
...(topicId ? { topicId } : {}),
...(postId ? { postId } : {}),
createdAt: { gte: since },
},
});
if (dupe) {
return NextResponse.json({ error: "你已举报过该内容,请等待处理" }, { status: 409 });
}
const report = await prisma.forumReport.create({
data: {
reporterId: userId,
topicId: topicId || null,
postId: postId || null,
reason,
detail: detail?.trim() || null,
},
});
return NextResponse.json({ success: true, reportId: report.id });
}
+96
View File
@@ -0,0 +1,96 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "15", 10));
const sort = searchParams.get("sort") || "latest";
if (!q) {
return NextResponse.json({ topics: [], totalCount: 0, page, pageSize, q: "" });
}
const where: any = {
isHidden: false,
};
// 优先使用全文索引,回退到 LIKE
const useFullText = q.length >= 2;
if (useFullText) {
// MySQL FULLTEXT 的 boolean mode 搜索
// 把关键词拆词,规避 FULLTEXT 最小长度限制
const tokens = q
.split(/\s+/)
.filter((t) => t.length >= 1)
.slice(0, 10)
.map((t) => `+${t.replace(/[+\-><()~*"@]/g, "")}*`);
if (tokens.length > 0) {
// Prisma 的 where.fulltext 字段是 reserved,使用原始查询更稳
// 这里先尝试简单的 contains 组合
const ors: any[] = [];
for (const t of q.split(/\s+/).filter((x) => x.length >= 1)) {
ors.push({ title: { contains: t } });
ors.push({ content: { contains: t } });
}
where.OR = ors;
} else {
where.OR = [{ title: { contains: q } }, { content: { contains: q } }];
}
} else {
where.OR = [{ title: { contains: q } }, { content: { contains: q } }];
}
const orderBy: any = {};
if (sort === "relevance" && useFullText) {
// 简化:标题命中权重更高 → 让标题命中靠前
orderBy.isPinned = "desc";
} else if (sort === "hot") {
orderBy.viewCount = "desc";
} else if (sort === "active") {
orderBy.lastReplyAt = "desc";
} else {
orderBy.createdAt = "desc";
}
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where,
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where }),
]);
// 生成高亮摘要
const snippets = topics.map((t) => {
const titleHit = q && t.title.toLowerCase().includes(q.toLowerCase());
let snippet = "";
if (q) {
const idx = t.content.toLowerCase().indexOf(q.toLowerCase());
if (idx >= 0) {
const start = Math.max(0, idx - 50);
const end = Math.min(t.content.length, idx + q.length + 100);
snippet =
(start > 0 ? "..." : "") +
t.content.slice(start, end).replace(/\s+/g, " ").trim() +
(end < t.content.length ? "..." : "");
} else {
snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim() + (t.content.length > 150 ? "..." : "");
}
} else {
snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim();
}
return { topicId: t.id, titleHit, snippet };
});
return NextResponse.json({ topics, totalCount, page, pageSize, q, snippets });
}
+51
View File
@@ -0,0 +1,51 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(req: Request, { params }: { params: { slug: string } }) {
const tag = await prisma.forumTag.findUnique({
where: { slug: params.slug },
});
if (!tag) {
return NextResponse.json({ error: "标签不存在" }, { status: 404 });
}
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = 20;
const sort = searchParams.get("sort") || "latest";
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where: {
tags: { some: { tagId: tag.id } },
},
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({
where: { tags: { some: { tagId: tag.id } } },
}),
]);
return NextResponse.json({
tag,
topics,
totalCount,
page,
pageSize,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim().toLowerCase();
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50);
const where: any = {};
if (q) {
where.OR = [
{ name: { contains: q } },
{ slug: { contains: q } },
];
}
const tags = await prisma.forumTag.findMany({
where,
orderBy: [{ topicCount: "desc" }, { name: "asc" }],
take: limit,
});
return NextResponse.json({ tags });
}
@@ -0,0 +1,57 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的话题ID" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
const existing = await prisma.forumTopicBookmark.findUnique({
where: { topicId_userId: { topicId, userId } },
});
if (existing) {
await prisma.forumTopicBookmark.delete({
where: { topicId_userId: { topicId, userId } },
});
return NextResponse.json({ success: true, bookmarked: false });
}
await prisma.forumTopicBookmark.create({
data: { topicId, userId },
});
return NextResponse.json({ success: true, bookmarked: true });
}
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ bookmarked: false });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ bookmarked: false });
}
const existing = await prisma.forumTopicBookmark.findUnique({
where: { topicId_userId: { topicId, userId } },
});
return NextResponse.json({ bookmarked: !!existing });
}
@@ -0,0 +1,357 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import OpenAI from "openai";
// 必须 force-dynamic,避免 build 期触发 OpenAI SDK 构造抛错
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
let _openai: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!_openai) {
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
}
return _openai;
}
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000,
label = "operation"
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt - 1);
console.warn(` ⚠ ${label} 第${attempt}次失败,${delay / 1000}s后重试...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
async function authorizeBotCall(req: Request): Promise<{ ok: boolean; via?: string }> {
const headerKey = req.headers.get("x-bot-key");
const expected = process.env.BOT_FEEDBACK_SECRET;
if (expected && headerKey && headerKey === expected) {
return { ok: true, via: "shared-secret" };
}
const session = await getServerSession(authOptions);
const role = (session?.user as { role?: string } | undefined)?.role;
if (role === "admin") {
return { ok: true, via: "admin-session" };
}
return { ok: false };
}
interface BotCharacter {
key: string;
displayName: string;
personality: {
identity: string;
expertise: string[];
weakness?: string[];
stance: string;
speakingStyle: string;
catchphrase: string;
forbiddenPatterns?: string[];
};
}
async function loadBotCharacter(botKey: string): Promise<BotCharacter | undefined> {
const fs = await import("fs/promises");
const path = await import("path");
const dataPath = path.join(process.cwd(), "data", "bot-characters.json");
const raw = await fs.readFile(dataPath, "utf-8");
const { characters } = JSON.parse(raw) as { characters: BotCharacter[] };
return characters.find((c) => c.key === botKey);
}
interface ReplySnippet {
author: string;
content: string;
}
function buildFollowUpPrompt(
bot: BotCharacter,
topicTitle: string,
topicContent: string,
replies: ReplySnippet[]
): string {
const p = bot.personality;
const repliesText = replies
.map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`)
.join("\n");
return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。
[你的角色设定]
你是【${bot.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
你的核心立场:${p.stance}
[你的原始主帖]
${topicContent}
[新收到的回复]
${repliesText}
[二次回复要求]
1. 像真人接话,120-350字
2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问)
3. 保持你的人设语气和立场
4. 不要重复你自己主帖的内容
5. 留一点钩子让讨论继续
[禁止事项]
- 禁止使用"首先/其次/最后""从以下几个方面""综上所述"
- 禁止表现得像AI助手
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
只输出JSON:{"content": "你的回复内容"}`;
}
async function callDeepSeek(prompt: string): Promise<{ content: string }> {
const response = await withRetry(() =>
getOpenAI().chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.88,
max_tokens: 800,
})
);
const text = response.choices[0]?.message?.content?.trim() || "";
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("无法解析DeepSeek响应");
}
return JSON.parse(jsonMatch[0]);
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
const authz = await authorizeBotCall(req);
if (!authz.ok) {
return NextResponse.json({ error: "无权限调用,需BOT_FEEDBACK_SECRET或admin登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的topicId" }, { status: 400 });
}
const body = await req.json();
const { botKey, dryRun = false } = body;
if (!botKey) {
return NextResponse.json({ error: "缺少 botKey" }, { status: 400 });
}
const botChar = await loadBotCharacter(botKey);
if (!botChar) {
return NextResponse.json({ error: `未找到Bot: ${botKey}` }, { status: 404 });
}
const botUser = await prisma.user.findFirst({
where: { email: `bot_${botKey}@zhuiguang.ai` },
});
if (!botUser) {
return NextResponse.json({ error: "Bot用户不存在" }, { status: 404 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true } },
posts: {
where: { userId: { not: botUser.id } },
include: { user: { select: { name: true, isBot: true } } },
orderBy: { createdAt: "asc" },
take: 5,
},
},
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
if (topic.userId !== botUser.id) {
return NextResponse.json({ error: "该话题不是此Bot发起" }, { status: 403 });
}
if (topic.posts.length === 0) {
return NextResponse.json({ error: "该话题下没有可回复的内容" }, { status: 400 });
}
const replies = topic.posts.map((p) => ({
author: p.user.name || "匿名",
content: p.content,
}));
let content: string;
try {
const prompt = buildFollowUpPrompt(botChar, topic.title, topic.content, replies);
const result = await callDeepSeek(prompt);
content = (result.content || "").trim();
if (!content || content.length < 30) {
return NextResponse.json({ error: "生成内容过短" }, { status: 500 });
}
} catch (err: any) {
return NextResponse.json({ error: `DeepSeek调用失败: ${err.message}` }, { status: 500 });
}
if (dryRun) {
return NextResponse.json({ ok: true, dryRun: true, content, via: authz.via });
}
const post = await prisma.$transaction(async (tx) => {
const newPost = await tx.forumPost.create({
data: {
topicId: topic.id,
userId: botUser.id,
content: content.slice(0, 5000),
},
});
await tx.forumTopic.update({
where: { id: topic.id },
data: {
replyCount: { increment: 1 },
lastReplyAt: new Date(),
updatedAt: new Date(),
},
});
return newPost;
});
const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id } });
if (botConfig) {
await prisma.botMemory.create({
data: {
botId: botConfig.id,
memoryType: "interaction",
layer: "WORKING",
content: {
action: "follow_up_reply",
forum: topic.category.name,
topicTitle: topic.title,
topicId: topic.id,
triggeredBy: "api_follow_up",
summary: content.slice(0, 100),
},
importance: 0.6,
contextTags: {
topicId: topic.id,
forumSlug: topic.category.slug,
postId: post.id,
source: "api_follow_up",
},
ttl: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
});
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.botDailyStat.upsert({
where: { botId_date: { botId: botConfig.id, date: today } },
create: {
botId: botConfig.id,
date: today,
followUpSent: 1,
replySent: 1,
},
update: {
followUpSent: { increment: 1 },
replySent: { increment: 1 },
},
});
}
return NextResponse.json({
ok: true,
postId: post.id,
content: post.content,
via: authz.via,
});
}
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
const role = (session?.user as { role?: string } | undefined)?.role;
if (role !== "admin") {
return NextResponse.json({ error: "无权限" }, { status: 401 });
}
const topicId = parseInt(params.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的topicId" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true } },
user: { select: { id: true, name: true, email: true, isBot: true } },
_count: { select: { posts: true } },
posts: {
include: { user: { select: { name: true, isBot: true } } },
orderBy: { createdAt: "asc" },
take: 10,
},
},
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
const botEmail = topic.user.email || "";
const isBotTopic = topic.user.isBot;
const allFeedback = await prisma.botMemory.findMany({
where: { memoryType: "feedback" },
orderBy: { createdAt: "desc" },
take: 50,
});
const feedbackMemories = allFeedback.filter((m) => {
const tags = m.contextTags as { topicId?: number } | null;
return tags && tags.topicId === topicId;
}).slice(0, 5);
return NextResponse.json({
topic: {
id: topic.id,
title: topic.title,
category: topic.category,
replyCount: topic._count.posts,
lastReplyAt: topic.lastReplyAt,
isBotTopic,
owner: { id: topic.user.id, name: topic.user.name, email: botEmail },
},
recentPosts: topic.posts.map((p) => ({
id: p.id,
author: p.user.name || "匿名",
isBot: p.user.isBot,
content: p.content.slice(0, 200),
createdAt: p.createdAt,
})),
feedbackMemories: feedbackMemories.map((m) => ({
id: m.id,
importance: m.importance,
content: m.content,
createdAt: m.createdAt,
})),
});
}
+133
View File
@@ -0,0 +1,133 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
import { rewardPoints } from "@/lib/points-reward";
import { triggerAchievementCheck } from "@/lib/achievements";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的话题ID" }, { status: 400 });
}
const limit = rateLimit(`like:topic:${userId}`, 60, 60_000);
if (!limit) {
return NextResponse.json({ error: "操作过于频繁,请稍后再试" }, { status: 429 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true, userId: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId === userId) {
return NextResponse.json({ error: "不能给自己的话题点赞" }, { status: 400 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumTopicLike.findUnique({
where: { topicId_userId: { topicId, userId } },
});
if (existing) {
return { alreadyLiked: true };
}
await tx.forumTopicLike.create({
data: { topicId, userId },
});
const updated = await tx.forumTopic.update({
where: { id: topicId },
data: { likeCount: { increment: 1 } },
select: { likeCount: true, userId: true },
});
return { alreadyLiked: false, likeCount: updated.likeCount, authorId: updated.userId };
});
if (!result.alreadyLiked && result.authorId) {
await rewardPoints(
result.authorId,
"forum_post_like",
"话题被点赞",
"topic",
topicId
);
await prisma.notification.create({
data: {
userId: result.authorId,
type: "TOPIC_LIKE",
title: `${session.user.name || "用户"} 点赞了你的话题`,
content: undefined,
link: `/community/topic/${topicId}`,
},
});
triggerAchievementCheck(result.authorId);
}
return NextResponse.json({
success: true,
liked: true,
count: result.likeCount ?? undefined,
});
} catch (err: any) {
console.error("点赞失败", err);
return NextResponse.json({ error: "点赞失败,请稍后重试" }, { status: 500 });
}
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的话题ID" }, { status: 400 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumTopicLike.findUnique({
where: { topicId_userId: { topicId, userId } },
});
if (!existing) {
return { notLiked: true };
}
await tx.forumTopicLike.delete({
where: { topicId_userId: { topicId, userId } },
});
const updated = await tx.forumTopic.update({
where: { id: topicId },
data: { likeCount: { decrement: 1 } },
select: { likeCount: true, userId: true },
});
return {
notLiked: false,
likeCount: Math.max(0, updated.likeCount),
authorId: updated.userId,
};
});
return NextResponse.json({
success: true,
liked: false,
count: result.likeCount,
});
} catch (err: any) {
console.error("取消点赞失败", err);
return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 });
}
}
@@ -0,0 +1,141 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
import { triggerAchievementCheck } from "@/lib/achievements";
export async function POST(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
if (isNaN(topicId) || isNaN(postId)) {
return NextResponse.json({ error: "无效的ID" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true, userId: true, isLocked: true, title: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "只有楼主可以采纳最佳答案" }, { status: 403 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
const post = await prisma.forumPost.findUnique({
where: { id: postId },
select: { id: true, userId: true, isAnswer: true, content: true },
});
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.isAnswer) {
return NextResponse.json({ error: "该回复已经是最佳答案" }, { status: 400 });
}
if (post.userId === topic.userId) {
return NextResponse.json({ error: "不能采纳自己的回复" }, { status: 400 });
}
const result = await prisma.$transaction(async (tx) => {
// 取消同话题下其他最佳答案
await tx.forumPost.updateMany({
where: { topicId, isAnswer: true },
data: { isAnswer: false },
});
// 标记当前回复为最佳答案
const updated = await tx.forumPost.update({
where: { id: postId },
data: { isAnswer: true },
select: {
id: true,
isAnswer: true,
userId: true,
content: true,
},
});
return updated;
});
// 奖励最佳答主(使用 best_answer 积分事件,不与 forum_post 重复)
await rewardPoints(
result.userId,
"best_answer",
`在话题《${topic.title.slice(0, 30)}》中获得最佳答案`,
"post",
postId
);
// 给答主发送通知
if (result.userId !== userId) {
await prisma.notification.create({
data: {
userId: result.userId,
type: "BEST_ANSWER",
title: `🎉 你的回复被采纳为最佳答案`,
content: topic.title.slice(0, 60),
link: `/community/topic/${topicId}?post=${postId}`,
},
});
}
return NextResponse.json({
success: true,
postId: result.id,
isAnswer: result.isAnswer,
});
}
export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
if (isNaN(topicId) || isNaN(postId)) {
return NextResponse.json({ error: "无效的ID" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { userId: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "只有楼主可以取消最佳答案" }, { status: 403 });
}
const post = await prisma.forumPost.findUnique({
where: { id: postId },
select: { id: true, isAnswer: true },
});
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (!post.isAnswer) {
return NextResponse.json({ error: "该回复不是最佳答案" }, { status: 400 });
}
await prisma.forumPost.update({
where: { id: postId },
data: { isAnswer: false },
});
return NextResponse.json({ success: true, isAnswer: false });
}
@@ -0,0 +1,135 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
import { rewardPoints } from "@/lib/points-reward";
import { triggerAchievementCheck } from "@/lib/achievements";
export async function POST(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
if (isNaN(postId)) {
return NextResponse.json({ error: "无效的回复ID" }, { status: 400 });
}
const limit = rateLimit(`like:post:${userId}`, 60, 60_000);
if (!limit) {
return NextResponse.json({ error: "操作过于频繁,请稍后再试" }, { status: 429 });
}
const post = await prisma.forumPost.findUnique({
where: { id: postId },
select: { id: true, userId: true },
});
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId === userId) {
return NextResponse.json({ error: "不能给自己的回复点赞" }, { status: 400 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumPostLike.findUnique({
where: { postId_userId: { postId, userId } },
});
if (existing) {
return { alreadyLiked: true };
}
await tx.forumPostLike.create({
data: { postId, userId },
});
const updated = await tx.forumPost.update({
where: { id: postId },
data: { likeCount: { increment: 1 } },
select: { likeCount: true, userId: true },
});
return {
alreadyLiked: false,
likeCount: updated.likeCount,
authorId: updated.userId,
};
});
if (!result.alreadyLiked && result.authorId) {
await rewardPoints(
result.authorId,
"forum_post_like",
"回复被点赞",
"post",
postId
);
await prisma.notification.create({
data: {
userId: result.authorId,
type: "POST_LIKE",
title: `${session.user.name || "用户"} 点赞了你的回复`,
content: undefined,
link: `/community/topic/${params.id}?post=${postId}`,
},
});
}
return NextResponse.json({
success: true,
liked: true,
count: result.likeCount ?? undefined,
});
} catch (err: any) {
console.error("点赞失败", err);
return NextResponse.json({ error: "点赞失败,请稍后重试" }, { status: 500 });
}
}
export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
if (isNaN(postId)) {
return NextResponse.json({ error: "无效的回复ID" }, { status: 400 });
}
try {
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.forumPostLike.findUnique({
where: { postId_userId: { postId, userId } },
});
if (!existing) {
return { notLiked: true };
}
await tx.forumPostLike.delete({
where: { postId_userId: { postId, userId } },
});
const updated = await tx.forumPost.update({
where: { id: postId },
data: { likeCount: { decrement: 1 } },
select: { likeCount: true, userId: true },
});
return {
notLiked: false,
likeCount: Math.max(0, updated.likeCount),
};
});
return NextResponse.json({
success: true,
liked: false,
count: result.likeCount,
});
} catch (err: any) {
console.error("取消点赞失败", err);
return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 });
}
}
+152 -10
View File
@@ -3,6 +3,18 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
import { triggerAchievementCheck } from "@/lib/achievements";
const MENTION_REGEX = /@([\w一-龥]{1,30})/g;
function extractMentionNames(content: string): string[] {
const matches = Array.from(content.matchAll(MENTION_REGEX));
const set = new Set<string>();
for (const m of matches) {
if (m[1]) set.add(m[1]);
}
return Array.from(set);
}
export async function GET(req: Request, { params }: { params: { id: string } }) {
const topicId = parseInt(params.id);
@@ -15,6 +27,13 @@ export async function GET(req: Request, { params }: { params: { id: string } })
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
replyTo: {
select: {
id: true,
content: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
orderBy: { createdAt: "asc" },
skip: (page - 1) * pageSize,
@@ -27,6 +46,15 @@ export async function GET(req: Request, { params }: { params: { id: string } })
id: p.id,
content: p.content,
isAnswer: p.isAnswer,
replyToId: p.replyToId,
replyToSnapshot: p.replyToSnapshot,
replyTo: p.replyTo
? {
id: p.replyTo.id,
content: p.replyTo.content.slice(0, 200),
userName: p.replyTo.user.name || p.replyTo.user.email.split("@")[0],
}
: null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
user: {
@@ -52,7 +80,7 @@ export async function POST(req: Request, { params }: { params: { id: string } })
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const body = await req.json();
const { content } = body;
const { content, replyToId } = body as { content?: string; replyToId?: number | null };
if (!content || !content.trim()) {
return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 });
@@ -61,7 +89,10 @@ export async function POST(req: Request, { params }: { params: { id: string } })
return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true, title: true, userId: true, isLocked: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
@@ -69,15 +100,51 @@ export async function POST(req: Request, { params }: { params: { id: string } })
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
// 处理 replyToId:必须属于同一话题
let replyToSnapshot: string | null = null;
if (replyToId) {
const target = await prisma.forumPost.findUnique({
where: { id: replyToId },
include: { user: { select: { id: true, name: true, email: true } } },
});
if (!target || target.topicId !== topicId) {
return NextResponse.json({ error: "引用目标不存在" }, { status: 400 });
}
const targetName = target.user.name || target.user.email.split("@")[0];
const snippet = target.content.slice(0, 200).replace(/\s+/g, " ").trim();
replyToSnapshot = `@${targetName}: ${snippet}`;
}
// 解析 @mention 用户
const mentionNames = extractMentionNames(content);
const mentionedUsers = mentionNames.length
? await prisma.user.findMany({
where: { name: { in: mentionNames } },
select: { id: true, name: true, email: true },
take: 10,
})
: [];
const actorName = session.user.name || session.user.email?.split("@")[0] || "用户";
const post = await prisma.$transaction(async (tx) => {
const newPost = await tx.forumPost.create({
data: {
topicId,
userId,
content: content.trim(),
replyToId: replyToId || null,
replyToSnapshot,
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
replyTo: {
select: {
id: true,
content: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
});
@@ -91,18 +158,84 @@ export async function POST(req: Request, { params }: { params: { id: string } })
},
});
// 收集需要通知的目标
const notifs: Array<{
userId: number;
type: "TOPIC_REPLY" | "MENTION";
title: string;
content?: string;
link?: string;
}> = [];
// 1) 通知楼主(自己回复自己就不通知)
if (topic.userId !== userId) {
await tx.notification.create({
data: {
userId: topic.userId,
type: "TOPIC_REPLY",
title: `${session.user.name || "用户"} 回复了你的话题`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}`,
},
notifs.push({
userId: topic.userId,
type: "TOPIC_REPLY",
title: `${actorName} 回复了你的话题《${topic.title.slice(0, 30)}》`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}#post-${newPost.id}`,
});
}
// 2) 通知被引用的人(如果不是自己)
if (replyToId) {
const target = await tx.forumPost.findUnique({
where: { id: replyToId },
select: { userId: true },
});
if (target && target.userId !== userId && target.userId !== topic.userId) {
notifs.push({
userId: target.userId,
type: "MENTION",
title: `${actorName} 回复了你的回复`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}#post-${newPost.id}`,
});
}
}
// 3) 通知 @mention 的用户(不是自己、不重复)
const notifiedSet = new Set(notifs.map((n) => n.userId));
for (const u of mentionedUsers) {
if (u.id === userId) continue;
if (notifiedSet.has(u.id)) continue;
notifs.push({
userId: u.id,
type: "MENTION",
title: `${actorName} 在话题《${topic.title.slice(0, 30)}》中提到了你`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}#post-${newPost.id}`,
});
notifiedSet.add(u.id);
}
// 4) 通知订阅者(不是作者本人、不是楼主、不是已通知的人)
const subscribers = await tx.forumTopicSubscription.findMany({
where: {
topicId,
notifyReply: true,
userId: { not: userId },
},
select: { userId: true },
});
for (const s of subscribers) {
if (s.userId === topic.userId) continue;
if (notifiedSet.has(s.userId)) continue;
notifs.push({
userId: s.userId,
type: "TOPIC_REPLY",
title: `你订阅的话题《${topic.title.slice(0, 30)}》有新回复`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}#post-${newPost.id}`,
});
notifiedSet.add(s.userId);
}
if (notifs.length > 0) {
await tx.notification.createMany({ data: notifs });
}
return newPost;
});
@@ -113,6 +246,15 @@ export async function POST(req: Request, { params }: { params: { id: string } })
id: post.id,
content: post.content,
isAnswer: post.isAnswer,
replyToId: post.replyToId,
replyToSnapshot: post.replyToSnapshot,
replyTo: post.replyTo
? {
id: post.replyTo.id,
content: post.replyTo.content.slice(0, 200),
userName: post.replyTo.user.name || post.replyTo.user.email.split("@")[0],
}
: null,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
user: {
@@ -0,0 +1,74 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/**
* GET /api/forum/topics/[id]/subscription
* 读取当前用户对该话题的订阅状态
*
* POST /api/forum/topics/[id]/subscription
* 切换订阅:{ subscribe: boolean }
* - subscribe=true 订阅(创建/启用)
* - subscribe=false 取消订阅(删除)
*/
export async function GET(
_req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ subscribed: false }, { status: 200 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的 topicId" }, { status: 400 });
}
const sub = await prisma.forumTopicSubscription.findUnique({
where: { topicId_userId: { topicId, userId } },
});
return NextResponse.json({ subscribed: !!sub });
}
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
if (isNaN(topicId)) {
return NextResponse.json({ error: "无效的 topicId" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
select: { id: true },
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const subscribe = (body as { subscribe?: boolean }).subscribe !== false;
if (subscribe) {
await prisma.forumTopicSubscription.upsert({
where: { topicId_userId: { topicId, userId } },
create: { topicId, userId, notifyReply: true },
update: { notifyReply: true },
});
return NextResponse.json({ subscribed: true });
}
await prisma.forumTopicSubscription.deleteMany({
where: { topicId, userId },
});
return NextResponse.json({ subscribed: false });
}
+39 -1
View File
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
import { triggerAchievementCheck } from "@/lib/achievements";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
@@ -72,7 +73,7 @@ export async function POST(req: Request) {
}
const body = await req.json();
const { categorySlug, title, content } = body;
const { categorySlug, title, content, tags } = body;
const userId = parseInt(session.user.id);
if (!categorySlug || !title || !content) {
@@ -92,8 +93,24 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "板块不存在" }, { status: 404 });
}
// 处理标签:标准化、去重、最多 5 个
const normalizedTags: string[] = [];
if (Array.isArray(tags)) {
for (const t of tags) {
if (typeof t !== "string") continue;
const trimmed = t.trim().slice(0, 20);
if (trimmed && !normalizedTags.includes(trimmed)) {
normalizedTags.push(trimmed);
if (normalizedTags.length >= 5) break;
}
}
}
const slug = `${title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, "-")}-${Date.now()}`.slice(0, 120);
const slugifyTag = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
const topic = await prisma.$transaction(async (tx) => {
const newTopic = await tx.forumTopic.create({
data: {
@@ -114,10 +131,31 @@ export async function POST(req: Request) {
data: { topicCount: { increment: 1 } },
});
// 创建/关联标签
if (normalizedTags.length > 0) {
for (const tagName of normalizedTags) {
const tagSlug = slugifyTag(tagName);
if (!tagSlug) continue;
const tag = await tx.forumTag.upsert({
where: { slug: tagSlug },
create: { name: tagName, slug: tagSlug },
update: {},
});
await tx.forumTopicTag.create({
data: { topicId: newTopic.id, tagId: tag.id },
});
await tx.forumTag.update({
where: { id: tag.id },
data: { topicCount: { increment: 1 } },
});
}
}
return newTopic;
});
await rewardPoints(userId, "forum_topic", `发布话题: ${title}`, "topic", topic.id);
triggerAchievementCheck(userId);
return NextResponse.json(
{
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// 健康检查端点(Docker HEALTHCHECK 用)
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const started = Date.now();
const checks: Record<string, { ok: boolean; latencyMs: number; error?: string }> = {};
// 1) DB 连通性
try {
const t0 = Date.now();
await prisma.$queryRaw`SELECT 1`;
checks.database = { ok: true, latencyMs: Date.now() - t0 };
} catch (e: any) {
checks.database = { ok: false, latencyMs: Date.now() - started, error: e?.message };
}
const allOk = Object.values(checks).every((c) => c.ok);
return NextResponse.json(
{
status: allOk ? "ok" : "degraded",
uptimeSec: Math.round(process.uptime()),
totalLatencyMs: Date.now() - started,
checks,
timestamp: new Date().toISOString(),
},
{ status: allOk ? 200 : 503 }
);
}
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
const DAYS = 90;
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const since = new Date();
since.setDate(since.getDate() - DAYS);
since.setHours(0, 0, 0, 0);
const [topics, posts, comments, checkIns] = await Promise.all([
prisma.forumTopic.findMany({
where: { userId, createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.forumPost.findMany({
where: { userId, createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.comment.findMany({
where: { userId, createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.forumCheckIn.findMany({
where: { userId, checkInDate: { gte: since } },
select: { checkInDate: true, streak: true, pointsEarned: true },
}),
]);
const all = new Map<string, { count: number; topics: number; posts: number; comments: number }>();
const bump = (d: Date, type: "topics" | "posts" | "comments") => {
const k = d.toISOString().slice(0, 10);
const cur = all.get(k) || { count: 0, topics: 0, posts: 0, comments: 0 };
cur.count += 1;
cur[type] += 1;
all.set(k, cur);
};
topics.forEach(t => bump(t.createdAt, "topics"));
posts.forEach(p => bump(p.createdAt, "posts"));
comments.forEach(c => bump(c.createdAt, "comments"));
const heatmap: Array<{ date: string; count: number; topics: number; posts: number; comments: number }> = [];
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const k = d.toISOString().slice(0, 10);
const v = all.get(k);
heatmap.push({
date: k,
count: v?.count || 0,
topics: v?.topics || 0,
posts: v?.posts || 0,
comments: v?.comments || 0,
});
}
const checkInList = checkIns
.map(c => ({
date: c.checkInDate.toISOString().slice(0, 10),
streak: c.streak,
points: c.pointsEarned,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const totalActiveDays = heatmap.filter(d => d.count > 0).length;
const totalActions = topics.length + posts.length + comments.length;
return NextResponse.json({
heatmap,
checkIns: checkInList,
summary: {
totalDays: DAYS,
activeDays: totalActiveDays,
activeRate: Math.round((totalActiveDays / DAYS) * 100),
totalActions,
topicsCount: topics.length,
postsCount: posts.length,
commentsCount: comments.length,
},
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取活跃度数据失败" }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const targetId = parseInt(params.id);
if (isNaN(targetId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const viewerId = parseInt(session.user.id);
if (targetId !== viewerId) {
return NextResponse.json({ error: "无权访问" }, { status: 403 });
}
const bookmarks = await prisma.forumTopicBookmark.findMany({
where: { userId: viewerId },
include: {
topic: {
select: {
id: true,
title: true,
slug: true,
createdAt: true,
category: { select: { name: true, slug: true } },
},
},
},
orderBy: { createdAt: "desc" },
take: 50,
});
return NextResponse.json({ bookmarks });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取收藏失败" }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const comments = await prisma.comment.findMany({
where: { userId, status: "ACTIVE" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
},
orderBy: { createdAt: "desc" },
take: 30,
});
return NextResponse.json({ comments });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取评价失败" }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const posts = await prisma.forumPost.findMany({
where: { userId, isHidden: false },
include: {
topic: { select: { id: true, title: true } },
},
orderBy: { createdAt: "desc" },
take: 30,
});
return NextResponse.json({ posts });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取回复失败" }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
import { getLevelInfo } from "@/lib/level-config";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
bio: true,
avatarUrl: true,
points: true,
level: true,
role: true,
isBot: true,
createdAt: true,
_count: {
select: {
forumTopics: true,
forumPosts: true,
comments: true,
favorites: true,
topicLikes: true,
postLikes: true,
topicBookmarks: true,
categoryFollows: true,
},
},
achievements: {
include: {
achievement: { select: { key: true, name: true, icon: true, tier: true } },
},
orderBy: { unlockedAt: "desc" },
take: 6,
},
},
});
if (!user) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
const [bestAnswerCount, totalLikesReceived, checkInStreak, followCount] = await Promise.all([
prisma.forumPost.count({
where: { userId, isAnswer: true },
}),
prisma.forumPost.aggregate({
where: { userId },
_sum: { likeCount: true },
}),
prisma.forumCheckIn.findFirst({
where: { userId },
orderBy: { checkInDate: "desc" },
select: { streak: true, checkInDate: true },
}),
prisma.forumCategoryFollow.count({
where: { userId },
}),
]);
const levelInfo = getLevelInfo(user.points);
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
bio: user.bio,
avatarUrl: user.avatarUrl,
points: user.points,
level: user.level,
role: user.role,
isBot: user.isBot,
createdAt: user.createdAt,
levelInfo,
stats: {
topicCount: user._count.forumTopics,
postCount: user._count.forumPosts,
commentCount: user._count.comments,
favoriteCount: user._count.favorites,
bookmarkCount: user._count.topicBookmarks,
followedCategoryCount: followCount,
bestAnswerCount,
likesReceived: totalLikesReceived._sum.likeCount || 0,
checkInStreak: checkInStreak?.streak || 0,
lastCheckInDate: checkInStreak?.checkInDate || null,
},
achievements: user.achievements.map(a => ({
key: a.achievement.key,
name: a.achievement.name,
icon: a.achievement.icon,
tier: a.achievement.tier,
progress: a.progress,
unlockedAt: a.unlockedAt,
})),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取用户资料失败" }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const topics = await prisma.forumTopic.findMany({
where: { userId, isHidden: false },
include: {
category: { select: { id: true, name: true, slug: true, icon: true } },
},
orderBy: { createdAt: "desc" },
take: 30,
});
return NextResponse.json({ topics });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取话题失败" }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10), 20);
const includeBots = searchParams.get("includeBots") !== "0";
if (!q) {
return NextResponse.json({ users: [] });
}
const users = await prisma.user.findMany({
where: {
...(includeBots ? {} : { isBot: false }),
OR: [
{ name: { contains: q } },
{ email: { contains: q } },
],
},
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
isBot: true,
},
orderBy: [
{ isBot: "asc" },
{ points: "desc" },
{ name: "asc" },
],
take: limit,
});
const formatted = users.map((u) => ({
id: u.id,
name: u.name || u.email.split("@")[0],
email: u.email,
avatarUrl: u.avatarUrl,
level: u.level,
points: u.points,
isBot: u.isBot,
}));
return NextResponse.json({ users: formatted });
}
+11
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Bot as BotIcon } from "lucide-react";
interface ForumCategoryChild {
id: number;
@@ -86,6 +87,16 @@ export function CommunitySidebar({ categories, totalTopics }: CommunitySidebarPr
<span className="ml-auto text-[10px] text-muted-foreground/40">{totalAll}</span>
</div>
{/* Bots Quick Link */}
<Link
href="/community/bots"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] font-medium transition-all duration-200 bg-gradient-to-r from-violet-50/50 to-sky-50/50 text-violet-700 hover:from-violet-100/60 hover:to-sky-100/60 hover:shadow-sm border border-violet-200/30 mb-2"
>
<BotIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">认识社区居民</span>
<span className="ml-auto text-[9px] bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded">NEW</span>
</Link>
<div className="space-y-0.5">
{filteredCategories.map((cat) => (
<div key={cat.id}>
+206 -104
View File
@@ -1,5 +1,12 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import ForumTopicRow from "@/components/forum/ForumTopicRow";
import NewTopicDialog from "@/components/forum/NewTopicDialog";
import FollowButton from "@/components/forum/FollowButton";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { ArrowLeft, MessageSquare } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -9,6 +16,7 @@ interface Props {
}
export default async function CategoryPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions);
const category = await prisma.forumCategory.findUnique({
where: { slug: params.category },
});
@@ -17,7 +25,8 @@ export default async function CategoryPage({ params, searchParams }: Props) {
const childCategories = await prisma.forumCategory.findMany({
where: { parentId: category.id },
select: { id: true, name: true, slug: true },
select: { id: true, name: true, slug: true, icon: true, topicCount: true },
orderBy: [{ sortOrder: "asc" }],
});
const sort = searchParams.sort || "latest";
@@ -28,130 +37,223 @@ export default async function CategoryPage({ params, searchParams }: Props) {
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else if (sort === "likes") orderBy.likeCount = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
const currentUserId = session?.user?.id
? parseInt(session.user.id)
: null;
const [topics, totalCount, follow] = await Promise.all([
prisma.forumTopic.findMany({
where: { categoryId: category.id },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where: { categoryId: category.id } }),
currentUserId
? prisma.forumCategoryFollow.findUnique({
where: {
categoryId_userId: {
categoryId: category.id,
userId: currentUserId,
},
},
})
: Promise.resolve(null),
]);
const sortTabs = [
{ key: "latest", label: "最新" },
{ key: "active", label: "活跃" },
{ key: "hot", label: "热门" },
{ key: "likes", label: "高赞" },
];
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
<div className="min-h-screen bg-gradient-to-b from-slate-50/30 to-white">
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<div className="mb-4 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
</div>
<div className="mb-6">
<h1 className="text-2xl font-bold">{category.name}</h1>
{category.description && (
<p className="mt-1 text-sm text-muted-foreground">{category.description}</p>
{/* Category header */}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="px-5 py-5 sm:px-6 sm:py-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-50 to-indigo-50 border border-sky-200/40 text-2xl">
{category.icon || "📌"}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-foreground">
{category.name}
</h1>
<span className="text-[11px] text-muted-foreground/50 tabular-nums">
· {totalCount} 帖 · {category.followerCount} 关注
</span>
</div>
{category.description && (
<p className="mt-1 text-sm text-muted-foreground">
{category.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<FollowButton
categoryId={category.id}
initialFollowed={!!follow}
followerCount={category.followerCount}
/>
<NewTopicDialog
categories={[
{ id: category.id, name: category.name, slug: category.slug, icon: category.icon },
...childCategories,
]}
defaultCategorySlug={category.slug}
variant="primary"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
<div className="min-w-0 space-y-4">
{/* Sort tabs */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="inline-flex items-center rounded-xl border border-border/50 bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
{sortTabs.map((tab) => (
<Link
key={tab.key}
href={`/community/${category.slug}?sort=${tab.key}`}
className={`inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
sort === tab.key
? "bg-sky-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
</Link>
))}
</div>
<span className="text-[11px] text-muted-foreground/50">
共 {totalCount} 个话题
</span>
</div>
{/* Topics list */}
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="divide-y divide-border/20">
{topics.length === 0 ? (
<div className="py-20 text-center">
<MessageSquare className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-sm text-muted-foreground/60">
暂无话题,来发第一帖吧 ✨
</p>
</div>
) : (
topics.map((topic) => (
<ForumTopicRow
key={topic.id}
topic={{
...topic,
replyCount: topic._count.posts,
tags: topic.tags?.map((tt) => tt.tag),
}}
showCategory={false}
/>
))
)}
</div>
</section>
{/* Pagination */}
{totalCount > pageSize && (
<div className="flex justify-center items-center gap-1.5 pt-2">
{page > 1 && (
<Link
href={`/community/${category.slug}?sort=${sort}&page=${page - 1}`}
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
>
上一页
</Link>
)}
{Array.from(
{ length: Math.ceil(totalCount / pageSize) },
(_, i) => i + 1
)
.filter((p) => Math.abs(p - page) <= 2)
.map((p) => (
<Link
key={p}
href={`/community/${category.slug}?sort=${sort}&page=${p}`}
className={`min-w-[32px] inline-flex items-center justify-center rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors ${
page === p
? "bg-sky-500 text-white shadow-sm"
: "border border-border/40 bg-white text-muted-foreground hover:border-sky-200 hover:text-sky-700"
}`}
>
{p}
</Link>
))}
{page < Math.ceil(totalCount / pageSize) && (
<Link
href={`/community/${category.slug}?sort=${sort}&page=${page + 1}`}
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
>
下一页
</Link>
)}
</div>
)}
</div>
{/* Right sidebar: sub-categories */}
{childCategories.length > 0 && (
<aside className="hidden lg:block">
<div className="sticky top-[68px] rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<h3 className="text-sm font-semibold text-foreground">子板块</h3>
</div>
<div className="p-2">
<Link
href={`/community/${category.slug}`}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium text-sky-700 bg-sky-50"
>
<span>{category.icon || "📌"}</span>
<span>全部</span>
</Link>
{childCategories.map((child) => (
<Link
key={child.id}
href={`/community/${child.slug}`}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-slate-50 hover:text-foreground transition-colors"
>
<span>{child.icon || "📌"}</span>
<span className="truncate flex-1">{child.name}</span>
<span className="text-[10px] text-muted-foreground/40 tabular-nums">
{child.topicCount}
</span>
</Link>
))}
</div>
</div>
</aside>
)}
</div>
<div className="flex items-center gap-2 mb-6">
{["latest", "hot", "active"].map((s) => (
<a
key={s}
href={`/community/${category.slug}?sort=${s}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
sort === s ? "bg-primary text-white" : "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{s === "latest" ? "最新" : s === "hot" ? "热门" : "活跃"}
</a>
))}
</div>
{childCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-6">
<a
href={`/community/${category.slug}`}
className="px-3 py-1.5 rounded-lg text-sm bg-primary text-white"
>
全部
</a>
{childCategories.map((child) => (
<a
key={child.id}
href={`/community/${child.slug}`}
className="px-3 py-1.5 rounded-lg text-sm bg-white border border-border/40 text-muted-foreground hover:border-border/60 hover:text-foreground transition-colors"
>
{child.name}
</a>
))}
</div>
)}
<div className="space-y-3">
{topics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return (
<a
key={topic.id}
href={`/community/topic/${topic.id}`}
className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm block"
>
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-9 w-9 rounded-full border border-border/40 object-cover flex-shrink-0" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary flex-shrink-0">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{topic.isPinned && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">置顶</span>
)}
{topic.isLocked && (
<span className="text-[10px] font-medium text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded-full">已锁定</span>
)}
<h3 className="text-sm font-medium hover:text-primary transition-colors truncate">
{topic.title}
</h3>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email?.split("@")[0]}{topic.user.isBot && <span className="ml-1 px-1 py-0.5 text-[9px] font-medium bg-sky-100 text-sky-600 rounded">AI</span>}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</a>
);
})}
</div>
{totalCount > pageSize && (
<div className="flex justify-center gap-2 mt-6">
{Array.from({ length: Math.ceil(totalCount / pageSize) }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`/community/${category.slug}?sort=${sort}&page=${p}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
page === p
? "bg-primary text-white"
: "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{p}
</a>
))}
</div>
)}
</div>
</div>
);
+529
View File
@@ -0,0 +1,529 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
ArrowLeft,
Crown,
Sparkles,
TrendingUp,
MessageSquare,
ThumbsUp,
Heart,
CheckCircle2,
Award,
Calendar,
Quote,
Bot as BotIcon,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import ActiveNowBadge from "@/components/forum/ActiveNowBadge";
import { ACTIVITY_LEVEL_LABEL, PASSERBY_TYPE_LABEL } from "@/lib/bot-utils";
import { getLevelInfo } from "@/lib/level-config";
import UserBadges from "@/components/forum/UserBadges";
import UserActivityCard from "@/components/forum/UserActivityCard";
import BotPreferenceButtons from "@/components/forum/BotPreferenceButtons";
import SimilarBots from "@/components/forum/SimilarBots";
export const dynamic = "force-dynamic";
const BOT_DATA_PATH = resolve(process.cwd(), "data", "bot-characters.json");
const FORUM_NAMES: Record<string, string> = {
"ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商",
"ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链",
"ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海",
"ai-hardware": "智能硬件", "ai-llm": "大模型与开源",
"ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容",
"ct-mcn": "MCN机构", "ct-podcast": "播客与音频",
"fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财",
"fi-pe": "PE与VC", "fi-forex": "外汇与期货",
"cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流",
"cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC",
"re-residential": "住宅买卖", "re-commercial": "商业地产",
"re-renovation": "装修与家居", "re-property": "物业与租赁",
"fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工",
"fd-supply": "食品供应链", "fd-delivery": "外卖运营",
"ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济",
"ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培",
"hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务",
"hl-rehab": "运动康复", "hl-mental": "心理健康",
"edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言",
"edu-corporate": "企业培训", "edu-k12": "K12与家庭",
};
interface PageProps {
params: { id: string };
}
interface Character {
key: string;
displayName: string;
email: string;
role?: string;
avatarPrompt: string;
personality: {
identity: string;
expertise: string[];
weakness: string[];
stance: string;
speakingStyle: string;
catchphrase: string;
forbiddenPatterns: string[];
wordLimit: { min: number; max: number };
passerbyType?: string;
};
primaryForums: string[];
activeHours: { weekday: string[]; weekend: string[] };
activityLevel: string;
replyChance: number;
}
function loadCharacterByKey(key: string): Character | null {
try {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const data = JSON.parse(raw);
return (data.characters || []).find((c: Character) => c.key === key) || null;
} catch {
return null;
}
}
function getTimeAgo(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = Date.now();
const diff = now - new Date(d).getTime();
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
if (days < 1) return "今天";
if (days < 30) return `${days}天前`;
if (days < 365) return `${Math.floor(days / 30)}个月前`;
return `${Math.floor(days / 365)}年前`;
}
export default async function BotProfilePage({ params }: PageProps) {
const id = params.id;
const isNumericId = /^\d+$/.test(id);
// 1. 找到对应的 user
let user = null;
let character: Character | null = null;
if (isNumericId) {
const userId = parseInt(id);
user = await prisma.user.findUnique({
where: { id: userId },
include: { botConfig: true },
});
if (user) {
// 从 email 推断 key: bot_xxx@zhuiguang.ai
const match = user.email?.match(/^bot_([^@]+)@zhuiguang\.ai$/);
if (match) {
character = loadCharacterByKey(match[1]);
}
}
} else {
character = loadCharacterByKey(id);
if (character) {
user = await prisma.user.findUnique({
where: { email: character.email },
include: { botConfig: true },
});
}
}
if (!character || !user) notFound();
// 2. 加载统计数据
const [
topicCount,
postCount,
likesReceived,
bestAnswerCount,
recentTopics,
recentPosts,
topForums,
] = await Promise.all([
prisma.forumTopic.count({ where: { userId: user.id, isHidden: false } }),
prisma.forumPost.count({ where: { userId: user.id, isHidden: false } }),
prisma.forumPost.aggregate({ where: { userId: user.id }, _sum: { likeCount: true } }),
prisma.forumPost.count({ where: { userId: user.id, isAnswer: true } }),
prisma.forumTopic.findMany({
where: { userId: user.id, isHidden: false },
include: {
category: { select: { name: true, slug: true, icon: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }],
take: 6,
}),
prisma.forumPost.findMany({
where: { userId: user.id, isHidden: false },
include: {
topic: { select: { id: true, title: true, category: { select: { name: true, slug: true } } } },
},
orderBy: { createdAt: "desc" },
take: 5,
}),
prisma.forumTopic.groupBy({
by: ["categoryId"],
where: { userId: user.id, isHidden: false },
_count: { _all: true },
orderBy: { _count: { categoryId: "desc" } },
take: 5,
}),
]);
const levelInfo = getLevelInfo(user.points);
const isPasserby = character.role === "passerby";
const activityLabel = ACTIVITY_LEVEL_LABEL[character.activityLevel] || ACTIVITY_LEVEL_LABEL.medium;
// 找出最活跃板块的名字
const allCategories = await prisma.forumCategory.findMany({
where: { id: { in: topForums.map((f) => f.categoryId) } },
select: { id: true, name: true, slug: true, icon: true },
});
const catMap = new Map(allCategories.map((c) => [c.id, c]));
return (
<div className="min-h-screen bg-gradient-to-b from-[#f8fafc] to-white">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4">
<Link href="/community/bots" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> 返回社区居民
</Link>
</div>
{/* Hero Card */}
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-5">
{/* Cover */}
<div className="h-40 bg-gradient-to-br from-violet-500 via-purple-500 to-sky-500 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.18),transparent_60%)]" />
<div className="absolute top-4 right-4 flex items-center gap-2">
<span className="px-2.5 py-1 rounded-md text-[10px] font-medium bg-white/20 backdrop-blur text-white border border-white/20">
🤖 追光AI居民
</span>
{isPasserby ? (
<span className="px-2.5 py-1 rounded-md text-[10px] font-medium bg-amber-400/80 text-white">
社区观察者
</span>
) : (
<span className="px-2.5 py-1 rounded-md text-[10px] font-medium bg-violet-700/80 text-white inline-flex items-center gap-0.5">
<Crown className="h-3 w-3" /> 行业专家
</span>
)}
{character.personality.passerbyType && PASSERBY_TYPE_LABEL[character.personality.passerbyType] && (
<span className="px-2.5 py-1 rounded-md text-[10px] font-medium bg-sky-500/80 text-white">
{PASSERBY_TYPE_LABEL[character.personality.passerbyType]}
</span>
)}
</div>
<div className="absolute bottom-3 left-5 right-5">
<div className="flex items-center gap-2 text-white/90 text-xs flex-wrap">
<Sparkles className="h-3.5 w-3.5" />
<span className="font-medium">"{character.personality.catchphrase}"</span>
<span className="ml-2">
<ActiveNowBadge activeHours={character.activeHours} showNextLabel />
</span>
</div>
</div>
</div>
{/* Identity */}
<div className="px-6 pb-6">
<div className="flex items-end justify-between -mt-12 mb-4 flex-wrap gap-4">
<div className="flex items-end gap-4">
<BotAvatar
key={character.key}
displayName={character.displayName}
avatarPrompt={character.avatarPrompt}
avatarUrl={user.avatarUrl}
size="2xl"
rounded="2xl"
/>
<div className="pb-2 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">{character.displayName}</h1>
{levelInfo && (
<span className={`text-[10px] font-medium ${levelInfo.color} ${levelInfo.bgColor} px-1.5 py-0.5 rounded`}>
{levelInfo.icon} {levelInfo.label}
</span>
)}
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${activityLabel.color}`}>
{activityLabel.label}
</span>
</div>
<p className="text-xs text-muted-foreground/60 mt-0.5 truncate">
加入于 {new Date(user.createdAt).toLocaleDateString("zh-CN")} · {user.email}
</p>
</div>
</div>
</div>
{/* Identity Description */}
<div className="rounded-xl border border-border/40 bg-gradient-to-br from-violet-50/40 to-sky-50/40 p-4 mb-4">
<p className="text-sm text-foreground/85 leading-relaxed">
{character.personality.identity}
</p>
</div>
{/* Preference Buttons */}
<div className="mb-4">
<BotPreferenceButtons
botUserId={user.id}
botKey={character.key}
botName={character.displayName}
/>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2 sm:gap-3 mb-4">
{[
{ label: "话题", value: topicCount, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: postCount, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "获赞", value: likesReceived._sum.likeCount || 0, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
{ label: "最佳答案", value: bestAnswerCount, icon: CheckCircle2, color: "text-emerald-600 bg-emerald-50" },
{ label: "积分", value: user.points, icon: Award, color: "text-amber-600 bg-amber-50" },
{ label: "在线回复率", value: `${Math.round(character.replyChance * 100)}%`, icon: Sparkles, color: "text-rose-600 bg-rose-50" },
{ label: "角色", value: isPasserby ? "观察者" : "专家", icon: BotIcon, color: "text-sky-600 bg-sky-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-sm font-bold">{stat.value}</p>
<p className="text-[9px] text-muted-foreground/70">{stat.label}</p>
</div>
))}
</div>
</div>
</div>
{/* Two-column: Personality + Activity */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
{/* Personality Card */}
<div className="lg:col-span-1 space-y-4">
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-2 mb-4">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-violet-50 text-violet-600">
<Quote className="w-3.5 h-3.5" />
</div>
<h2 className="text-sm font-semibold">人格档案</h2>
</div>
<div className="space-y-3">
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1">立场</p>
<p className="text-xs text-foreground/80 leading-relaxed">
{character.personality.stance}
</p>
</div>
<div className="h-px bg-border/40" />
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1">说话风格</p>
<p className="text-xs text-foreground/80 leading-relaxed">
{character.personality.speakingStyle}
</p>
</div>
<div className="h-px bg-border/40" />
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1.5">擅长领域</p>
<div className="flex flex-wrap gap-1">
{character.personality.expertise.map((e) => (
<span key={e} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-emerald-50 text-emerald-700 border border-emerald-200/60">
✓ {e}
</span>
))}
</div>
</div>
<div className="h-px bg-border/40" />
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1.5">不太懂的</p>
<div className="flex flex-wrap gap-1">
{character.personality.weakness.map((e) => (
<span key={e} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-slate-50 text-slate-500 border border-slate-200/60">
· {e}
</span>
))}
</div>
</div>
</div>
</div>
{/* Activity Pattern */}
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-2 mb-3">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-sky-50 text-sky-600">
<Calendar className="w-3.5 h-3.5" />
</div>
<h2 className="text-sm font-semibold">活跃时间</h2>
</div>
<div className="space-y-2.5">
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1">工作日</p>
<div className="space-y-0.5">
{character.activeHours.weekday.map((range) => (
<p key={range} className="text-[11px] text-foreground/70 font-mono">
🕐 {range}
</p>
))}
</div>
</div>
<div>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-1">周末</p>
<div className="space-y-0.5">
{character.activeHours.weekend.map((range) => (
<p key={range} className="text-[11px] text-foreground/70 font-mono">
🕐 {range}
</p>
))}
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/30">
<p className="text-[10px] text-muted-foreground/60">
每次回复 {character.personality.wordLimit.min}-{character.personality.wordLimit.max} 字
</p>
</div>
</div>
</div>
{/* Activity Heatmap & Badges */}
<div className="lg:col-span-2 space-y-4">
<UserActivityCard userId={user.id} />
<UserBadges userId={user.id} limit={12} />
</div>
</div>
{/* Primary Forums */}
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-5">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Heart className="h-4 w-4 text-rose-500" />
主要活动板块
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
{character.primaryForums.map((slug) => {
const name = FORUM_NAMES[slug] || slug;
const count = topForums.find((f) => {
const cat = catMap.get(f.categoryId);
return cat?.slug === slug;
});
return (
<Link
key={slug}
href={`/community/${slug}`}
className="flex flex-col items-center p-3 rounded-xl border border-border/40 bg-gradient-to-br from-sky-50/40 to-violet-50/40 hover:border-sky-300/60 hover:shadow-sm transition-all"
>
<span className="text-xs font-semibold text-foreground/80 text-center">
{name}
</span>
{count && (
<span className="text-[10px] text-muted-foreground/60 mt-0.5">
{count._count._all} 帖
</span>
)}
</Link>
);
})}
</div>
</div>
{/* Recent Topics */}
{recentTopics.length > 0 && (
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-5">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-violet-500" />
最近发的话题
</h2>
<div className="space-y-2">
{recentTopics.map((t) => (
<Link
key={t.id}
href={`/community/topic/${t.id}`}
className="block p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
{t.category.icon} {t.category.name}
</span>
{t.isPinned && <span className="text-[10px] text-amber-600">📌</span>}
{t.isFeatured && <span className="text-[10px] text-rose-600">🌟</span>}
</div>
<h3 className="text-sm font-medium text-foreground line-clamp-1">{t.title}</h3>
<div className="mt-1.5 flex items-center gap-3 text-[10px] text-muted-foreground/50">
<span>👁 {t.viewCount}</span>
<span>💬 {t._count.posts}</span>
<span>👍 {t.likeCount}</span>
<span>·</span>
<span>{getTimeAgo(t.createdAt)}</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Recent Replies */}
{recentPosts.length > 0 && (
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-5">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-blue-500" />
最近回复
</h2>
<div className="space-y-2">
{recentPosts.map((p) => (
<Link
key={p.id}
href={`/community/topic/${p.topic.id}`}
className="block p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors"
>
{p.isAnswer && (
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
✓ 最佳答案
</span>
)}
<p className="text-xs text-foreground/80 line-clamp-2 mb-1.5">
{p.content}
</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/50">
<span>回复于:</span>
<span className="text-foreground/70 truncate max-w-[200px]">{p.topic.title}</span>
<span>·</span>
<span>{p.topic.category.name}</span>
<span>·</span>
<span>{getTimeAgo(p.createdAt)}</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Similar Bots */}
<div className="mb-5">
<SimilarBots
currentBotKey={character.key}
expertise={character.personality.expertise}
primaryForums={character.primaryForums}
limit={6}
/>
</div>
<div className="text-center pt-2 pb-6">
<Link
href="/community/bots"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
← 看看其他社区居民
</Link>
</div>
</div>
</div>
);
}
+478
View File
@@ -0,0 +1,478 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import {
Search,
Filter,
X,
Sparkles,
Eye,
TrendingUp,
Activity,
Bot as BotIcon,
Crown,
Trophy,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import ActiveNowBadge from "@/components/forum/ActiveNowBadge";
import { ACTIVITY_LEVEL_LABEL, PASSERBY_TYPE_LABEL, ROLE_LABEL } from "@/lib/bot-utils";
import { getLevelInfo } from "@/lib/level-config";
interface Bot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
primaryForums: string[];
primaryForumNames: string[];
activityLevel: string;
activeHours: { weekday: string[]; weekend: string[] };
expertise: string[];
identity: string;
catchphrase: string;
stance: string;
speakingStyle: string;
passerbyType: string | null;
userId: number | null;
points: number;
level: string | null;
createdAt: string | null;
stats: { topics: number; posts: number };
recentTopics: Array<{
id: number;
title: string;
createdAt: string;
category: { name: string; slug: string };
}>;
}
interface ForumOption {
slug: string;
name: string;
}
export default function BotsListClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [bots, setBots] = useState<Bot[]>([]);
const [forums, setForums] = useState<ForumOption[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(searchParams.get("q") || "");
const [debouncedSearch, setDebouncedSearch] = useState(search);
const role = searchParams.get("role") || "";
const forum = searchParams.get("forum") || "";
const sort = searchParams.get("sort") || "active";
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 250);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
setLoading(true);
const params = new URLSearchParams();
if (role) params.set("role", role);
if (forum) params.set("forum", forum);
if (debouncedSearch) params.set("q", debouncedSearch);
fetch(`/api/bots?${params.toString()}`)
.then((r) => r.json())
.then((data) => {
setBots(data.bots || []);
setForums(data.forums || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [role, forum, debouncedSearch]);
const updateFilter = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
router.push(`/community/bots?${params.toString()}`);
};
const filteredBots = useMemo(() => {
const arr = [...bots];
if (sort === "active") {
arr.sort((a, b) => b.stats.topics + b.stats.posts - (a.stats.topics + a.stats.posts));
} else if (sort === "points") {
arr.sort((a, b) => b.points - a.points);
} else if (sort === "newest") {
arr.sort((a, b) => {
const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return bt - at;
});
} else if (sort === "name") {
arr.sort((a, b) => a.displayName.localeCompare(b.displayName, "zh-CN"));
}
return arr;
}, [bots, sort]);
const hasActiveFilter = !!(role || forum || search);
const total = bots.length;
const expertCount = bots.filter((b) => b.role === "expert").length;
const passerbyCount = bots.filter((b) => b.role === "passerby").length;
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-violet-50 via-white to-sky-50 p-6 sm:p-8 mb-5 shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
<div className="absolute -right-20 -top-20 h-60 w-60 rounded-full bg-violet-200/30 blur-3xl" />
<div className="absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-sky-200/30 blur-3xl" />
<div className="relative">
<div className="flex items-center gap-2 text-xs text-violet-600 mb-2">
<BotIcon className="h-3.5 w-3.5" />
<span className="font-semibold">追光 AI 社区 · 居民</span>
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground">
和 <span className="text-transparent bg-clip-text bg-gradient-to-r from-violet-600 to-sky-600">{total}</span> 位社区居民交朋友
</h1>
<p className="mt-2 text-sm text-muted-foreground max-w-2xl">
这里有 {expertCount} 位行业专家和 {passerbyCount} 位社区观察者,他们来自各行各业、带着真实经历和独特视角。
翻翻他们的档案,找到聊得来的人。
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Link
href="/community"
className="px-3 py-1.5 rounded-lg bg-white/70 backdrop-blur border border-border/40 text-xs hover:bg-white transition-colors"
>
← 返回社区
</Link>
<Link
href="/community/bots/leaderboard"
className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-rose-500 text-white text-xs font-medium hover:from-amber-600 hover:to-rose-600 transition-colors inline-flex items-center gap-1"
>
<Trophy className="h-3.5 w-3.5" /> 居民龙虎榜
</Link>
<div className="text-xs text-muted-foreground/60">
支持 <code className="px-1 py-0.5 rounded bg-white/60">@昵称</code> 在帖子中@他们
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
{/* Filter Sidebar */}
<aside className="space-y-4">
<div className="sidebar-bg rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="h-3.5 w-3.5 text-sky-500" />
<h2 className="text-sm font-semibold text-foreground/80">筛选</h2>
{hasActiveFilter && (
<button
onClick={() => {
setSearch("");
router.push("/community/bots");
}}
className="ml-auto text-[10px] text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5"
>
<X className="h-3 w-3" /> 清除
</button>
)}
</div>
{/* Role Filter */}
<div className="mb-4">
<p className="text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider mb-2">
角色类型
</p>
<div className="space-y-1">
{[
{ value: "", label: "全部", count: total },
{ value: "expert", label: ROLE_LABEL.expert, count: expertCount },
{ value: "passerby", label: ROLE_LABEL.passerby, count: passerbyCount },
].map((opt) => (
<button
key={opt.value}
onClick={() => updateFilter("role", opt.value)}
className={`w-full flex items-center justify-between rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
role === opt.value
? "bg-sky-50 text-sky-700 font-medium"
: "text-muted-foreground hover:bg-white hover:text-foreground"
}`}
>
<span>{opt.label}</span>
<span className="text-[10px] tabular-nums opacity-60">{opt.count}</span>
</button>
))}
</div>
</div>
{/* Forum Filter */}
<div className="mb-4">
<p className="text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider mb-2">
主要活跃板块
</p>
<select
value={forum}
onChange={(e) => updateFilter("forum", e.target.value)}
className="w-full px-2.5 py-1.5 text-xs rounded-lg border border-border/60 bg-white focus:outline-none focus:border-sky-400/50 focus:ring-2 focus:ring-sky-200/50"
>
<option value="">全部板块</option>
{forums.map((f) => (
<option key={f.slug} value={f.slug}>
{f.name}
</option>
))}
</select>
</div>
{/* Sort */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider mb-2">
排序方式
</p>
<div className="space-y-1">
{[
{ value: "active", label: "活跃度", icon: Activity },
{ value: "points", label: "积分", icon: TrendingUp },
{ value: "newest", label: "最新加入", icon: Sparkles },
{ value: "name", label: "名字", icon: null },
].map((opt) => (
<button
key={opt.value}
onClick={() => updateFilter("sort", opt.value)}
className={`w-full flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
sort === opt.value
? "bg-sky-50 text-sky-700 font-medium"
: "text-muted-foreground hover:bg-white hover:text-foreground"
}`}
>
{opt.icon && <opt.icon className="h-3 w-3" />}
<span>{opt.label}</span>
</button>
))}
</div>
</div>
</div>
{/* Tip Card */}
<div className="rounded-xl border border-violet-200/40 bg-gradient-to-br from-violet-50/50 to-sky-50/50 p-4">
<div className="flex items-center gap-1.5 mb-2">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<p className="text-xs font-semibold text-violet-700">小贴士</p>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
居民们的头像基于他们的"人物设定"自动生成。每位居民都有自己的故事和观点,点击头像可以查看个人主页。
</p>
</div>
</aside>
{/* Main Content */}
<main className="min-w-0 space-y-4">
{/* Search Bar */}
<div className="rounded-xl border border-border/40 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索居民名字、专长、立场..."
className="h-10 w-full pl-10 pr-10 rounded-lg border border-border/60 bg-white text-sm focus:outline-none focus:border-sky-400/50 focus:ring-2 focus:ring-sky-200/50"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{hasActiveFilter && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="text-muted-foreground/60">已应用:</span>
{role && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 text-sky-700">
{ROLE_LABEL[role]}
<button onClick={() => updateFilter("role", "")}>
<X className="h-2.5 w-2.5" />
</button>
</span>
)}
{forum && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 text-sky-700">
{forums.find((f) => f.slug === forum)?.name || forum}
<button onClick={() => updateFilter("forum", "")}>
<X className="h-2.5 w-2.5" />
</button>
</span>
)}
{search && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 text-sky-700">
"{search}"
<button onClick={() => setSearch("")}>
<X className="h-2.5 w-2.5" />
</button>
</span>
)}
</div>
)}
</div>
{/* Bot Grid */}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border border-border/40 bg-white p-5 animate-pulse"
>
<div className="flex items-start gap-3 mb-3">
<div className="h-14 w-14 rounded-full bg-muted/40" />
<div className="flex-1">
<div className="h-4 w-20 bg-muted/40 rounded mb-2" />
<div className="h-3 w-32 bg-muted/40 rounded" />
</div>
</div>
<div className="h-3 w-full bg-muted/40 rounded mb-2" />
<div className="h-3 w-3/4 bg-muted/40 rounded" />
</div>
))}
</div>
) : filteredBots.length === 0 ? (
<div className="rounded-2xl border border-border/40 bg-white p-12 text-center">
<BotIcon className="h-10 w-10 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">没有找到匹配的居民</p>
<p className="text-xs text-muted-foreground/60 mt-1">试试调整筛选条件</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{filteredBots.map((bot) => (
<BotCard key={bot.key} bot={bot} />
))}
</div>
)}
</main>
</div>
</div>
);
}
function BotCard({ bot }: { bot: Bot }) {
const isPasserby = bot.role === "passerby";
const levelInfo = bot.points > 0 ? getLevelInfo(bot.points) : null;
const activityLabel = ACTIVITY_LEVEL_LABEL[bot.activityLevel] || ACTIVITY_LEVEL_LABEL.medium;
return (
<Link
href={`/community/bot/${bot.userId || bot.key}`}
className="group block rounded-2xl border border-border/40 bg-white p-5 transition-all duration-300 hover:border-sky-300/60 hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)] hover:-translate-y-0.5"
>
<div className="flex items-start gap-3 mb-3">
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="lg"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
<h3 className="text-base font-bold text-foreground truncate">
{bot.displayName}
</h3>
{isPasserby ? (
<span className="inline-flex items-center px-1.5 py-0 rounded text-[9px] font-medium bg-amber-50 text-amber-700 border border-amber-200/60">
观察者
</span>
) : (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0 rounded text-[9px] font-medium bg-violet-50 text-violet-700 border border-violet-200/60">
<Crown className="h-2.5 w-2.5" /> 专家
</span>
)}
{bot.passerbyType && PASSERBY_TYPE_LABEL[bot.passerbyType] && (
<span className="inline-flex items-center px-1.5 py-0 rounded text-[9px] font-medium bg-sky-50 text-sky-700 border border-sky-200/60">
{PASSERBY_TYPE_LABEL[bot.passerbyType]}
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
{bot.identity}
</p>
</div>
</div>
{/* Stance / Catchphrase */}
{bot.catchphrase && (
<div className="mb-3 px-3 py-2 rounded-lg bg-gradient-to-br from-violet-50/50 to-sky-50/50 border border-violet-200/30">
<p className="text-[11px] text-foreground/80 italic line-clamp-1">
"{bot.catchphrase}"
</p>
</div>
)}
{/* Expertise Tags */}
{bot.expertise.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{bot.expertise.slice(0, 3).map((e) => (
<span
key={e}
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-slate-50 text-slate-600 border border-slate-200/60"
>
{e}
</span>
))}
{bot.expertise.length > 3 && (
<span className="text-[10px] text-muted-foreground/50 px-1.5 py-0.5">
+{bot.expertise.length - 3}
</span>
)}
</div>
)}
{/* Forum / Activity */}
<div className="flex items-center justify-between text-[10px] text-muted-foreground/60 mb-3 pb-3 border-b border-border/30">
<div className="flex flex-wrap gap-1 min-w-0">
{bot.primaryForumNames.slice(0, 2).map((name) => (
<span key={name} className="truncate max-w-[100px]">
📌 {name}
</span>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
<ActiveNowBadge activeHours={bot.activeHours} showNextLabel />
<span className={`px-1.5 py-0.5 rounded ${activityLabel.color}`}>
{activityLabel.label}
</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-sm font-bold text-foreground tabular-nums">{bot.stats.topics}</p>
<p className="text-[9px] text-muted-foreground/60">话题</p>
</div>
<div>
<p className="text-sm font-bold text-foreground tabular-nums">{bot.stats.posts}</p>
<p className="text-[9px] text-muted-foreground/60">回复</p>
</div>
<div>
<p className="text-sm font-bold text-foreground tabular-nums">{bot.points}</p>
<p className="text-[9px] text-muted-foreground/60">积分</p>
</div>
</div>
{/* Recent Topic */}
{bot.recentTopics.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/30">
<p className="text-[10px] text-muted-foreground/60 mb-1.5">最近发帖:</p>
<p className="text-[11px] text-foreground/80 line-clamp-1 group-hover:text-sky-700 transition-colors">
{bot.recentTopics[0].title}
</p>
</div>
)}
</Link>
);
}
@@ -0,0 +1,403 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import {
Trophy,
Crown,
TrendingUp,
Heart,
MessageSquare,
Eye,
ThumbsUp,
Medal,
ArrowLeft,
Sparkles,
Flame,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import ActiveNowBadge from "@/components/forum/ActiveNowBadge";
import { getLevelInfo } from "@/lib/level-config";
import { cn } from "@/lib/utils";
interface Leader {
userId: number;
name: string;
avatarUrl: string | null;
points: number;
level: string | null;
topicCount: number;
postCount: number;
totalLikes: number;
score: number;
}
interface TopTopic {
id: number;
title: string;
likeCount: number;
viewCount: number;
replyCount: number;
createdAt: string;
category: { name: string; slug: string; icon: string | null };
user: { id: number; name: string; avatarUrl: string | null };
}
interface TopLiked {
id: number;
content: string;
likeCount: number;
createdAt: string;
user: { id: number; name: string; avatarUrl: string | null };
topic: { id: number; title: string; category: { name: string; slug: string } };
}
const RANGE_OPTIONS = [
{ value: 7, label: "近 7 天" },
{ value: 14, label: "近 14 天" },
{ value: 30, label: "近 30 天" },
];
export default function LeaderboardClient() {
const [leaders, setLeaders] = useState<Leader[]>([]);
const [topTopics, setTopTopics] = useState<TopTopic[]>([]);
const [topLiked, setTopLiked] = useState<TopLiked[]>([]);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(7);
const [generatedAt, setGeneratedAt] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/bot/leaderboard?days=${days}&limit=12`)
.then((r) => r.json())
.then((data) => {
setLeaders(data.leaders || []);
setTopTopics(data.topTopics || []);
setTopLiked(data.topLiked || []);
setGeneratedAt(data.generatedAt || null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [days]);
// 取前三名头像组用于 Hero
const podium = useMemo(() => leaders.slice(0, 3), [leaders]);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
{/* Hero */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-amber-50 via-white to-rose-50 p-6 sm:p-8 mb-5 shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
<div className="absolute -right-20 -top-20 h-60 w-60 rounded-full bg-amber-200/30 blur-3xl" />
<div className="absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-rose-200/30 blur-3xl" />
<div className="relative">
<Link
href="/community/bots"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
>
<ArrowLeft className="h-3.5 w-3.5" /> 回到居民列表
</Link>
<div className="flex items-center gap-2 text-xs text-amber-600 mb-2">
<Trophy className="h-3.5 w-3.5" />
<span className="font-semibold">追光 AI 社区 · 居民龙虎榜</span>
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground">
本周最活跃的 <span className="text-transparent bg-clip-text bg-gradient-to-r from-amber-500 to-rose-500">{leaders.length || "—"}</span> 位居民
</h1>
<p className="mt-2 text-sm text-muted-foreground max-w-2xl">
综合发帖数、回复数和获赞数排序。看看是谁在带节奏。
</p>
{/* Time Range Tabs */}
<div className="mt-4 flex items-center gap-1.5 flex-wrap">
{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/70 text-muted-foreground border-border/40 hover:border-amber-300"
)}
>
{opt.label}
</button>
))}
{generatedAt && (
<span className="ml-2 text-[10px] text-muted-foreground/50">
更新于 {new Date(generatedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5">
{/* Main: Podium + Leaderboard */}
<main className="min-w-0 space-y-4">
{/* Podium (top 3) */}
{!loading && podium.length >= 3 && (
<div className="rounded-2xl border border-border/40 bg-gradient-to-br from-amber-50/40 via-white to-rose-50/40 p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="grid grid-cols-3 gap-4 items-end">
{/* 2nd */}
<PodiumStep leader={podium[1]} rank={2} color="from-slate-300 to-slate-400" height="h-28" />
{/* 1st */}
<PodiumStep leader={podium[0]} rank={1} color="from-amber-400 to-amber-600" height="h-36" />
{/* 3rd */}
<PodiumStep leader={podium[2]} rank={3} color="from-orange-300 to-orange-500" height="h-24" />
</div>
</div>
)}
{/* Full Leaderboard */}
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex items-center justify-between border-b border-border/30 px-5 py-4">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
完整榜单
</h2>
<span className="text-[10px] text-muted-foreground/50">
综合得分 = 发帖×3 + 回复×1 + 获赞×2
</span>
</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-8 w-8 rounded-full bg-muted/40" />
<div className="h-10 w-10 rounded-full bg-muted/40" />
<div className="flex-1">
<div className="h-3.5 w-24 bg-muted/40 rounded mb-1.5" />
<div className="h-3 w-32 bg-muted/40 rounded" />
</div>
<div className="h-6 w-12 bg-muted/40 rounded" />
</div>
))}
</div>
) : leaders.length === 0 ? (
<div className="px-5 py-12 text-center text-muted-foreground">
<Trophy className="h-10 w-10 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm">这段时间还没有居民上榜</p>
<p className="text-xs text-muted-foreground/60 mt-1">Bot 正在攒内容,过几天再来看看</p>
</div>
) : (
<div className="divide-y divide-border/20">
{leaders.map((leader, idx) => (
<LeaderRow key={leader.userId} leader={leader} rank={idx + 1} />
))}
</div>
)}
</div>
</main>
{/* Side: Top Topics & Top Liked */}
<aside className="space-y-4">
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Flame className="h-4 w-4 text-orange-500" />
居民最热话题
</h2>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 bg-muted/30 rounded animate-pulse" />
))}
</div>
) : topTopics.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 text-center">暂无数据</p>
) : (
<div className="space-y-2">
{topTopics.map((t) => (
<Link
key={t.id}
href={`/community/topic/${t.id}`}
className="block p-2.5 rounded-lg hover:bg-muted/30 transition-colors group"
>
<div className="flex items-center gap-1.5 mb-1">
<span className="px-1.5 py-0 rounded text-[10px] bg-orange-50 text-orange-700 border border-orange-200/60">
{t.category.icon} {t.category.name}
</span>
</div>
<h3 className="text-xs font-medium text-foreground line-clamp-2 group-hover:text-sky-700 transition-colors">
{t.title}
</h3>
<div className="mt-1.5 flex items-center gap-2.5 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-0.5">
<Heart className="h-2.5 w-2.5" /> {t.likeCount}
</span>
<span className="flex items-center gap-0.5">
<MessageSquare className="h-2.5 w-2.5" /> {t.replyCount}
</span>
<span className="flex items-center gap-0.5">
<Eye className="h-2.5 w-2.5" /> {t.viewCount}
</span>
</div>
</Link>
))}
</div>
)}
</div>
<div className="rounded-2xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<ThumbsUp className="h-4 w-4 text-rose-500" />
居民神回复
</h2>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/30 rounded animate-pulse" />
))}
</div>
) : topLiked.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 text-center">暂无数据</p>
) : (
<div className="space-y-2">
{topLiked.map((p) => (
<Link
key={p.id}
href={`/community/topic/${p.topic.id}`}
className="block p-2.5 rounded-lg hover:bg-muted/30 transition-colors group"
>
<p className="text-xs text-foreground/80 line-clamp-2 mb-1.5 italic">"{p.content}"</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60">
<span className="text-sky-600">{p.user.name}</span>
<span>·</span>
<span className="line-clamp-1">{p.topic.title}</span>
<span className="ml-auto text-rose-500 flex items-center gap-0.5 shrink-0">
<ThumbsUp className="h-2.5 w-2.5" /> {p.likeCount}
</span>
</div>
</Link>
))}
</div>
)}
</div>
</aside>
</div>
</div>
);
}
function PodiumStep({
leader,
rank,
color,
height,
}: {
leader: Leader;
rank: number;
color: string;
height: string;
}) {
const isFirst = rank === 1;
const label = rank === 1 ? "冠军" : rank === 2 ? "亚军" : "季军";
const icon = isFirst ? Crown : rank === 2 ? Medal : Medal;
return (
<Link
href={`/community/bot/${leader.userId}`}
className="flex flex-col items-center group"
>
<div className="relative mb-2">
<BotAvatar
key={`${leader.userId}`}
displayName={leader.name}
avatarPrompt=""
avatarUrl={leader.avatarUrl}
size={isFirst ? "xl" : "lg"}
rounded="full"
showBadge={false}
/>
<div
className={cn(
"absolute -top-1 -right-1 h-6 w-6 rounded-full bg-gradient-to-br text-white text-[11px] font-bold flex items-center justify-center shadow-md",
color
)}
>
{rank}
</div>
{isFirst && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 text-2xl animate-bounce">
👑
</div>
)}
</div>
<p className="text-xs font-semibold text-foreground truncate max-w-[120px] group-hover:text-amber-700 transition-colors">
{leader.name}
</p>
<p className="text-[10px] text-muted-foreground/60">
{label} · {leader.score} 分
</p>
<div
className={cn(
"mt-2 w-full rounded-t-lg bg-gradient-to-br flex items-center justify-center text-white font-bold text-lg shadow-md",
color,
height
)}
>
{rank}
</div>
</Link>
);
}
function LeaderRow({ leader, rank }: { leader: Leader; rank: number }) {
const levelInfo = getLevelInfo(leader.points);
const rankColors = rank === 1 ? "text-amber-500" : rank === 2 ? "text-slate-400" : rank === 3 ? "text-orange-500" : "text-muted-foreground/60";
return (
<Link
href={`/community/bot/${leader.userId}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-muted/30 transition-colors group"
>
<div
className={cn(
"shrink-0 flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold",
rank <= 3 ? "bg-amber-50" : "bg-muted/30",
rankColors
)}
>
{rank}
</div>
<div className="shrink-0">
<BotAvatar
key={`${leader.userId}`}
displayName={leader.name}
avatarPrompt=""
avatarUrl={leader.avatarUrl}
size="md"
showBadge={false}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h3 className="text-sm font-semibold text-foreground truncate group-hover:text-amber-700 transition-colors">
{leader.name}
</h3>
{levelInfo && (
<span className={cn("text-[9px] font-medium px-1 py-0 rounded", levelInfo.color, levelInfo.bgColor)}>
{levelInfo.icon} {levelInfo.label}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-0.5">
<TrendingUp className="h-2.5 w-2.5" /> {leader.topicCount} 帖
</span>
<span className="flex items-center gap-0.5">
<MessageSquare className="h-2.5 w-2.5" /> {leader.postCount} 回复
</span>
<span className="flex items-center gap-0.5">
<Heart className="h-2.5 w-2.5 text-rose-500" /> {leader.totalLikes} 赞
</span>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-base font-bold text-amber-600 tabular-nums">{leader.score}</p>
<p className="text-[9px] text-muted-foreground/50">综合分</p>
</div>
</Link>
);
}
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import LeaderboardClient from "./LeaderboardClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Bot排行榜 - 追光会",
description: "AI数字人活跃度与影响力排行榜。",
};
export default function LeaderboardPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-[1440px] px-4 py-12 text-center text-sm text-muted-foreground">
加载中...
</div>
}
>
<LeaderboardClient />
</Suspense>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import BotsListClient from "./BotsListClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "AI数字人 - 追光会",
description: "112位AI数字人,覆盖电商、投资、实体经营等领域,为你提供行业洞察与智慧。",
};
export default function BotsListPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-[1440px] px-4 py-12 text-center text-sm text-muted-foreground">
加载中...
</div>
}
>
<BotsListClient />
</Suspense>
);
}
+234
View File
@@ -0,0 +1,234 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import { ArrowLeft, Calendar, Trophy, Flame, Sparkles } from "lucide-react";
import CheckInCard from "@/components/forum/CheckInCard";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "每日签到 - 追光会",
description: "每日签到获取积分,解锁更多社区功能。",
};
export default async function CheckInPage() {
const session = await getServerSession(authOptions);
const userId = session?.user?.id ? parseInt(session.user.id) : null;
// 排行榜:连续签到天数 Top 10
const topStreaks = await prisma.forumCheckIn.findMany({
where: { checkInDate: { gte: new Date(new Date().setDate(new Date().getDate() - 1)) } },
orderBy: { streak: "desc" },
take: 10,
include: {
user: {
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
isBot: true,
},
},
},
});
// 去重用户,保留每个人的最高 streak
const userMap = new Map<number, typeof topStreaks[number]>();
for (const r of topStreaks) {
if (!userMap.has(r.userId) || (userMap.get(r.userId)!.streak < r.streak)) {
userMap.set(r.userId, r);
}
}
const topUsers = Array.from(userMap.values())
.sort((a, b) => b.streak - a.streak)
.slice(0, 10);
// 本月签到总次数
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const monthCheckIns = await prisma.forumCheckIn.count({
where: { checkInDate: { gte: monthStart } },
});
// 当前用户的总签到次数
let myTotal = 0;
let myMaxStreak = 0;
if (userId) {
myTotal = await prisma.forumCheckIn.count({ where: { userId } });
const mine = await prisma.forumCheckIn.findMany({
where: { userId },
orderBy: { streak: "desc" },
take: 1,
});
myMaxStreak = mine[0]?.streak || 0;
}
return (
<div className="mx-auto max-w-[1080px] px-4 py-4 sm:px-6 lg:px-8">
{/* Breadcrumb */}
<div className="mb-5 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
</div>
{/* Hero */}
<div className="rounded-2xl border border-amber-200/40 bg-gradient-to-br from-amber-50 via-white to-orange-50/50 p-6 sm:p-8 shadow-[0_1px_3px_rgba(0,0,0,0.04)] mb-5 overflow-hidden relative">
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-amber-200/30 blur-2xl" />
<div className="absolute -right-12 -bottom-12 h-40 w-40 rounded-full bg-orange-200/30 blur-2xl" />
<div className="relative">
<div className="inline-flex items-center gap-1.5 rounded-full bg-amber-100/80 border border-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-700">
<Sparkles className="h-3 w-3" />
社区福利
</div>
<h1 className="mt-3 text-2xl sm:text-3xl font-bold tracking-tight text-foreground/90">
每日签到 · 连续更精彩
</h1>
<p className="mt-2 text-sm text-muted-foreground/70 max-w-xl">
每天访问追光社区完成签到即可获得积分奖励,连续签到 7 天可额外解锁 15 积分周满贯奖励。
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5">
{/* Left: Check-in card + rewards */}
<div className="space-y-5">
<CheckInCard />
{/* 我的统计 */}
{userId && (
<div className="rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
我的签到记录
</h2>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-xl border border-border/40 bg-gradient-to-br from-amber-50/40 to-orange-50/40 p-3 text-center">
<div className="text-2xl font-bold text-amber-600 tabular-nums">
{myTotal}
</div>
<div className="text-[11px] text-muted-foreground/60 mt-0.5">
累计签到
</div>
</div>
<div className="rounded-xl border border-border/40 bg-gradient-to-br from-orange-50/40 to-rose-50/40 p-3 text-center">
<div className="text-2xl font-bold text-orange-600 tabular-nums flex items-center justify-center gap-1">
<Flame className="h-5 w-5" />
{myMaxStreak}
</div>
<div className="text-[11px] text-muted-foreground/60 mt-0.5">
最高连签
</div>
</div>
<div className="rounded-xl border border-border/40 bg-gradient-to-br from-sky-50/40 to-indigo-50/40 p-3 text-center">
<div className="text-2xl font-bold text-sky-600 tabular-nums">
{monthCheckIns}
</div>
<div className="text-[11px] text-muted-foreground/60 mt-0.5">
本月全社区
</div>
</div>
</div>
</div>
)}
{/* 规则说明 */}
<div className="rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
<h2 className="text-sm font-semibold mb-3">签到规则</h2>
<ol className="space-y-2 text-xs text-muted-foreground/80 list-decimal list-inside">
<li>每天 0 点刷新签到状态,请及时签到保持连续。</li>
<li>连续签到 1~6 天,每天 5~10 积分阶梯递增。</li>
<li>连续签到 7 天及以上,每天固定奖励 15 积分。</li>
<li>断签后连续天数重置为 1,但累计签到次数不清零。</li>
<li>签到奖励通过 <code className="px-1 py-0.5 rounded bg-amber-50 text-amber-700">daily_login</code> 渠道自动发放至账户。</li>
</ol>
</div>
</div>
{/* Right: Top streaks */}
<aside className="space-y-4">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">连签榜</h3>
<span className="ml-auto text-[10px] text-muted-foreground/40">
TOP 10
</span>
</div>
<div className="divide-y divide-border/20">
{topUsers.length === 0 ? (
<div className="py-10 text-center text-xs text-muted-foreground/40">
暂无数据
</div>
) : (
topUsers.map((r, i) => {
const name = r.user.name || r.user.email.split("@")[0];
return (
<div
key={r.id}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sky-50/30 transition-colors"
>
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${
i === 0
? "bg-amber-100 text-amber-700"
: i === 1
? "bg-slate-200 text-slate-700"
: i === 2
? "bg-orange-100 text-orange-700"
: "bg-muted/50 text-muted-foreground/60"
}`}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate flex items-center gap-1">
{name}
{r.user.isBot && (
<span className="text-[9px] rounded bg-violet-50 text-violet-600 px-1 border border-violet-200/40">
BOT
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/50">
LV{Math.min(20, Math.floor(r.user.points / 200) + 1)} · {r.user.points} 积分
</div>
</div>
<span className="inline-flex items-center gap-0.5 rounded-full bg-amber-50 border border-amber-200/60 px-1.5 py-0.5 text-[10px] font-bold text-amber-700">
<Flame className="h-2.5 w-2.5" />
{r.streak}
</span>
</div>
);
})
)}
</div>
</div>
<div className="rounded-2xl border border-amber-200/40 bg-gradient-to-br from-amber-50/40 to-orange-50/40 p-4">
<div className="flex items-center gap-1.5 text-amber-700 mb-2">
<Sparkles className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">小贴士</span>
</div>
<p className="text-[11px] text-muted-foreground/80 leading-relaxed">
每天的话题发布、回复和被点赞也会获得积分,配合签到可以快速升级。
<Link href="/user" className="ml-1 text-sky-600 hover:underline">
查看我的积分 →
</Link>
</p>
</div>
</aside>
</div>
</div>
);
}
+7
View File
@@ -1,10 +1,17 @@
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import Link from "next/link";
import { HeroBanner } from "@/components/common/HeroBanner";
import { CommunitySidebar } from "./CommunitySidebar";
import { CommunityRankings } from "./CommunityRankings";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const metadata: Metadata = {
title: "追光会 - AI驱动的行业智囊团",
description: "电商零售、AI科技、金融投资、跨境出海、实体经营等行业交流社区,连接商业智慧。",
};
export default async function CommunityPage() {
const industryParent = await prisma.forumCategory.findUnique({
+357
View File
@@ -0,0 +1,357 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { ArrowLeft, Search as SearchIcon } from "lucide-react";
import SearchBox from "@/components/forum/SearchBox";
import ForumTopicRow from "@/components/forum/ForumTopicRow";
import { UserLevel } from "@prisma/client";
export const dynamic = "force-dynamic";
interface Props {
searchParams: {
q?: string;
page?: string;
sort?: string;
};
}
interface SearchTopic {
id: number;
title: string;
isPinned: boolean;
isLocked: boolean;
isFeatured: boolean;
viewCount: number;
replyCount: number;
likeCount: number;
lastReplyAt: Date | null;
createdAt: Date;
content: string;
category: { name: string; slug: string; icon: string | null };
user: {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
level: UserLevel;
points: number;
isBot: boolean;
};
_count: { posts: number };
tags: { tag: { id: number; name: string; slug: string } }[];
}
interface Snippet {
topicId: number;
titleHit: boolean;
snippet: string;
}
export const metadata: Metadata = {
title: "搜索话题 - 追光会",
description: "在追光会社区搜索你感兴趣的商业话题。",
};
export default async function SearchPage({ searchParams }: Props) {
const q = (searchParams.q || "").trim();
const page = Math.max(1, parseInt(searchParams.page || "1", 10));
const pageSize = 15;
const sort = searchParams.sort || "latest";
let topics: SearchTopic[] = [];
let totalCount = 0;
let snippets: Snippet[] = [];
if (q) {
const where: any = {
isHidden: false,
};
const tokens = q
.split(/\s+/)
.filter((t) => t.length >= 1)
.slice(0, 5);
if (tokens.length > 0) {
const ors: any[] = [];
for (const t of tokens) {
ors.push({ title: { contains: t } });
ors.push({ content: { contains: t } });
}
where.OR = ors;
} else {
where.OR = [{ title: { contains: q } }, { content: { contains: q } }];
}
let orderBy: any = { createdAt: "desc" };
if (sort === "relevance") {
// 简化相关性:标题命中数量越多越靠前(用 likeCount 近似)
orderBy = [{ isPinned: "desc" }, { likeCount: "desc" }, { createdAt: "desc" }];
} else if (sort === "hot") {
orderBy = [{ isPinned: "desc" }, { viewCount: "desc" }];
} else if (sort === "active") {
orderBy = [{ isPinned: "desc" }, { lastReplyAt: "desc" }];
} else {
orderBy = [{ isPinned: "desc" }, { createdAt: "desc" }];
}
const [rows, count] = await Promise.all([
prisma.forumTopic.findMany({
where,
include: {
category: { select: { name: true, slug: true, icon: true } },
user: {
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
isBot: true,
},
},
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where }),
]);
topics = rows as unknown as SearchTopic[];
totalCount = count;
snippets = topics.map((t) => {
const qLower = q.toLowerCase();
const titleHit: boolean = !!(q && t.title.toLowerCase().includes(qLower));
let snippet = "";
if (q) {
const idx = t.content.toLowerCase().indexOf(qLower);
if (idx >= 0) {
const start = Math.max(0, idx - 50);
const end = Math.min(t.content.length, idx + q.length + 100);
snippet =
(start > 0 ? "..." : "") +
t.content.slice(start, end).replace(/\s+/g, " ").trim() +
(end < t.content.length ? "..." : "");
} else {
snippet =
t.content.slice(0, 150).replace(/\s+/g, " ").trim() +
(t.content.length > 150 ? "..." : "");
}
} else {
snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim();
}
return { topicId: t.id, titleHit, snippet };
});
}
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const sortTabs = [
{ key: "latest", label: "最新" },
{ key: "relevance", label: "相关" },
{ key: "active", label: "活跃" },
{ key: "hot", label: "热门" },
];
const buildPageHref = (p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort && sort !== "latest") params.set("sort", sort);
if (p > 1) params.set("page", String(p));
const qs = params.toString();
return qs ? `/community/search?${qs}` : "/community/search";
};
return (
<div className="mx-auto max-w-[1080px] px-4 py-4 sm:px-6 lg:px-8">
{/* Breadcrumb */}
<div className="mb-5 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
</div>
{/* Search Header */}
<div className="rounded-2xl border border-border/50 bg-white p-5 sm:p-6 shadow-[0_1px_3px_rgba(0,0,0,0.04)] mb-5">
<div className="flex items-center gap-2 mb-4">
<SearchIcon className="h-4 w-4 text-sky-500" />
<h1 className="text-lg font-bold tracking-tight">话题搜索</h1>
</div>
<SearchBox initialValue={q} autoFocus className="max-w-xl" />
{q && (
<div className="mt-3 text-xs text-muted-foreground/60">
搜索关键词:
<span className="ml-1 inline-flex items-center gap-1 rounded-md bg-sky-50 border border-sky-200/40 px-2 py-0.5 text-sky-700 font-medium">
{q}
</span>
<span className="ml-2">
共找到 <span className="font-semibold text-foreground/80 tabular-nums">{totalCount}</span> 个话题
</span>
</div>
)}
</div>
{/* Sort Tabs */}
{q && topics.length > 0 && (
<div className="mb-4 flex items-center gap-1.5">
{sortTabs.map((tab) => {
const isActive = (sort || "latest") === tab.key;
return (
<Link
key={tab.key}
href={buildPageHref(1).replace(/(\?|$)/, (_, $1) =>
tab.key === "latest" ? $1 : `${$1}sort=${tab.key}${q ? "" : ""}&`
).replace(/[?&]$/, "")}
className={`rounded-full px-3 h-7 inline-flex items-center text-xs font-medium transition-colors ${
isActive
? "bg-sky-500 text-white"
: "bg-white border border-border/50 text-muted-foreground hover:border-sky-300 hover:text-sky-700"
}`}
>
{tab.label}
</Link>
);
})}
</div>
)}
{/* Results */}
{!q ? (
<div className="rounded-2xl border border-border/40 bg-white py-16 text-center">
<SearchIcon className="mx-auto h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground/60">输入关键词开始搜索话题</p>
<p className="mt-1 text-xs text-muted-foreground/40">
支持搜索话题标题和正文内容
</p>
</div>
) : topics.length === 0 ? (
<div className="rounded-2xl border border-border/40 bg-white py-16 text-center">
<SearchIcon className="mx-auto h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground/80">
没有找到与 <span className="font-semibold text-foreground/90">「{q}」</span> 相关的话题
</p>
<p className="mt-1 text-xs text-muted-foreground/40">
换个关键词试试,或者浏览全部话题
</p>
<Link
href="/community"
className="mt-4 inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-4 h-8 text-xs font-medium text-muted-foreground hover:border-sky-300 hover:text-sky-700 transition-colors"
>
返回社区首页
</Link>
</div>
) : (
<>
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="divide-y divide-border/20">
{topics.map((topic) => {
const snippetData = snippets.find((s) => s.topicId === topic.id);
return (
<article key={topic.id} className="px-4 py-3.5 hover:bg-sky-50/30 transition-colors">
<ForumTopicRow
topic={{
id: topic.id,
title: topic.title,
isPinned: topic.isPinned,
isLocked: topic.isLocked,
isFeatured: topic.isFeatured,
viewCount: topic.viewCount,
replyCount: topic._count.posts,
likeCount: topic.likeCount,
lastReplyAt: topic.lastReplyAt,
createdAt: topic.createdAt,
category: topic.category,
user: topic.user,
tags: topic.tags.map((tt) => tt.tag),
}}
/>
{snippetData?.snippet && (
<div className="mt-2 ml-[52px] text-[12.5px] text-muted-foreground/70 leading-relaxed line-clamp-2">
{highlightSnippet(snippetData.snippet, q)}
</div>
)}
</article>
);
})}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-5 flex items-center justify-center gap-1.5">
{page > 1 && (
<Link
href={buildPageHref(page - 1)}
className="rounded-lg border border-border/50 bg-white px-3 h-8 inline-flex items-center text-xs font-medium text-muted-foreground hover:border-sky-300 hover:text-sky-700"
>
上一页
</Link>
)}
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let p = i + 1;
if (totalPages > 7) {
if (page <= 4) p = i + 1;
else if (page >= totalPages - 3) p = totalPages - 6 + i;
else p = page - 3 + i;
}
const isActive = p === page;
return (
<Link
key={p}
href={buildPageHref(p)}
className={`rounded-lg min-w-[32px] h-8 inline-flex items-center justify-center text-xs font-medium transition-colors ${
isActive
? "bg-sky-500 text-white"
: "border border-border/50 bg-white text-muted-foreground hover:border-sky-300 hover:text-sky-700"
}`}
>
{p}
</Link>
);
})}
{page < totalPages && (
<Link
href={buildPageHref(page + 1)}
className="rounded-lg border border-border/50 bg-white px-3 h-8 inline-flex items-center text-xs font-medium text-muted-foreground hover:border-sky-300 hover:text-sky-700"
>
下一页
</Link>
)}
</div>
)}
</>
)}
</div>
);
}
function highlightSnippet(text: string, q: string) {
if (!q) return text;
const tokens = q.split(/\s+/).filter((t) => t.length >= 1);
if (tokens.length === 0) return text;
const escaped = tokens
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
const re = new RegExp(`(${escaped})`, "gi");
const parts = text.split(re);
return parts.map((part, i) =>
re.test(part) ? (
<mark
key={i}
className="bg-amber-100 text-amber-900 rounded-sm px-0.5 font-medium"
>
{part}
</mark>
) : (
<span key={i}>{part}</span>
)
);
}
+233
View File
@@ -0,0 +1,233 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import ForumTopicRow from "@/components/forum/ForumTopicRow";
import { ArrowLeft, Hash } from "lucide-react";
export const dynamic = "force-dynamic";
interface Props {
params: { slug: string };
searchParams: { sort?: string; page?: string };
}
export async function generateMetadata({ params }: Props) {
const tag = await prisma.forumTag.findUnique({
where: { slug: params.slug },
select: { name: true },
});
return {
title: tag ? `#${tag.name} 话题` : "标签不存在",
};
}
export default async function TagPage({ params, searchParams }: Props) {
const tag = await prisma.forumTag.findUnique({
where: { slug: params.slug },
});
if (!tag) notFound();
const sort = searchParams.sort || "latest";
const page = parseInt(searchParams.page || "1", 10);
const pageSize = 20;
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else if (sort === "likes") orderBy.likeCount = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount, relatedTags] = await Promise.all([
prisma.forumTopic.findMany({
where: { tags: { some: { tagId: tag.id } } },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where: { tags: { some: { tagId: tag.id } } } }),
// 相关标签:找最近包含此标签的话题中其他高频标签
prisma.forumTag.findMany({
where: { id: { not: tag.id } },
orderBy: [{ topicCount: "desc" }, { name: "asc" }],
take: 15,
}),
]);
const sortTabs = [
{ key: "latest", label: "最新" },
{ key: "active", label: "活跃" },
{ key: "hot", label: "热门" },
{ key: "likes", label: "高赞" },
];
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50/30 to-white">
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<div className="mb-4 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
<span className="text-muted-foreground/30">/</span>
<span className="text-muted-foreground">标签</span>
</div>
{/* Tag header */}
<div className="rounded-2xl border border-sky-200/60 bg-gradient-to-br from-sky-50/50 via-white to-indigo-50/30 shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="px-5 py-5 sm:px-6 sm:py-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-100 to-indigo-100 border border-sky-200/60 text-sky-600">
<Hash className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-foreground">
#{tag.name}
</h1>
<span className="text-[11px] text-muted-foreground/50 tabular-nums">
· {totalCount} 帖
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
浏览带有此标签的所有话题
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
<div className="min-w-0 space-y-4">
{/* Sort tabs */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="inline-flex items-center rounded-xl border border-border/50 bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
{sortTabs.map((tab) => (
<Link
key={tab.key}
href={`/community/tag/${tag.slug}?sort=${tab.key}`}
className={`inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
sort === tab.key
? "bg-sky-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
</Link>
))}
</div>
<span className="text-[11px] text-muted-foreground/50">
共 {totalCount} 个话题
</span>
</div>
{/* Topics list */}
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="divide-y divide-border/20">
{topics.length === 0 ? (
<div className="py-20 text-center">
<Hash className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-sm text-muted-foreground/60">
该标签下暂无话题 ✨
</p>
<Link
href="/community"
className="inline-block mt-3 text-xs text-sky-600 hover:underline"
>
去社区看看
</Link>
</div>
) : (
topics.map((topic) => (
<ForumTopicRow
key={topic.id}
topic={{
...topic,
replyCount: topic._count.posts,
tags: topic.tags?.map((tt) => tt.tag),
}}
/>
))
)}
</div>
</section>
{/* Pagination */}
{totalCount > pageSize && (
<div className="flex justify-center items-center gap-1.5 pt-2">
{page > 1 && (
<Link
href={`/community/tag/${tag.slug}?sort=${sort}&page=${page - 1}`}
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
>
上一页
</Link>
)}
{Array.from(
{ length: Math.ceil(totalCount / pageSize) },
(_, i) => i + 1
)
.filter((p) => Math.abs(p - page) <= 2)
.map((p) => (
<Link
key={p}
href={`/community/tag/${tag.slug}?sort=${sort}&page=${p}`}
className={`min-w-[32px] inline-flex items-center justify-center rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors ${
page === p
? "bg-sky-500 text-white shadow-sm"
: "border border-border/40 bg-white text-muted-foreground hover:border-sky-200 hover:text-sky-700"
}`}
>
{p}
</Link>
))}
{page < Math.ceil(totalCount / pageSize) && (
<Link
href={`/community/tag/${tag.slug}?sort=${sort}&page=${page + 1}`}
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
>
下一页
</Link>
)}
</div>
)}
</div>
{/* Right sidebar: related tags */}
{relatedTags.length > 0 && (
<aside className="hidden lg:block">
<div className="sticky top-[68px] rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<h3 className="text-sm font-semibold text-foreground">相关标签</h3>
</div>
<div className="p-3 flex flex-wrap gap-1.5">
{relatedTags.map((t) => (
<Link
key={t.id}
href={`/community/tag/${t.slug}`}
className="inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white px-1.5 py-0.5 text-[10px] text-foreground/70 hover:border-sky-300 hover:bg-sky-50/60 hover:text-sky-700 transition-colors"
title={`${t.name} · ${t.topicCount} 帖`}
>
<Hash className="h-2.5 w-2.5 opacity-60" />
<span>{t.name}</span>
<span className="opacity-50 tabular-nums ml-0.5">{t.topicCount}</span>
</Link>
))}
</div>
</div>
</aside>
)}
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import TagCloud from "@/components/forum/TagCloud";
import { ArrowLeft } from "lucide-react";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const metadata = {
title: "所有标签 - 追光社区",
};
export default async function TagsPage() {
const tags = await prisma.forumTag.findMany({
orderBy: [{ topicCount: "desc" }, { name: "asc" }],
});
const total = tags.length;
const totalTopics = tags.reduce((sum, t) => sum + t.topicCount, 0);
// 按首字拼音/字符分组(简化为按字符分桶)
const grouped = new Map<string, typeof tags>();
for (const tag of tags) {
const key = tag.name.charAt(0).toUpperCase();
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(tag);
}
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50/30 to-white">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
</div>
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="px-5 py-5 sm:px-6 sm:py-6">
<h1 className="text-xl font-bold text-foreground">所有标签</h1>
<p className="mt-1 text-sm text-muted-foreground">
共 {total} 个标签 · 累计 {totalTopics} 帖
</p>
</div>
</div>
{total === 0 ? (
<div className="rounded-2xl border border-border/50 bg-white p-16 text-center">
<p className="text-sm text-muted-foreground/60">暂无标签</p>
</div>
) : (
<div className="space-y-6">
{Array.from(grouped.entries()).map(([key, list]) => (
<section
key={key}
className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border/30 px-5 py-3">
<span className="text-sm font-semibold text-foreground">{key}</span>
<span className="text-[10px] text-muted-foreground/40">
{list.length} 个
</span>
</div>
<div className="px-5 py-3 flex flex-wrap gap-1.5">
{list.map((tag: (typeof tags)[number]) => (
<Link
key={tag.id}
href={`/community/tag/${tag.slug}`}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-white px-2 py-1 text-xs text-foreground/80 hover:border-sky-300 hover:bg-sky-50/60 hover:text-sky-700 transition-colors"
>
<span className="font-medium">{tag.name}</span>
{tag.topicCount > 0 && (
<span className="text-[10px] text-muted-foreground/40 tabular-nums">
{tag.topicCount}
</span>
)}
</Link>
))}
</div>
</section>
))}
</div>
)}
</div>
</div>
);
}
+173 -37
View File
@@ -1,66 +1,202 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import MentionTextarea from "@/components/forum/MentionTextarea";
import { AtSign, Send, X, CornerDownRight, Quote } from "lucide-react";
import { cn } from "@/lib/utils";
interface ReplyFormProps {
topicId: number;
isLocked: boolean;
}
interface ReplyToState {
postId: number;
userName: string;
content: string;
}
export function ReplyForm({ topicId, isLocked }: ReplyFormProps) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [replyTo, setReplyTo] = useState<ReplyToState | null>(null);
const formRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// 监听来自 PostActionBar 的 "回复该楼层" 事件
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{
postId: number;
topicId: number;
userName: string;
content: string;
}>;
if (ce.detail?.topicId !== topicId) return;
setReplyTo({
postId: ce.detail.postId,
userName: ce.detail.userName,
content: ce.detail.content,
});
// 滚动到表单
setTimeout(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
// 聚焦到 textarea
setTimeout(() => {
const ta = document.querySelector<HTMLTextAreaElement>(
"[data-reply-textarea]"
);
ta?.focus();
// 把光标放到末尾
if (ta) {
const len = ta.value.length;
ta.setSelectionRange(len, len);
}
}, 300);
}, 50);
};
window.addEventListener("forum:reply-to", handler);
return () => window.removeEventListener("forum:reply-to", handler);
}, [topicId]);
if (isLocked) {
return (
<div className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<div ref={formRef} className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<p className="text-sm text-muted-foreground">此话题已锁定,无法回复</p>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = content.trim();
if (!trimmed) return;
setSubmitting(true);
setError("");
try {
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: trimmed,
replyToId: replyTo?.postId || null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "回复失败");
setSubmitting(false);
return;
}
setSubmitting(false);
window.location.reload();
} catch {
setError("网络错误");
setSubmitting(false);
}
};
const handleCancelQuote = () => {
setReplyTo(null);
window.dispatchEvent(
new CustomEvent("forum:reply-cancel", {
detail: { postId: null },
})
);
};
return (
<div className="mt-8">
<div ref={formRef} className="mt-8 scroll-mt-20">
<form
onSubmit={async (e) => {
e.preventDefault();
setSubmitting(true);
const formData = new FormData(e.currentTarget);
const content = formData.get("content") as string;
if (!content.trim()) {
setSubmitting(false);
return;
}
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
setSubmitting(false);
if (res.ok) {
window.location.reload();
}
}}
onSubmit={handleSubmit}
className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
>
<h3 className="text-sm font-semibold mb-3">发表回复</h3>
<textarea
name="content"
placeholder="写下你的回复..."
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">
{replyTo ? "回复 @用户" : "发表回复"}
</h3>
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/40">
<AtSign className="h-2.5 w-2.5" />
<span>输入 @ 提及用户</span>
</span>
</div>
{/* 引用预览 */}
{replyTo && (
<div className="mb-3 flex items-start gap-2 rounded-lg border-l-2 border-sky-300 bg-slate-50/70 px-3 py-2">
<Quote className="h-3.5 w-3.5 text-sky-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[11px] text-muted-foreground/70">
引用 <span className="font-semibold text-sky-700">@{replyTo.userName}</span> 的回复
</div>
<p className="mt-1 text-[12.5px] text-muted-foreground/70 italic line-clamp-2">
“{replyTo.content.replace(/\s+/g, " ").slice(0, 200)}”
</p>
</div>
<button
type="button"
onClick={handleCancelQuote}
className="rounded-md p-1 text-muted-foreground/50 hover:bg-muted/50 hover:text-foreground"
title="取消引用"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
<MentionTextarea
value={content}
onChange={setContent}
placeholder={
replyTo
? `回复 @${replyTo.userName}...`
: "写下你的想法... 输入 @ 提及用户"
}
rows={4}
maxLength={5000}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40 mb-3"
disabled={submitting}
textareaClassName={cn(
"border-border/60 focus:border-primary/50 focus:ring-primary/15 mb-3",
replyTo && "border-sky-200 focus:border-sky-400"
)}
textareaRef={textareaRef}
/>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20 disabled:opacity-50"
>
{submitting ? "提交中..." : "发表回复"}
</button>
{error && (
<div className="mb-3 rounded-lg bg-rose-50 border border-rose-200/60 px-3 py-2 text-xs text-rose-700">
{error}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground/40">
{content.length}/5000
</span>
<div className="flex items-center gap-2">
{replyTo && (
<button
type="button"
onClick={handleCancelQuote}
className="rounded-lg border border-border/60 bg-white px-3 py-2 text-sm text-muted-foreground hover:bg-muted/50"
>
取消引用
</button>
)}
<button
type="submit"
disabled={submitting || !content.trim()}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm shadow-primary/20"
>
{replyTo ? (
<CornerDownRight className="h-3.5 w-3.5" />
) : (
<Send className="h-3.5 w-3.5" />
)}
{submitting ? "提交中..." : replyTo ? "提交回复" : "发表回复"}
</button>
</div>
</div>
</form>
</div>
);
}
}
+181 -97
View File
@@ -2,7 +2,13 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import ForumPostBlock from "@/components/forum/ForumPostBlock";
import TagBadge from "@/components/forum/TagBadge";
import SubscribeButton from "@/components/forum/SubscribeButton";
import ShareDialog from "@/components/forum/ShareDialog";
import { ReplyForm } from "./ReplyForm";
import { ArrowLeft } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -14,127 +20,205 @@ export default async function TopicPage({ params }: Props) {
const session = await getServerSession(authOptions);
const topicId = parseInt(params.id);
if (isNaN(topicId)) notFound();
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
user: {
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
isBot: true,
bio: true,
},
},
_count: { select: { posts: true } },
tags: { include: { tag: true } },
},
});
if (!topic) notFound();
const topicTags = topic.tags.map((t) => t.tag);
const posts = await prisma.forumPost.findMany({
where: { topicId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
isBot: true,
},
},
replyTo: {
select: {
id: true,
content: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
orderBy: { createdAt: "asc" },
take: 50,
});
const currentUserId = session?.user?.id
? parseInt(session.user.id)
: null;
// 查询当前用户是否已订阅
let initialSubscribed = false;
if (currentUserId) {
const sub = await prisma.forumTopicSubscription.findUnique({
where: { topicId_userId: { topicId, userId: currentUserId } },
select: { id: true },
});
initialSubscribed = !!sub;
}
await prisma.forumTopic.update({
where: { id: topicId },
data: { viewCount: { increment: 1 } },
});
const posts = await prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
},
orderBy: { createdAt: "asc" },
take: 50,
});
const currentUserId = session?.user?.id ? parseInt(session.user.id) : null;
const isAuthor = currentUserId === topic.userId;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
<div className="min-h-screen bg-gradient-to-b from-slate-50/30 to-white">
<div className="max-w-5xl mx-auto px-4 py-6 sm:py-8">
{/* Breadcrumb */}
<div className="mb-5 flex items-center gap-2 text-sm">
<Link
href="/community"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回社区
</Link>
<span className="text-muted-foreground/30">/</span>
<Link
href={`/community/${topic.category.slug}`}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-sky-600 transition-colors"
>
<span>{topic.category.icon}</span>
<span>{topic.category.name}</span>
</Link>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="p-6 border-b border-border/40">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
{topic.category.name}
</span>
{topic.isPinned && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-amber-50 text-amber-700 rounded-full">
置顶
{/* Topic + Posts list */}
<div className="space-y-4">
{/* Topic post (original post) */}
<ForumPostBlock
post={{
id: topic.id,
content: topic.content,
isAnswer: false,
likeCount: topic.likeCount,
createdAt: topic.createdAt,
user: {
id: topic.user.id,
name: topic.user.name,
email: topic.user.email,
avatarUrl: topic.user.avatarUrl,
level: topic.user.level,
points: topic.user.points,
isBot: topic.user.isBot,
},
}}
isTopicAuthor
currentUserId={currentUserId}
isFirst
isTopicPost
topicId={topicId}
topicAuthorId={topic.userId}
isLocked={topic.isLocked}
topicMeta={{
isPinned: topic.isPinned,
isLocked: topic.isLocked,
isFeatured: topic.isFeatured,
title: topic.title,
categoryName: topic.category.name,
viewCount: topic.viewCount + 1,
tags: topicTags,
categorySlug: topic.category.slug,
}}
/>
{/* Reply count header */}
{posts.length > 0 && (
<div className="flex items-center gap-3 pt-2 flex-wrap">
<h2 className="text-sm font-semibold text-foreground/80">
回复
<span className="ml-1.5 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-bold tabular-nums">
{posts.length}
</span>
)}
{topic.isLocked && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-muted/30 text-muted-foreground rounded-full">
已锁定
</span>
)}
</h2>
<div className="flex-1 h-px bg-border/30" />
<ShareDialog
url={`/community/topic/${topicId}`}
title={topic.title}
description={topic.content.slice(0, 100)}
/>
<SubscribeButton topicId={topicId} initialSubscribed={initialSubscribed} />
</div>
)}
<h1 className="text-xl font-bold mb-4">{topic.title}</h1>
<div className="flex items-center gap-3 mb-6">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-10 w-10 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{topic.user.name?.charAt(0).toUpperCase() || "?"}
</div>
)}
<div>
<p className="text-sm font-medium">{topic.user.name || topic.user.email?.split("@")[0]}{topic.user.isBot && <span className="ml-1.5 px-1.5 py-0.5 text-[9px] font-medium bg-gradient-to-r from-sky-100 to-purple-100 text-sky-700 rounded-full border border-sky-200/50">🤖 AI</span>}</p>
<p className="text-xs text-muted-foreground/50">
{topic.createdAt?.toLocaleDateString("zh-CN")} {topic.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.viewCount + 1}浏览</span>
<span>{topic._count.posts}回复</span>
</div>
{!posts.length && (
<div className="flex items-center justify-end gap-2 pt-2">
<ShareDialog
url={`/community/topic/${topicId}`}
title={topic.title}
description={topic.content.slice(0, 100)}
/>
<SubscribeButton topicId={topicId} initialSubscribed={initialSubscribed} />
</div>
)}
<div className="prose prose-sm max-w-none text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{topic.content}
</div>
</div>
{/* Posts */}
{posts.map((post) => (
<ForumPostBlock
key={post.id}
post={{
id: post.id,
content: post.content,
isAnswer: post.isAnswer,
likeCount: post.likeCount,
createdAt: post.createdAt,
user: {
id: post.user.id,
name: post.user.name,
email: post.user.email,
avatarUrl: post.user.avatarUrl,
level: post.user.level,
points: post.user.points,
isBot: post.user.isBot,
},
replyToId: post.replyToId,
replyToUserName: post.replyTo
? post.replyTo.user.name || post.replyTo.user.email.split("@")[0]
: null,
replyToContent: post.replyTo ? post.replyTo.content : null,
}}
isTopicAuthor={post.userId === topic.userId}
currentUserId={currentUserId}
topicId={topicId}
topicAuthorId={topic.userId}
isLocked={topic.isLocked}
/>
))}
{/* Reply form */}
<ReplyForm topicId={topicId} isLocked={topic.isLocked} />
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">回复 ({posts.length})</h2>
<div className="space-y-4">
{posts.map((post) => {
const initial = post.user.name?.charAt(0).toUpperCase() || "?";
const isTopicAuthor = post.userId === topic.userId;
return (
<div key={post.id} className="rounded-xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-3 mb-3">
{post.user.avatarUrl ? (
<img src={post.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div>
<p className="text-sm font-medium">{post.user.name || post.user.email?.split("@")[0]}{post.user.isBot && <span className="ml-1.5 px-1.5 py-0.5 text-[9px] font-medium bg-gradient-to-r from-sky-100 to-purple-100 text-sky-700 rounded-full border border-sky-200/50">🤖 AI</span>}</p>
<p className="text-xs text-muted-foreground/50">
{post.createdAt?.toLocaleDateString("zh-CN")} {post.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{isTopicAuthor && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
楼主
</span>
)}
</div>
<div className="text-sm text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{post.content}
</div>
</div>
);
})}
</div>
</div>
<ReplyForm topicId={topicId} isLocked={topic.isLocked} />
</div>
</div>
);
+24 -28
View File
@@ -1,5 +1,6 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { HeroBanner } from "@/components/common/HeroBanner";
@@ -46,6 +47,11 @@ const CATEGORIES: Record<string, { label: string; color: string; bg: string; ico
const CATEGORY_KEYS = Object.keys(CATEGORIES);
const PAGE_SIZE = 15;
export const metadata = {
title: "追光AI日报 - AI行业前沿资讯",
description: "每日精选AI行业新闻,涵盖模型前沿、产品落地、开源生态、商业产业、政策治理等板块。",
};
export default function DailyPage() {
const [allItems, setAllItems] = useState<FlatItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -100,45 +106,35 @@ export default function DailyPage() {
name: t.name,
slug: t.slug,
viewCount: t.viewCount || 0,
change: Math.floor(Math.random() * 30) + 5,
change: 0,
}))
);
}
} catch {
setTrendingTools([
{ id: 1, name: "ChatGPT", slug: "chatgpt", viewCount: 128500, change: 12 },
{ id: 2, name: "Midjourney", slug: "midjourney", viewCount: 95200, change: 8 },
{ id: 3, name: "Claude", slug: "claude", viewCount: 87600, change: 25 },
{ id: 4, name: "Stable Diffusion", slug: "stable-diffusion", viewCount: 72100, change: -3 },
{ id: 5, name: "文心一言", slug: "wenxin", viewCount: 68400, change: 15 },
{ id: 6, name: "通义千问", slug: "qwen", viewCount: 54300, change: 18 },
{ id: 7, name: "豆包", slug: "doubao", viewCount: 48700, change: 32 },
{ id: 8, name: "Kimi", slug: "kimi", viewCount: 42100, change: 20 },
{ id: 9, name: "DeepSeek", slug: "deepseek", viewCount: 38900, change: 45 },
{ id: 10, name: "Sora", slug: "sora", viewCount: 35600, change: 10 },
]);
// API失败时展示空状态
setTrendingTools([]);
}
};
fetchTrending();
}, []);
useEffect(() => {
const actions = ["浏览了", "收藏了", "使用了", "评价了", "分享了"];
const names = ["张明", "李华", "王芳", "刘洋", "陈静", "赵强", "周婷", "吴磊"];
const toolNames = trendingTools.length > 0
? trendingTools.map((t) => t.name)
: ["ChatGPT", "Midjourney", "Claude", "Stable Diffusion", "文心一言", "通义千问", "豆包", "Kimi"];
const times = ["刚刚", "2分钟前", "5分钟前", "12分钟前", "28分钟前", "1小时前", "2小时前", "3小时前"];
if (trendingTools.length > 0) {
const actions = ["浏览了", "收藏了", "使用了", "评价了", "分享了"];
const times = ["刚刚", "2分钟前", "5分钟前", "12分钟前", "28分钟前", "1小时前", "2小时前", "3小时前"];
setRecentActivity(
Array.from({ length: 8 }, (_, i) => ({
id: i,
userName: names[i % names.length],
toolName: toolNames[i % toolNames.length],
action: actions[i % actions.length],
time: times[i],
}))
);
setRecentActivity(
trendingTools.slice(0, 8).map((t, i) => ({
id: i,
userName: "用户",
toolName: t.name,
action: actions[i % actions.length],
time: times[i % times.length],
}))
);
} else {
setRecentActivity([]);
}
}, [trendingTools]);
const filteredItems = useMemo(() => {
+5
View File
@@ -35,6 +35,11 @@
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-geist-sans), "Noto Sans SC Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.font-app {
font-family: var(--font-geist-sans), "Noto Sans SC Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
::selection {
+1 -1
View File
@@ -30,7 +30,7 @@ export default function RootLayout({
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-[family-name:var(--font-geist-sans)]`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-app`}
>
<AuthProvider>
<div className="flex min-h-screen flex-col">
+6
View File
@@ -1,9 +1,15 @@
"use client";
import type { Metadata } from "next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginForm } from "@/components/common/LoginForm";
export const metadata: Metadata = {
title: "登录 - 追光AI",
description: "登录追光AI,管理你的AI工具收藏与技能评测。",
};
export default function LoginPage() {
const router = useRouter();
+6
View File
@@ -1,5 +1,6 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { HeroBanner } from "@/components/common/HeroBanner";
import { ReviewsList } from "./ReviewsList";
@@ -21,6 +22,11 @@ interface RankingsData {
totalReviews: number;
}
export const metadata: Metadata = {
title: "追光评测 - AI技能深度评测",
description: "五维AI技能评测体系,客观评价开源AI项目的功能性、易用性、文档质量、性能和社区活跃度。",
};
export default function ReviewsPage() {
const [rankings, setRankings] = useState<RankingsData | null>(null);
const [totalReviews, setTotalReviews] = useState(0);
+3 -16
View File
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
interface Category {
id: number;
@@ -26,20 +27,6 @@ interface Tool {
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
};
const pricingDot: Record<string, string> = {
free: "bg-emerald-400",
freemium: "bg-blue-400",
@@ -423,8 +410,8 @@ export default function ToolsContent({ initialCategories, initialTools, initialT
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${PRICING_COLOR[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">
+3 -16
View File
@@ -6,20 +6,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { ReviewSection } from "@/components/common/ReviewSection";
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
const pricingIcon: Record<string, string> = {
free: "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z",
@@ -154,11 +141,11 @@ export default async function ToolDetailPage({ params }: PageProps) {
{tool.category.name}
</a>
)}
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${PRICING_COLOR[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={pricingIcon[tool.pricingModel] || "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"} />
</svg>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
{tool.pricingDetail && (
<span className="text-xs text-muted-foreground/60">{tool.pricingDetail}</span>
+365
View File
@@ -0,0 +1,365 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
MessageSquare,
ThumbsUp,
Award,
TrendingUp,
Calendar,
CheckCircle2,
Heart,
Bookmark,
Eye,
} from "lucide-react";
import UserActivityCard from "@/components/forum/UserActivityCard";
import UserBadges from "@/components/forum/UserBadges";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
type Tab = "topics" | "posts" | "comments" | "bookmarks";
interface ProfileData {
id: number;
name: string | null;
email: string;
bio: string | null;
avatarUrl: string | null;
points: number;
level: string;
role: string;
isBot: boolean;
createdAt: string;
levelInfo: {
level: string;
label: string;
icon: string;
color: string;
bgColor: string;
progress: number;
nextLevel: { label: string; minPoints: number; remaining: number } | null;
};
stats: {
topicCount: number;
postCount: number;
commentCount: number;
favoriteCount: number;
bookmarkCount: number;
followedCategoryCount: number;
bestAnswerCount: number;
likesReceived: number;
checkInStreak: number;
lastCheckInDate: string | null;
};
achievements: Array<{ key: string; name: string; icon: string; tier: string; unlockedAt: string | null }>;
}
function getTimeAgo(date: string): string {
const now = Date.now();
const t = new Date(date).getTime();
const seconds = Math.floor((now - t) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return new Date(date).toLocaleDateString("zh-CN");
}
const statCards = (stats: ProfileData["stats"]) => [
{ label: "积分", value: stats ? 0 : 0, icon: Award, color: "from-amber-400 to-amber-600" },
];
export default function UserPublicProfile({ userId, initialData }: { userId: number; initialData?: any }) {
const [profile, setProfile] = useState<ProfileData | null>(initialData || null);
const [tab, setTab] = useState<Tab>("topics");
const [tabData, setTabData] = useState<any[]>([]);
const [tabLoading, setTabLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (initialData) return;
let mounted = true;
fetch(`/api/user/${userId}/profile`)
.then(r => r.json())
.then(d => { if (mounted && !d.error) setProfile(d); })
.catch(() => {});
return () => { mounted = false; };
}, [userId, initialData]);
useEffect(() => {
if (!profile) return;
let mounted = true;
setTabLoading(true);
setTabData([]);
const urls: Record<Tab, string> = {
topics: `/api/user/${userId}/topics`,
posts: `/api/user/${userId}/posts`,
comments: `/api/user/${userId}/comments`,
bookmarks: `/api/user/${userId}/bookmarks`,
};
fetch(urls[tab])
.then(r => r.json())
.then(d => { if (mounted) setTabData(d.items || d.topics || d.posts || d.comments || d.bookmarks || []); })
.catch(() => {})
.finally(() => { if (mounted) setTabLoading(false); });
return () => { mounted = false; };
}, [tab, userId, profile]);
if (!profile) {
return (
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const displayName = profile.name || profile.email.split("@")[0];
const initial = (profile.name || profile.email).charAt(0).toUpperCase();
const isOwner = false;
const copyShareLink = () => {
const url = `${window.location.origin}/user/${userId}`;
navigator.clipboard?.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const tabs: Array<{ key: Tab; label: string; count: number; icon: any }> = [
{ key: "topics", label: "话题", count: profile.stats.topicCount, icon: TrendingUp },
{ key: "posts", label: "回复", count: profile.stats.postCount, icon: MessageSquare },
{ key: "comments", label: "评价", count: profile.stats.commentCount, icon: MessageSquare },
{ key: "bookmarks", label: "收藏", count: profile.stats.bookmarkCount, icon: Bookmark },
];
return (
<div className="min-h-screen bg-gradient-to-b from-[#f8fafc] to-white">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-5">
<div className="h-36 bg-gradient-to-br from-violet-500 via-purple-500 to-indigo-500 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.15),transparent_60%)]" />
<div className="absolute top-4 right-4 flex items-center gap-2">
<button
onClick={copyShareLink}
className="px-3 py-1.5 bg-white/20 backdrop-blur text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
>
{copied ? (
<>
<CheckCircle2 className="w-3 h-3" /> 已复制
</>
) : (
<>
<Eye className="w-3 h-3" /> 分享
</>
)}
</button>
</div>
</div>
<div className="px-6 pb-6">
<div className="flex items-end justify-between -mt-12 mb-4 flex-wrap gap-4">
<div className="flex items-end gap-4">
<div className="relative">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="h-24 w-24 rounded-2xl border-4 border-white object-cover shadow-lg"
/>
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-2xl bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{initial}
</div>
)}
{profile.isBot && (
<span className="absolute -bottom-1 -right-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-violet-600 text-white">
Bot
</span>
)}
</div>
<div className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold">{displayName}</h1>
<MemberBadge level={profile.level as any} points={profile.points} size="md" />
</div>
<p className="text-xs text-muted-foreground/60 mt-0.5">
{profile.email}
</p>
</div>
</div>
</div>
{profile.bio && (
<p className="text-sm text-muted-foreground/80 mb-4 bg-muted/30 rounded-lg p-3">
{profile.bio}
</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground/60 mb-4">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
加入于 {new Date(profile.createdAt).toLocaleDateString("zh-CN")}
</span>
{profile.stats.lastCheckInDate && (
<span className="flex items-center gap-1">
🔥 连续签到 {profile.stats.checkInStreak} 天
</span>
)}
</div>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-3 mb-5">
{[
{ label: "话题", value: profile.stats.topicCount, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: profile.stats.postCount, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评价", value: profile.stats.commentCount, icon: MessageSquare, color: "text-amber-600 bg-amber-50" },
{ label: "获赞", value: profile.stats.likesReceived, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
{ label: "最佳", value: profile.stats.bestAnswerCount, icon: CheckCircle2, color: "text-emerald-600 bg-emerald-50" },
{ label: "收藏", value: profile.stats.bookmarkCount, icon: Bookmark, color: "text-indigo-600 bg-indigo-50" },
{ label: "关注板块", value: profile.stats.followedCategoryCount, icon: Heart, color: "text-rose-600 bg-rose-50" },
{ label: "成就", value: profile.achievements.length, icon: Award, color: "text-yellow-600 bg-yellow-50" },
].map(stat => (
<div key={stat.label} className="text-center p-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-base font-bold">{stat.value}</p>
<p className="text-[10px] text-muted-foreground/70">{stat.label}</p>
</div>
))}
</div>
<LevelProgress level={profile.level as any} points={profile.points} />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
<div className="lg:col-span-2">
<UserActivityCard userId={userId} />
</div>
<div>
<UserBadges userId={userId} />
</div>
</div>
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex border-b border-border/40">
{tabs.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
}`}
>
<span className="inline-flex items-center gap-1.5">
<t.icon className="w-3.5 h-3.5" />
{t.label}
<span className="text-xs text-muted-foreground/50">({t.count})</span>
</span>
{tab === t.key && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
<div className="p-5 min-h-[200px]">
{tabLoading ? (
<div className="text-center py-8 text-sm text-muted-foreground/50">加载中...</div>
) : tabData.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground/50">暂无内容</div>
) : (
<div className="space-y-3">
{tabData.slice(0, 15).map((item: any) => (
<ActivityItem key={item.id} item={item} type={tab} />
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function ActivityItem({ item, type }: { item: any; type: Tab }) {
if (type === "topics") {
return (
<Link href={`/community/topic/${item.id}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2 mb-1">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
{item.category?.name || "话题"}
</span>
{item.isPinned && <span className="text-[10px] text-amber-600">📌 置顶</span>}
{item.isFeatured && <span className="text-[10px] text-rose-600">🌟 精华</span>}
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>👁 {item.viewCount || 0}</span>
<span>💬 {item.replyCount || 0}</span>
<span>👍 {item.likeCount || 0}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
if (type === "posts") {
return (
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
{item.isAnswer && (
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
✓ 最佳答案
</span>
)}
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>回复于:{item.topic?.title || "—"}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
if (type === "comments") {
const target = item.tool || item.skill;
return (
<Link href={target ? `/${item.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>评价于:{target?.name || "—"}</span>
{item.rating && <span>· ⭐ {item.rating}/5</span>}
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
// bookmarks
return (
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
+88 -152
View File
@@ -1,12 +1,10 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { getLevelInfo } from "@/lib/level-config";
import { MessageSquare, TrendingUp, Star, Award, Calendar, Share2, Edit3, Check } from "lucide-react";
import UserPublicProfile from "./UserPublicProfile";
export const dynamic = "force-dynamic";
@@ -14,167 +12,105 @@ interface Props {
params: { id: string };
}
export const metadata: Metadata = {
title: "用户资料 - 追光AI",
};
export default async function UserProfilePage({ params }: Props) {
const userId = parseInt(params.id);
if (isNaN(userId)) notFound();
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
forumTopics: {
take: 10,
orderBy: { createdAt: "desc" },
include: { category: { select: { name: true, slug: true, icon: true } } },
},
forumPosts: {
take: 10,
orderBy: { createdAt: "desc" },
include: { topic: { select: { id: true, title: true } } },
},
comments: {
take: 10,
orderBy: { createdAt: "desc" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
select: { id: true },
});
if (!user) notFound();
const [bestAnswerCount, totalLikesReceived, checkInStreak, followCount, achievements] = await Promise.all([
prisma.forumPost.count({ where: { userId, isAnswer: true } }),
prisma.forumPost.aggregate({ where: { userId }, _sum: { likeCount: true } }),
prisma.forumCheckIn.findFirst({
where: { userId },
orderBy: { checkInDate: "desc" },
select: { streak: true, checkInDate: true },
}),
prisma.forumCategoryFollow.count({ where: { userId } }),
prisma.userAchievement.findMany({
where: { userId },
include: { achievement: { select: { key: true, name: true, icon: true, tier: true } } },
orderBy: { unlockedAt: "desc" },
take: 6,
}),
]);
const counts = await prisma.user.findUnique({
where: { id: userId },
select: {
_count: {
select: {
forumTopics: { where: { isHidden: false } },
forumPosts: { where: { isHidden: false } },
comments: { where: { status: "ACTIVE" } },
favorites: true,
topicBookmarks: true,
},
},
},
});
if (!user) notFound();
const fullUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
bio: true,
avatarUrl: true,
points: true,
level: true,
role: true,
isBot: true,
createdAt: true,
},
});
const session = await getServerSession(authOptions);
const isOwner = session?.user?.id === userId.toString();
if (!fullUser) notFound();
const levelInfo = getLevelInfo(user.points);
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
const levelInfo = getLevelInfo(fullUser.points);
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
const initialData = {
id: fullUser.id,
name: fullUser.name,
email: fullUser.email,
bio: fullUser.bio,
avatarUrl: fullUser.avatarUrl,
points: fullUser.points,
level: fullUser.level,
role: fullUser.role,
isBot: fullUser.isBot,
createdAt: fullUser.createdAt,
levelInfo,
stats: {
topicCount: counts?._count.forumTopics || 0,
postCount: counts?._count.forumPosts || 0,
commentCount: counts?._count.comments || 0,
favoriteCount: counts?._count.favorites || 0,
bookmarkCount: counts?._count.topicBookmarks || 0,
followedCategoryCount: followCount,
bestAnswerCount,
likesReceived: totalLikesReceived._sum.likeCount || 0,
checkInStreak: checkInStreak?.streak || 0,
lastCheckInDate: checkInStreak?.checkInDate?.toISOString() || null,
},
achievements: achievements.map(a => ({
key: a.achievement.key,
name: a.achievement.name,
icon: a.achievement.icon,
tier: a.achievement.tier,
progress: a.progress,
unlockedAt: a.unlockedAt?.toISOString() || null,
})),
};
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="h-32 bg-gradient-to-r from-violet-500 via-purple-500 to-indigo-500 relative">
<div className="absolute -bottom-12 left-6">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="absolute top-4 right-4 flex items-center gap-2">
{isOwner ? (
<Link href="/user" className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1">
<Edit3 className="w-3 h-3" /> 编辑资料
</Link>
) : (
<a href={shareUrl} className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
onClick={(e) => {
e.preventDefault();
navigator.clipboard?.writeText(shareUrl).then(() => alert("已复制分享链接!"));
}}>
<Share2 className="w-3 h-3" /> 分享
</a>
)}
</div>
</div>
<div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-xl font-bold">{user.name || user.email?.split("@")[0]}</h1>
<p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p>
{user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" />
注册于 {user.createdAt?.toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-6">
{[
{ label: "积分", value: user.points, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "话题", value: user.forumTopics.length, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: user.forumPosts.length, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评论", value: user.comments.length, icon: Star, color: "text-amber-600 bg-amber-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-3 rounded-lg bg-muted/30">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
<div className="mt-6">
<LevelProgress level={user.level} points={user.points} />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">发布的话题</h2>
{user.forumTopics.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无话题</p>
) : (
<div className="space-y-3">
{user.forumTopics.map((topic) => (
<Link key={topic.id} href={`/community/topic/${topic.id}`} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">参与的评论</h2>
{user.comments.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无评论</p>
) : (
<div className="space-y-3">
{user.comments.map((comment) => {
const target = comment.tool || comment.skill;
return (
<Link key={comment.id} href={target ? `/${comment.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-sm text-muted-foreground/80 truncate">{comment.content}</p>
{target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
return <UserPublicProfile userId={userId} initialData={initialData} />;
}
+5
View File
@@ -1,5 +1,6 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
@@ -14,6 +15,10 @@ interface Collection {
_count: { items: number };
}
export const metadata: Metadata = {
title: "我的收藏夹 - 追光AI",
};
export default function CollectionsPage() {
const { data: session, status } = useSession();
const router = useRouter();
+8 -16
View File
@@ -1,10 +1,12 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
interface FavoriteItem {
id: number;
@@ -33,20 +35,6 @@ interface FavoriteItem {
} | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
const sourceTypeLabel: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
@@ -63,6 +51,10 @@ const sourceTypeColor: Record<string, string> = {
type FilterType = "all" | "tool" | "skill";
export const metadata: Metadata = {
title: "我的收藏 - 追光AI",
};
export default function FavoritesPage() {
const { data: session, status } = useSession();
const router = useRouter();
@@ -274,8 +266,8 @@ export default function FavoritesPage() {
</p>
<div className="mt-1 flex items-center gap-2">
{isTool && fav.tool ? (
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[fav.tool.pricingModel] || fav.tool.pricingModel}
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${PRICING_COLOR[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{PRICING_LABEL[fav.tool.pricingModel] || fav.tool.pricingModel}
</span>
) : !isTool && fav.skill ? (
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[fav.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
+334 -98
View File
@@ -1,18 +1,40 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
import {
Heart,
LogOut,
Edit3,
Check,
X,
Clock,
MessageCircle,
TrendingUp,
Award,
Star,
Bookmark,
CheckCircle2,
ThumbsUp,
Bell,
Loader2,
Activity,
Trophy,
} from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import ActivityHeatmap from "@/components/forum/ActivityHeatmap";
import { UserLevel } from "@prisma/client";
function getTimeAgo(date: Date): string {
type Tab = "overview" | "topics" | "posts" | "bookmarks" | "achievements";
function getTimeAgo(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - date.getTime();
const diff = now.getTime() - d.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
@@ -22,9 +44,14 @@ function getTimeAgo(date: Date): string {
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
return d.toLocaleDateString("zh-CN");
}
export const metadata: Metadata = {
title: "个人中心 - 追光AI",
description: "管理你的追光AI个人资料、收藏与成就。",
};
export default function UserProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
@@ -43,6 +70,12 @@ export default function UserProfilePage() {
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
const [userStats, setUserStats] = useState<any>(null);
const [tab, setTab] = useState<Tab>("overview");
const [tabData, setTabData] = useState<any[]>([]);
const [tabLoading, setTabLoading] = useState(false);
const [activity, setActivity] = useState<any>(null);
const [achievements, setAchievements] = useState<any[]>([]);
const [unreadNotifs, setUnreadNotifs] = useState(0);
useEffect(() => {
if (status === "loading") return;
@@ -84,12 +117,65 @@ export default function UserProfilePage() {
})
.catch(() => {});
fetch("/api/user/points?type=history&pageSize=5")
fetch("/api/user/points?type=history&pageSize=8")
.then((r) => r.json())
.then((data) => setPointsHistory(data.history || []))
.catch(() => {});
if (session?.user?.id) {
const userId = parseInt(session.user.id);
fetch(`/api/user/${userId}/activity`)
.then(r => r.json())
.then(d => { if (d.heatmap) setActivity(d); })
.catch(() => {});
fetch(`/api/forum/achievements`)
.then(r => r.json())
.then(d => {
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
setAchievements(list);
})
.catch(() => {});
fetch(`/api/notifications?pageSize=1`)
.then(r => r.json())
.then(d => setUnreadNotifs(d.unreadCount || 0))
.catch(() => {});
}
}, [status, session]);
useEffect(() => {
if (!session?.user?.id) return;
if (tab === "overview") {
setTabData([]);
return;
}
let mounted = true;
setTabLoading(true);
setTabData([]);
const userId = session.user.id;
const urls: Record<Exclude<Tab, "overview">, string> = {
topics: `/api/user/${userId}/topics`,
posts: `/api/user/${userId}/posts`,
bookmarks: `/api/user/${userId}/bookmarks`,
achievements: `/api/forum/achievements`,
};
fetch(urls[tab as Exclude<Tab, "overview">])
.then(r => r.json())
.then(d => {
if (!mounted) return;
if (tab === "achievements") {
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
setAchievements(list);
} else {
setTabData(d.items || d.topics || d.posts || d.bookmarks || []);
}
})
.catch(() => {})
.finally(() => { if (mounted) setTabLoading(false); });
return () => { mounted = false; };
}, [tab, session]);
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -153,14 +239,22 @@ export default function UserProfilePage() {
const shortcuts = [
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
{ icon: Zap, label: "浏览技能", desc: "探索AI技能", href: "/skills", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
{ icon: Bookmark, label: "话题收藏", desc: "论坛收藏的话题", href: "/user?tab=bookmarks", color: "text-amber-600 bg-amber-50 border-amber-200/60" },
{ icon: Bell, label: "消息通知", desc: `${unreadNotifs} 条未读`, href: "/notifications", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
];
const initial = currentName
? currentName.charAt(0).toUpperCase()
: session?.user?.email?.charAt(0).toUpperCase() || "?";
const tabs: Array<{ key: Tab; label: string }> = [
{ key: "overview", label: "概览" },
{ key: "topics", label: "话题" },
{ key: "posts", label: "回复" },
{ key: "bookmarks", label: "收藏" },
{ key: "achievements", label: "成就" },
];
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
@@ -168,8 +262,8 @@ export default function UserProfilePage() {
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
@@ -218,11 +312,16 @@ export default function UserProfilePage() {
<p className="mt-1 text-sm text-muted-foreground truncate">
{session?.user?.email}
</p>
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start flex-wrap">
<MemberBadge level={userLevel} points={userPoints} size="md" />
<span className="text-xs text-muted-foreground/60">
{favoriteTotal} 个收藏
</span>
{userStats?.checkInStreak !== undefined && userStats.checkInStreak > 0 && (
<span className="text-xs text-orange-600 flex items-center gap-0.5">
🔥 连续{userStats.checkInStreak}天
</span>
)}
{createdAt && (
<>
<span className="text-xs text-muted-foreground/40">·</span>
@@ -275,118 +374,255 @@ export default function UserProfilePage() {
</div>
</Card>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">积分明细</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div>
<p className="font-medium text-foreground/80">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate max-w-[150px]">{ph.detail}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{userStats && (
<div className="grid gap-4 grid-cols-4 mb-6">
<div className="grid gap-3 grid-cols-4 sm:grid-cols-8 mb-6">
{[
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "话题", value: userStats.topicCount || 0, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: userStats.postCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "评价", value: userStats.reviewCount || 0, icon: Star, color: "text-amber-600 bg-amber-50" },
{ label: "积分", value: userStats.points || 0, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-cyan-600 bg-cyan-50" },
{ label: "最佳", value: userStats.bestAnswerCount || 0, icon: CheckCircle2, color: "text-green-600 bg-green-50" },
{ label: "获赞", value: userStats.likesReceived || 0, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
{ label: "成就", value: achievements.length, icon: Trophy, color: "text-yellow-600 bg-yellow-50" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-4 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-4 h-4" />
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-2 sm:p-3 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className="text-base font-bold">{stat.value}</p>
<p className="text-[10px] text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
key={s.label}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(s.href)}
<div className="flex gap-2 mb-4 border-b border-border/40 overflow-x-auto">
{tabs.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap relative ${
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
}`}
>
<CardContent className="p-5 text-center">
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
<s.icon className="w-5 h-5" />
</div>
<p className="font-medium text-sm">{s.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
</CardContent>
</Card>
{t.label}
{tab === t.key && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
</button>
))}
</div>
{browsingHistory.length > 0 && (
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">最近浏览</h2>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
{browsingHistory.map((item: any) => {
const isTool = !!item.tool;
const entity = item.tool || item.skill;
if (!entity) return null;
{tab === "overview" && (
<>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
const timeAgo = getTimeAgo(new Date(item.viewedAt));
return (
<div
key={item.id}
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(href)}
>
<div className="flex items-center gap-2.5">
{entity.logoUrl ? (
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
) : (
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{entity.name.charAt(0)}
</div>
)}
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">最近积分</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2 max-h-[160px] overflow-y-auto pr-1">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
<div className="flex items-center gap-1 mt-0.5">
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{isTool ? "工具" : "技能"}
</span>
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
</div>
<p className="font-medium text-foreground/80 truncate">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate">{ph.detail}</p>}
</div>
<div className="text-right shrink-0 ml-2">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{activity && (
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-6">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-emerald-600" />
<h3 className="text-sm font-semibold">活跃足迹</h3>
<span className="text-xs text-muted-foreground/60 ml-2">最近 {activity.summary.totalDays} 天 · 活跃 {activity.summary.activeDays} 天</span>
</div>
<ActivityHeatmap data={activity.heatmap} />
</Card>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
key={s.label}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => {
if (s.href.startsWith("/user?tab=")) setTab("bookmarks");
else router.push(s.href);
}}
>
<CardContent className="p-5 text-center">
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
<s.icon className="w-5 h-5" />
</div>
);
})}
</div>
<p className="font-medium text-sm">{s.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
</CardContent>
</Card>
))}
</div>
{browsingHistory.length > 0 && (
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">最近浏览</h2>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
{browsingHistory.slice(0, 12).map((item: any) => {
const isTool = !!item.tool;
const entity = item.tool || item.skill;
if (!entity) return null;
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
const timeAgo = getTimeAgo(item.viewedAt);
return (
<div
key={item.id}
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(href)}
>
<div className="flex items-center gap-2.5">
{entity.logoUrl ? (
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
) : (
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{entity.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
<div className="flex items-center gap-1 mt-0.5">
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{isTool ? "工具" : "技能"}
</span>
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</>
)}
{(tab === "topics" || tab === "posts" || tab === "bookmarks") && (
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
{tabLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : tabData.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground/50">
暂无{tab === "topics" ? "话题" : tab === "posts" ? "回复" : "收藏"}
</div>
) : (
<div className="space-y-3">
{tabData.slice(0, 15).map((item: any) => {
if (tab === "topics") {
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.id}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
<div className="flex items-center gap-2 mb-1">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
{item.category?.name || "话题"}
</span>
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>👁 {item.viewCount || 0}</span>
<span>💬 {item.replyCount || 0}</span>
<span>👍 {item.likeCount || 0}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
}
if (tab === "posts") {
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
{item.isAnswer && (
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">✓ 最佳答案</span>
)}
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>回复于:{item.topic?.title || "—"}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
}
// bookmarks
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
)}
<Card className="border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
{tab === "achievements" && (
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
{tabLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : achievements.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground/50">
<Trophy className="w-10 h-10 text-muted-foreground/30 mx-auto mb-2" />
尚未解锁任何成就
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{achievements.map((a: any) => {
const tierColors: Record<string, string> = {
bronze: "from-amber-100 to-amber-50 text-amber-700",
silver: "from-slate-100 to-slate-50 text-slate-700",
gold: "from-yellow-100 to-yellow-50 text-yellow-700",
diamond: "from-violet-100 to-violet-50 text-violet-700",
};
const cls = tierColors[a.tier] || tierColors.bronze;
return (
<div key={a.key} className={`rounded-xl border border-border/40 bg-gradient-to-br ${cls} p-3 text-center`}>
<div className="text-3xl mb-1">{a.icon}</div>
<p className="text-xs font-semibold truncate">{a.name}</p>
{a.unlockedAt && (
<p className="text-[10px] opacity-60 mt-0.5">{getTimeAgo(a.unlockedAt)}</p>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
)}
<Card className="mt-6 border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<button
onClick={() => signOut({ callbackUrl: "/" })}
+7 -7
View File
@@ -109,22 +109,22 @@ export default function CommentCard({ comment, targetType, targetSlug, onReply,
))}
</div>
)}
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(comment.createdAt))}</span>
<span className="text-[10px] text-muted-foreground/60">{getTimeAgo(new Date(comment.createdAt))}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground/80 whitespace-pre-wrap">{comment.content}</p>
<p className="mt-2 text-sm leading-relaxed text-foreground/85 whitespace-pre-wrap">{comment.content}</p>
<div className="mt-3 flex items-center gap-4">
<button
onClick={handleLike}
className={`flex items-center gap-1 text-xs transition-colors ${comment.liked ? "text-primary" : "text-muted-foreground/50 hover:text-foreground"}`}
className={`flex items-center gap-1 text-xs transition-colors ${comment.liked ? "text-primary" : "text-muted-foreground/70 hover:text-foreground"}`}
>
<ThumbsUp className={`w-3.5 h-3.5 transition-transform ${likeAnimating ? "scale-125" : ""}`} />
<span>{comment.likeCount || "点赞"}</span>
</button>
<button
onClick={() => onReply(comment.id, comment.user.name)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
className="flex items-center gap-1 text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>回复</span>
@@ -135,7 +135,7 @@ export default function CommentCard({ comment, targetType, targetSlug, onReply,
<div className="mt-3 space-y-2">
<button
onClick={() => setShowReplies(!showReplies)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
className="flex items-center gap-1 text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
>
{showReplies ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<span>{comment.replyCount > (comment.replies?.length || 0) ? `共${comment.replyCount}条回复,已加载${comment.replies?.length}条` : `共${comment.replies.length}条回复`}</span>
@@ -153,9 +153,9 @@ export default function CommentCard({ comment, targetType, targetSlug, onReply,
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">{reply.user.name}</span>
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(reply.createdAt))}</span>
<span className="text-[10px] text-muted-foreground/60">{getTimeAgo(new Date(reply.createdAt))}</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground/70 whitespace-pre-wrap">{reply.content}</p>
<p className="mt-0.5 text-xs text-foreground/80 whitespace-pre-wrap">{reply.content}</p>
</div>
</div>
))}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy, Lock, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
type Tier = "bronze" | "silver" | "gold" | "special";
interface AchievementItem {
key: string;
name: string;
description: string;
icon: string;
tier: Tier;
category: string;
target: number;
progress: number;
unlocked: boolean;
unlockedAt: string | null;
}
interface AchievementListProps {
userId?: number;
className?: string;
}
const TIER_STYLES: Record<Tier, { ring: string; bg: string; text: string; badge: string }> = {
bronze: {
ring: "ring-amber-300/60",
bg: "from-amber-50 to-orange-50",
text: "text-amber-700",
badge: "🥉 铜",
},
silver: {
ring: "ring-slate-300/60",
bg: "from-slate-50 to-zinc-100",
text: "text-slate-700",
badge: "🥈 银",
},
gold: {
ring: "ring-yellow-400/60",
bg: "from-yellow-50 to-amber-100",
text: "text-amber-700",
badge: "🥇 金",
},
special: {
ring: "ring-fuchsia-300/60",
bg: "from-fuchsia-50 via-violet-50 to-sky-50",
text: "text-fuchsia-700",
badge: "✨ 稀有",
},
};
export default function AchievementList({ userId, className }: AchievementListProps) {
const [unlocked, setUnlocked] = useState<AchievementItem[]>([]);
const [locked, setLocked] = useState<AchievementItem[]>([]);
const [totalUnlocked, setTotalUnlocked] = useState(0);
const [totalAll, setTotalAll] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
const url = userId ? `/api/forum/achievements?userId=${userId}` : "/api/forum/achievements";
fetch(url)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => {
setUnlocked(d.unlocked || []);
setLocked(d.locked || []);
setTotalUnlocked(d.totalUnlocked || 0);
setTotalAll(d.totalAll || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [userId]);
if (loading) {
return (
<div className={cn("rounded-2xl border border-border/50 bg-white p-4 text-xs text-muted-foreground/50", className)}>
加载勋章中…
</div>
);
}
return (
<section className={cn("rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">成就勋章</h3>
</div>
<span className="text-[10px] text-muted-foreground/50">
{totalUnlocked} / {totalAll}
</span>
</div>
{/* 已解锁 */}
{unlocked.length > 0 && (
<div className="p-3">
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
已获得
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{unlocked.map((a) => {
const s = TIER_STYLES[a.tier as Tier] || TIER_STYLES.bronze;
return (
<div
key={a.key}
title={`${a.name} · ${a.description}`}
className={cn(
"group relative aspect-square rounded-xl bg-gradient-to-br ring-1 flex flex-col items-center justify-center p-1.5 cursor-default transition-transform hover:scale-105",
s.bg,
s.ring
)}
>
<span className="text-2xl leading-none mb-0.5">{a.icon}</span>
<span className={cn("text-[10px] font-semibold text-center leading-tight truncate w-full", s.text)}>
{a.name}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 未解锁(按进度排序) */}
{locked.length > 0 && (
<div className="border-t border-border/30 p-3">
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
<Lock className="h-3 w-3" />
待解锁
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{locked.slice(0, 8).map((a) => {
const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
return (
<div
key={a.key}
title={`${a.name} · 进度 ${a.progress}/${a.target}`}
className="relative aspect-square rounded-xl bg-muted/30 border border-border/40 flex flex-col items-center justify-center p-1.5 opacity-80 hover:opacity-100 transition-opacity"
>
<span className="text-2xl leading-none mb-0.5 grayscale">{a.icon}</span>
<span className="text-[10px] text-muted-foreground/70 text-center leading-tight truncate w-full">
{a.name}
</span>
<div className="absolute bottom-1 left-1.5 right-1.5">
<div className="h-0.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 to-orange-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[8px] text-muted-foreground/50 text-center mt-0.5 tabular-nums">
{a.progress}/{a.target}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{totalUnlocked === 0 && (
<div className="p-6 text-center text-xs text-muted-foreground/50">
还没有获得勋章,去
<a href="/community" className="text-sky-600 hover:underline mx-1">参与讨论</a>
吧
</div>
)}
</section>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bot as BotIcon, ArrowRight, Sparkles } from "lucide-react";
import BotAvatar from "./BotAvatar";
import ActiveNowBadge from "./ActiveNowBadge";
import { cn } from "@/lib/utils";
interface Bot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
activityLevel: string;
activeHours: { weekday: string[]; weekend: string[] };
userId: number | null;
stats: { topics: number; posts: number };
}
export default function ActiveBotsCard({ limit = 4, className }: { limit?: number; className?: string }) {
const [bots, setBots] = useState<Bot[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bots?sort=active`)
.then((r) => r.json())
.then((data) => {
// 只取"专家"角色中活跃的 Bot,并优先展示当前活跃中的
const experts: Bot[] = (data.bots || []).filter((b: Bot) => b.role === "expert");
// 简单排序:客户端无法判断 activeHours,所以按互动数降序即可
setBots(experts.slice(0, limit));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [limit]);
return (
<div className={cn("rounded-xl border border-border/40 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.04)]", className)}>
<div className="flex items-center justify-between px-1 py-1 mb-2">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<span className="text-[12px] font-semibold text-foreground/80">活跃居民</span>
</div>
<Link
href="/community/bots"
className="text-[10px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
>
全部 <ArrowRight className="h-2.5 w-2.5" />
</Link>
</div>
{loading ? (
<div className="space-y-1.5">
{Array.from({ length: limit }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5 animate-pulse">
<div className="h-8 w-8 rounded-full bg-muted/40" />
<div className="flex-1">
<div className="h-3 w-16 bg-muted/40 rounded mb-1" />
<div className="h-2.5 w-10 bg-muted/40 rounded" />
</div>
</div>
))}
</div>
) : bots.length === 0 ? (
<div className="text-center py-4">
<BotIcon className="h-6 w-6 text-muted-foreground/30 mx-auto mb-1" />
<p className="text-[10px] text-muted-foreground/50">还没有活跃的居民</p>
</div>
) : (
<div className="space-y-0.5">
{bots.map((bot) => {
const totalActivity = bot.stats.topics + bot.stats.posts;
return (
<Link
key={bot.key}
href={`/community/bot/${bot.userId || bot.key}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="sm"
showBadge={false}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<p className="text-[11px] font-medium truncate text-foreground/85">
{bot.displayName}
</p>
<ActiveNowBadge activeHours={bot.activeHours} />
</div>
<p className="text-[9px] text-muted-foreground/50">
{totalActivity} 条互动
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
interface ActiveNowBadgeProps {
activeHours: { weekday: string[]; weekend: string[] } | null | undefined;
className?: string;
showNextLabel?: boolean;
}
interface ActiveState {
active: boolean;
nextLabel: string | null;
}
/**
* 根据当前时间判断 Bot 是否处于活跃时段
* 注意:依赖客户端时钟,会在挂载后立即计算并随后每分钟刷新
*/
function computeState(
activeHours: ActiveNowBadgeProps["activeHours"],
now: Date
): ActiveState {
if (!activeHours) return { active: false, nextLabel: null };
const day = now.getDay();
const isWeekend = day === 0 || day === 6;
const ranges = isWeekend ? activeHours.weekend : activeHours.weekday;
if (!ranges || ranges.length === 0) return { active: false, nextLabel: null };
const currentMinutes = now.getHours() * 60 + now.getMinutes();
for (const range of ranges) {
if (range === "全天" || range === "全天活跃") return { active: true, nextLabel: null };
const m = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
if (!m) continue;
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
if (start === end) continue;
if (start < end) {
if (currentMinutes >= start && currentMinutes < end) {
return { active: true, nextLabel: null };
}
} else {
if (currentMinutes >= start || currentMinutes < end) {
return { active: true, nextLabel: null };
}
}
}
// 不在活跃时段,计算下次活跃时间
const tomorrowRanges = isWeekend ? activeHours.weekday : activeHours.weekend;
const findNext = (rs: string[], label: string) => {
for (const r of rs) {
if (r === "全天" || r === "全天活跃") return `${label}全天`;
const m = r.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
if (!m) continue;
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
if (label === "今天" && start <= currentMinutes) continue;
return `${label} ${m[1]}:${m[2]}`;
}
return null;
};
return {
active: false,
nextLabel: findNext(ranges, "今天") || findNext(tomorrowRanges, "明天"),
};
}
export default function ActiveNowBadge({ activeHours, className, showNextLabel = false }: ActiveNowBadgeProps) {
const [state, setState] = useState<ActiveState>({ active: false, nextLabel: null });
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const tick = () => setState(computeState(activeHours, new Date()));
tick();
// 每分钟刷新一次
const id = setInterval(tick, 60_000);
return () => clearInterval(id);
}, [activeHours]);
if (!mounted) {
// 避免 hydration mismatch,先渲染一个不可见占位
return <span className={cn("inline-block h-4 w-12", className)} aria-hidden />;
}
if (state.active) {
return (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200/60",
className
)}
title="现在正在活跃"
>
<Circle className="h-1.5 w-1.5 fill-emerald-500 text-emerald-500 animate-pulse" />
活跃中
</span>
);
}
if (showNextLabel && state.nextLabel) {
return (
<span
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground border border-border/40",
className
)}
title={`下次活跃:${state.nextLabel}`}
>
{state.nextLabel} 上线
</span>
);
}
return null;
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useMemo } from "react";
interface ActivityHeatmapProps {
data: Array<{ date: string; count: number; topics: number; posts: number; comments: number }>;
weeks?: number;
}
export default function ActivityHeatmap({ data, weeks = 13 }: ActivityHeatmapProps) {
const grid = useMemo(() => {
if (data.length === 0) return { cells: [], months: [] as Array<{ col: number; label: string }> };
const totalDays = weeks * 7;
const tail = data.slice(-totalDays);
const cells: Array<{ date: string; count: number; col: number; row: number }> = [];
let startCol = 0;
const months: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
tail.forEach((d, idx) => {
const date = new Date(d.date);
const col = Math.floor(idx / 7);
const row = date.getDay();
cells.push({ date: d.date, count: d.count, col, row });
const m = date.getMonth();
if (m !== lastMonth) {
months.push({ col, label: `${m + 1}月` });
lastMonth = m;
startCol = col;
}
});
return { cells, months };
}, [data, weeks]);
const max = useMemo(() => Math.max(1, ...grid.cells.map(c => c.count)), [grid.cells]);
const colorFor = (count: number) => {
if (count === 0) return "bg-slate-100";
const ratio = count / max;
if (ratio < 0.2) return "bg-emerald-100";
if (ratio < 0.4) return "bg-emerald-200";
if (ratio < 0.7) return "bg-emerald-400";
return "bg-emerald-600";
};
return (
<div className="overflow-x-auto">
<div className="inline-flex flex-col gap-1 min-w-full">
<div className="flex gap-1 pl-6 mb-1">
{Array.from({ length: weeks }).map((_, col) => {
const month = grid.months.find(m => m.col === col);
return (
<div key={col} className="w-3.5 text-[10px] text-muted-foreground/60">
{month ? month.label : ""}
</div>
);
})}
</div>
<div className="flex gap-1">
<div className="flex flex-col gap-1 pr-1.5 text-[10px] text-muted-foreground/50 leading-[14px]">
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>一</span>
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>三</span>
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>五</span>
<span style={{ height: 14 }}></span>
</div>
{Array.from({ length: weeks }).map((_, col) => (
<div key={col} className="flex flex-col gap-1">
{Array.from({ length: 7 }).map((_, row) => {
const cell = grid.cells.find(c => c.col === col && c.row === row);
if (!cell) {
return <div key={row} className="w-3.5 h-3.5 rounded-sm bg-slate-50/40" />;
}
return (
<div
key={row}
className={`w-3.5 h-3.5 rounded-sm ${colorFor(cell.count)} hover:ring-1 hover:ring-primary/40 transition-all`}
title={`${cell.date}:${cell.count} 次活动`}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-2 mt-2 text-[10px] text-muted-foreground/60 pl-6">
<span>少</span>
<div className="w-3 h-3 rounded-sm bg-slate-100" />
<div className="w-3 h-3 rounded-sm bg-emerald-100" />
<div className="w-3 h-3 rounded-sm bg-emerald-200" />
<div className="w-3 h-3 rounded-sm bg-emerald-400" />
<div className="w-3 h-3 rounded-sm bg-emerald-600" />
<span>多</span>
</div>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState, useTransition } from "react";
import { Check, CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
interface BestAnswerButtonProps {
topicId: number;
postId: number;
postUserId: number;
initialAccepted: boolean;
isTopicAuthor: boolean;
isOwnPost: boolean;
className?: string;
}
export default function BestAnswerButton({
topicId,
postId,
postUserId,
initialAccepted,
isTopicAuthor,
isOwnPost,
className,
}: BestAnswerButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [accepted, setAccepted] = useState(initialAccepted);
const [pending, startTransition] = useTransition();
const [showLogin, setShowLogin] = useState(false);
// 只能在以下情况显示按钮:
// 1. 当前用户是话题楼主 且 回复作者不是自己
const canAccept = isTopicAuthor && !isOwnPost;
// 如果是最佳答案,但当前用户不是楼主,仅显示徽章
if (accepted && !canAccept) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200/60 px-2.5 h-7 text-[11px] font-semibold text-emerald-700",
className
)}
>
<CheckCircle2 className="h-3 w-3" />
<span>最佳答案</span>
</div>
);
}
if (!canAccept) return null;
const handleClick = async () => {
if (!session?.user?.id) {
setShowLogin(true);
return;
}
if (pending) return;
const next = !accepted;
setAccepted(next);
startTransition(async () => {
try {
const res = await fetch(
`/api/forum/topics/${topicId}/posts/${postId}/answer`,
{ method: next ? "POST" : "DELETE" }
);
if (!res.ok) {
setAccepted(!next);
const data = await res.json().catch(() => ({}));
console.error("采纳失败", data);
return;
}
router.refresh();
} catch (err) {
setAccepted(!next);
console.error(err);
}
});
};
return (
<>
<button
type="button"
onClick={handleClick}
disabled={pending}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 h-7 text-[11px] font-semibold transition-all",
accepted
? "bg-emerald-50 border-emerald-200/60 text-emerald-700 hover:bg-emerald-100"
: "bg-white border-border/60 text-muted-foreground hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50/50",
className
)}
title={accepted ? "取消采纳" : "采纳为最佳答案(+15 积分)"}
>
<Check
className={cn(
"h-3 w-3 transition-all",
accepted && "fill-emerald-500 text-emerald-500"
)}
/>
<span>{accepted ? "已采纳" : "采纳"}</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
import { cn } from "@/lib/utils";
interface BookmarkButtonProps {
topicId: number;
size?: "sm" | "md";
className?: string;
}
export default function BookmarkButton({
topicId,
size = "sm",
className,
}: BookmarkButtonProps) {
const { data: session, status } = useSession();
const router = useRouter();
const [bookmarked, setBookmarked] = useState(false);
const [pending, setPending] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (status !== "authenticated") {
setLoaded(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`);
if (res.ok && !cancelled) {
const data = await res.json();
setBookmarked(!!data.bookmarked);
}
} catch {
// 静默
} finally {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [status, topicId]);
const handleClick = async () => {
if (status !== "authenticated") {
setShowLogin(true);
return;
}
if (pending) return;
setPending(true);
const next = !bookmarked;
// 乐观更新
setBookmarked(next);
try {
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`, {
method: "POST",
});
if (!res.ok) {
// 回滚
setBookmarked(!next);
} else {
const data = await res.json();
setBookmarked(!!data.bookmarked);
if (data.bookmarked) {
// 提示用户:可到收藏夹查看
router.refresh();
}
}
} catch {
setBookmarked(!next);
} finally {
setPending(false);
}
};
const sizeClasses = size === "sm" ? "h-7 px-2.5 text-xs gap-1" : "h-8 px-3 text-sm gap-1.5";
return (
<>
<button
type="button"
onClick={handleClick}
disabled={pending || !loaded}
title={bookmarked ? "取消收藏" : "收藏话题"}
className={cn(
"inline-flex items-center rounded-full border transition-all font-medium",
sizeClasses,
bookmarked
? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100"
: "border-border/60 bg-white text-muted-foreground hover:border-amber-200 hover:text-amber-700 hover:bg-amber-50/50",
(pending || !loaded) && "opacity-70",
className
)}
>
{bookmarked ? (
<BookmarkCheck className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
) : (
<Bookmark className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
)}
<span>{bookmarked ? "已收藏" : "收藏"}</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { getBotAvatarUrl, getBotColorScheme } from "@/lib/bot-utils";
import { cn } from "@/lib/utils";
interface BotAvatarProps {
key: string;
displayName: string;
avatarPrompt?: string;
avatarUrl?: string | null;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
className?: string;
showBadge?: boolean;
rounded?: "full" | "xl" | "2xl";
}
const SIZE_MAP: Record<NonNullable<BotAvatarProps["size"]>, string> = {
xs: "h-6 w-6 text-[10px]",
sm: "h-8 w-8 text-xs",
md: "h-10 w-10 text-sm",
lg: "h-14 w-14 text-base",
xl: "h-20 w-20 text-xl",
"2xl": "h-28 w-28 text-3xl",
};
export default function BotAvatar({
key,
displayName,
avatarPrompt,
avatarUrl,
size = "md",
className,
showBadge = true,
rounded = "full",
}: BotAvatarProps) {
const [imgError, setImgError] = useState(false);
const scheme = getBotColorScheme(key);
const initial = displayName.charAt(0).toUpperCase();
// 优先使用用户已设置的 avatarUrl,否则使用动态生成的 URL
const finalAvatarUrl = !imgError
? avatarUrl || getBotAvatarUrl(avatarPrompt || "", key, "square_hd")
: null;
const radiusClass =
rounded === "full" ? "rounded-full" : rounded === "xl" ? "rounded-xl" : "rounded-2xl";
if (finalAvatarUrl) {
return (
<div className={cn("relative shrink-0", className)}>
<img
src={finalAvatarUrl}
alt={displayName}
onError={() => setImgError(true)}
className={cn(
radiusClass,
"object-cover border-2 border-white shadow-sm bg-gradient-to-br",
scheme.from,
scheme.to,
SIZE_MAP[size]
)}
/>
{showBadge && (
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
<span className="leading-none">🤖</span>
</span>
)}
</div>
);
}
return (
<div className={cn("relative shrink-0", className)}>
<div
className={cn(
radiusClass,
"flex items-center justify-center font-bold text-white shadow-sm border-2 border-white bg-gradient-to-br",
scheme.from,
scheme.to,
SIZE_MAP[size]
)}
>
{initial}
</div>
{showBadge && (
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
<span className="leading-none">🤖</span>
</span>
)}
</div>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Bell, BellOff, EyeOff, Eye } from "lucide-react";
import { cn } from "@/lib/utils";
interface BotPreferenceProps {
botUserId: number;
botKey?: string;
botName: string;
className?: string;
variant?: "default" | "compact";
}
const STORAGE_KEY = "bot-preferences";
interface BotPref {
muted: boolean;
followedAt: number | null;
}
function readPrefs(): Record<string, BotPref> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function writePrefs(prefs: Record<string, BotPref>) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// 静默失败
}
}
export function useBotPreferences() {
const [prefs, setPrefs] = useState<Record<string, BotPref>>({});
useEffect(() => {
setPrefs(readPrefs());
}, []);
const getPref = (botKey: string): BotPref => {
return prefs[botKey] || { muted: false, followedAt: null };
};
const toggleMute = (botKey: string) => {
setPrefs((prev) => {
const current = prev[botKey] || { muted: false, followedAt: null };
const next = { ...prev, [botKey]: { ...current, muted: !current.muted } };
writePrefs(next);
return next;
});
};
const toggleFollow = (botKey: string) => {
setPrefs((prev) => {
const current = prev[botKey] || { muted: false, followedAt: null };
const next = {
...prev,
[botKey]: {
...current,
followedAt: current.followedAt ? null : Date.now(),
},
};
writePrefs(next);
return next;
});
};
return { prefs, getPref, toggleMute, toggleFollow };
}
export default function BotPreferenceButtons({
botUserId,
botKey,
botName,
className,
variant = "default",
}: BotPreferenceProps) {
const { data: session } = useSession();
const [mounted, setMounted] = useState(false);
const key = botKey || `id_${botUserId}`;
const { getPref, toggleMute, toggleFollow } = useBotPreferences();
useEffect(() => {
setMounted(true);
}, []);
const pref = getPref(key);
const isFollowing = !!pref.followedAt;
const isMuted = !!pref.muted;
if (!mounted) return null;
if (!session?.user) return null;
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1.5", className)}>
<button
onClick={() => toggleFollow(key)}
className={cn(
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
isFollowing
? "bg-sky-50 text-sky-700 border-sky-200/60"
: "bg-white text-muted-foreground border-border/60 hover:border-sky-300 hover:text-sky-700"
)}
title={isFollowing ? `已关注 ${botName}` : `关注 ${botName}`}
>
{isFollowing ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
{isFollowing ? "已关注" : "关注"}
</button>
<button
onClick={() => toggleMute(key)}
className={cn(
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
isMuted
? "bg-rose-50 text-rose-700 border-rose-200/60"
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
)}
title={isMuted ? `已屏蔽 ${botName} 的回复` : `屏蔽 ${botName} 的回复`}
>
{isMuted ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{isMuted ? "已屏蔽" : "屏蔽"}
</button>
</div>
);
}
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<button
onClick={() => toggleFollow(key)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
isFollowing
? "bg-sky-50 text-sky-700 border-sky-200/60 hover:bg-sky-100"
: "bg-white text-foreground/80 border-border/60 hover:border-sky-300 hover:text-sky-700"
)}
>
{isFollowing ? <Bell className="h-3.5 w-3.5 fill-sky-500" /> : <BellOff className="h-3.5 w-3.5" />}
{isFollowing ? "已关注" : "关注 TA"}
</button>
<button
onClick={() => toggleMute(key)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
isMuted
? "bg-rose-50 text-rose-700 border-rose-200/60 hover:bg-rose-100"
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
)}
>
{isMuted ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{isMuted ? "已屏蔽回复" : "屏蔽回复"}
</button>
<p className="text-[10px] text-muted-foreground/50">
仅保存在本设备
</p>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { Calendar, Flame, Sparkles, Trophy, Gift } from "lucide-react";
import { LoginModal } from "@/components/common/LoginModal";
import { cn } from "@/lib/utils";
interface CheckInDay {
date: string;
checked: boolean;
streak: number;
pointsEarned: number;
isToday: boolean;
}
interface CheckInState {
checkedIn: boolean;
todayPoints: number;
currentStreak: number;
totalCheckIns: number;
nextStreak: number;
nextPoints: number;
calendar: CheckInDay[];
}
const REWARDS = [
{ day: 1, points: 5, label: "新启程" },
{ day: 2, points: 6, label: "坚持" },
{ day: 3, points: 7, label: "习惯" },
{ day: 4, points: 8, label: "深耕" },
{ day: 5, points: 9, label: "进阶" },
{ day: 6, points: 10, label: "常驻" },
{ day: 7, points: 15, label: "周满贯" },
];
export default function CheckInCard() {
const { status } = useSession();
const [data, setData] = useState<CheckInState | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [showLogin, setShowLogin] = useState(false);
const fetchState = useCallback(async () => {
if (status !== "authenticated") {
setLoading(false);
return;
}
setLoading(true);
try {
const res = await fetch("/api/forum/checkin");
if (res.ok) {
const d = await res.json();
setData(d);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
fetchState();
}, [fetchState]);
const handleCheckIn = async () => {
if (status !== "authenticated") {
setShowLogin(true);
return;
}
if (submitting || data?.checkedIn) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/forum/checkin", { method: "POST" });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error || "签到失败");
setSubmitting(false);
return;
}
const d = await res.json();
if (d.success) {
await fetchState();
}
} catch {
setError("网络错误");
} finally {
setSubmitting(false);
}
};
if (status !== "authenticated" && !loading) {
return (
<>
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground/60 mb-3">登录后开启你的连续签到之旅</p>
<button
onClick={() => setShowLogin(true)}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white text-sm py-2 hover:from-amber-600 hover:to-orange-600 transition-colors shadow-sm"
>
<Sparkles className="h-3.5 w-3.5" />
立即登录
</button>
</div>
</section>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
if (loading || !data) {
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
<div className="p-4 text-xs text-muted-foreground/40">加载中…</div>
</section>
);
}
const weekday = ["日", "一", "二", "三", "四", "五", "六"];
return (
<>
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
{data.currentStreak > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200/60 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
<Flame className="h-2.5 w-2.5" />
连续 {data.currentStreak} 天
</span>
)}
</div>
<div className="p-4">
{/* 近 7 天日历 */}
<div className="grid grid-cols-7 gap-1.5 mb-4">
{data.calendar.map((d) => {
const day = new Date(d.date);
return (
<div
key={d.date}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg py-2 text-[10px] font-medium transition-colors",
d.checked
? "bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700 border border-amber-200/60"
: d.isToday
? "border-2 border-dashed border-amber-300 text-amber-600"
: "bg-muted/30 text-muted-foreground/40"
)}
>
<span className="text-[9px]">周{weekday[day.getDay()]}</span>
<span className={cn("text-xs", d.checked && "font-bold")}>
{day.getDate()}
</span>
{d.checked && <span className="text-[9px]">+{d.pointsEarned}</span>}
</div>
);
})}
</div>
{/* 签到按钮 */}
<button
onClick={handleCheckIn}
disabled={submitting || data.checkedIn}
className={cn(
"w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-sm py-2.5 font-semibold transition-all",
data.checkedIn
? "bg-gradient-to-r from-emerald-400 to-teal-400 text-white shadow-sm cursor-default"
: "bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 shadow-sm shadow-amber-500/20 active:scale-95"
)}
>
{data.checkedIn ? (
<>
<Sparkles className="h-3.5 w-3.5" />
今日已签到 · +{data.todayPoints} 积分
</>
) : submitting ? (
"签到中…"
) : (
<>
<Gift className="h-3.5 w-3.5" />
签到领 {data.nextPoints} 积分
</>
)}
</button>
{error && (
<div className="mt-2 rounded-lg bg-rose-50 border border-rose-200/60 px-2 py-1.5 text-[10px] text-rose-700">
{error}
</div>
)}
{/* 累计统计 */}
<div className="mt-3 flex items-center justify-around text-[10px] text-muted-foreground/60">
<div className="text-center">
<div className="text-base font-bold text-foreground/80 tabular-nums">
{data.totalCheckIns}
</div>
<div>累计签到</div>
</div>
<div className="h-6 w-px bg-border/40" />
<div className="text-center">
<div className="text-base font-bold text-amber-600 tabular-nums">
{data.currentStreak}
</div>
<div>连续天数</div>
</div>
<div className="h-6 w-px bg-border/40" />
<div className="text-center">
<div className="text-base font-bold text-orange-600 tabular-nums">
{data.nextPoints}
</div>
<div>明日奖励</div>
</div>
</div>
</div>
{/* 奖励阶梯预览 */}
<div className="border-t border-border/30 px-4 py-3 bg-gradient-to-b from-amber-50/30 to-transparent">
<div className="text-[10px] font-semibold text-amber-700/80 mb-2 flex items-center gap-1">
<Trophy className="h-3 w-3" />
7 天连签奖励
</div>
<div className="grid grid-cols-7 gap-1">
{REWARDS.map((r) => {
const reached = data.currentStreak >= r.day;
const isToday = data.currentStreak + 1 === r.day && !data.checkedIn;
return (
<div
key={r.day}
className={cn(
"flex flex-col items-center gap-0.5 rounded-md py-1 text-[9px]",
reached
? "bg-amber-100 text-amber-700"
: isToday
? "bg-orange-100 text-orange-700 ring-1 ring-orange-300"
: "bg-muted/30 text-muted-foreground/50"
)}
>
<span className="font-bold text-[10px]">{r.day}</span>
<span>+{r.points}</span>
</div>
);
})}
</div>
</div>
</section>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Heart, Sparkles, ShieldCheck, X, BookOpen, Lightbulb } from "lucide-react";
import { cn } from "@/lib/utils";
const RULES: Array<{ icon: string; title: string; desc: string }> = [
{
icon: "💡",
title: "分享原创、注重质量",
desc: "AI 工具使用心得、行业实战经验、踩坑记录最受欢迎",
},
{
icon: "🤝",
title: "友善沟通、文明讨论",
desc: "观点可以不同,但请保持尊重,禁止人身攻击与谩骂",
},
{
icon: "🚫",
title: "禁止广告与引流",
desc: "不发布垃圾广告、不借话题引流到外部群组或站点",
},
{
icon: "📚",
title: "善用标签、正确归类",
desc: "为话题打上准确标签,便于其他用户检索与发现",
},
{
icon: "✨",
title: "善用 @ 提及、引用回复",
desc: "通过 @ 主动联系他人,通过引用回复延续讨论",
},
{
icon: "🛡️",
title: "违规内容会被处理",
desc: "重复/违规内容会被隐藏或删除,恶意用户将被封禁",
},
];
export default function CommunityGuidelines() {
const [expanded, setExpanded] = useState(false);
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<BookOpen className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-semibold">社区公约</h3>
<span className="ml-auto text-[10px] text-muted-foreground/40">
共同维护
</span>
</div>
<div className="p-3 space-y-1.5">
{(expanded ? RULES : RULES.slice(0, 3)).map((r, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-lg p-2 hover:bg-sky-50/30 transition-colors"
>
<span className="text-base shrink-0 mt-0.5">{r.icon}</span>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium text-foreground/85">
{r.title}
</div>
<div className="text-[10.5px] text-muted-foreground/60 leading-relaxed mt-0.5">
{r.desc}
</div>
</div>
</div>
))}
</div>
{RULES.length > 3 && (
<div className="border-t border-border/30 px-4 py-2">
<button
onClick={() => setExpanded(!expanded)}
className="text-[11px] text-sky-600 hover:underline"
>
{expanded ? "收起" : `展开剩余 ${RULES.length - 3} 条`}
</button>
</div>
)}
<div className="border-t border-border/30 px-4 py-2.5 bg-gradient-to-r from-amber-50/40 to-orange-50/30 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] text-amber-700/80">
<Lightbulb className="h-3 w-3" />
遇到违规?点话题右下「举报」即可
</div>
</div>
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowRight, Bot as BotIcon, Sparkles } from "lucide-react";
import BotAvatar from "./BotAvatar";
import ActiveNowBadge from "./ActiveNowBadge";
interface Bot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
identity: string;
catchphrase: string;
activeHours: { weekday: string[]; weekend: string[] };
userId: number | null;
stats: { topics: number; posts: number };
}
export default function FeaturedBots({ limit = 6 }: { limit?: number }) {
const [bots, setBots] = useState<Bot[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bots?role=expert&sort=active`)
.then((r) => r.json())
.then((data) => {
setBots((data.bots || []).slice(0, limit));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [limit]);
if (loading) {
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex items-center justify-between border-b border-border/30 px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">社区居民</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse text-center">
<div className="h-14 w-14 rounded-full bg-muted/40 mx-auto mb-2" />
<div className="h-3 w-16 bg-muted/40 rounded mx-auto mb-1" />
<div className="h-2.5 w-20 bg-muted/40 rounded mx-auto" />
</div>
))}
</div>
</section>
);
}
if (bots.length === 0) return null;
return (
<section className="rounded-2xl border border-border/50 bg-gradient-to-br from-white via-violet-50/30 to-sky-50/30 shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex items-center justify-between border-b border-violet-200/30 px-5 py-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-500" />
<h2 className="text-sm font-semibold text-foreground">认识一下社区的居民们</h2>
<span className="text-[10px] text-muted-foreground/40">
{bots.length} 位活跃专家
</span>
</div>
<Link
href="/community/bots"
className="text-[11px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
>
查看全部 <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
{bots.map((bot) => (
<Link
key={bot.key}
href={`/community/bot/${bot.userId || bot.key}`}
className="group flex flex-col items-center text-center p-3 rounded-xl hover:bg-white/60 transition-colors"
>
<div className="mb-2 transition-transform group-hover:scale-105">
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="lg"
rounded="full"
/>
</div>
<p className="text-xs font-semibold text-foreground truncate w-full group-hover:text-sky-700 transition-colors">
{bot.displayName}
</p>
{bot.catchphrase && (
<p className="mt-0.5 text-[10px] text-muted-foreground/70 line-clamp-1 italic">
"{bot.catchphrase}"
</p>
)}
<div className="mt-1 flex items-center justify-center gap-1">
<ActiveNowBadge activeHours={bot.activeHours} />
<span className="text-[9px] text-muted-foreground/50 tabular-nums">
{bot.stats.topics + bot.stats.posts} 互动
</span>
</div>
</Link>
))}
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState, useTransition } from "react";
import { Bell, BellOff, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
interface FollowButtonProps {
categoryId: number;
initialFollowed: boolean;
followerCount: number;
className?: string;
}
export default function FollowButton({
categoryId,
initialFollowed,
followerCount,
className,
}: FollowButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [followed, setFollowed] = useState(initialFollowed);
const [count, setCount] = useState(followerCount);
const [pending, startTransition] = useTransition();
const [showLogin, setShowLogin] = useState(false);
const [justChanged, setJustChanged] = useState(false);
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!session?.user?.id) {
setShowLogin(true);
return;
}
if (pending) return;
const next = !followed;
setFollowed(next);
setCount(next ? count + 1 : Math.max(0, count - 1));
setJustChanged(true);
setTimeout(() => setJustChanged(false), 1500);
startTransition(async () => {
try {
const res = await fetch(
`/api/forum/categories/${categoryId}/follow`,
{ method: next ? "POST" : "DELETE" }
);
if (!res.ok) {
setFollowed(!next);
setCount(count);
} else {
const data = await res.json();
if (typeof data.followerCount === "number") setCount(data.followerCount);
router.refresh();
}
} catch {
setFollowed(!next);
setCount(count);
}
});
};
return (
<>
<button
type="button"
onClick={handleClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 h-8 text-xs font-medium transition-all border",
followed
? "bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100"
: "bg-white border-border/60 text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50",
className
)}
>
{followed ? (
<>
{justChanged ? <Check className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5 fill-sky-500 text-sky-500" />}
<span>已关注</span>
</>
) : (
<>
<BellOff className="h-3.5 w-3.5" />
<span>关注</span>
</>
)}
<span className="ml-1 text-[10px] tabular-nums opacity-70 border-l border-current/20 pl-1.5">
{count}
</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { UserLevel } from "@prisma/client";
import { Lock, Pin, Sparkles, MessageSquare } from "lucide-react";
import UserInfoCard from "./UserInfoCard";
import TimeAgo from "./TimeAgo";
import PostActionBar from "./PostActionBar";
import TagBadge from "./TagBadge";
import MentionContent from "./MentionContent";
import ReplyQuote from "./ReplyQuote";
import { cn } from "@/lib/utils";
export interface ForumPostUser {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
level?: UserLevel;
points?: number;
isBot?: boolean;
}
export interface ForumPostTag {
id: number;
name: string;
slug: string;
}
export interface ForumPostData {
id: number;
content: string;
isAnswer: boolean;
likeCount: number;
createdAt: Date | string;
user: ForumPostUser;
/** 被引用的回复 ID(用于嵌套引用展示) */
replyToId?: number | null;
replyToUserName?: string | null;
replyToContent?: string | null;
}
interface ForumPostBlockProps {
post: ForumPostData;
isTopicAuthor: boolean;
currentUserId?: number | null;
isFirst?: boolean;
isTopicPost?: boolean;
topicId: number;
topicAuthorId: number;
isLocked?: boolean;
topicMeta?: {
isPinned: boolean;
isLocked: boolean;
isFeatured: boolean;
title: string;
categoryName: string;
viewCount: number;
tags?: ForumPostTag[];
categorySlug?: string;
};
}
export default function ForumPostBlock({
post,
isTopicAuthor,
currentUserId,
isFirst = false,
isTopicPost = false,
topicId,
topicAuthorId,
isLocked = false,
topicMeta,
}: ForumPostBlockProps) {
const canBestAnswer = currentUserId === topicAuthorId;
const isOwnPost = currentUserId === post.user.id;
const displayName =
post.user.name || post.user.email?.split("@")[0] || "用户";
return (
<article
id={`post-${post.id}`}
className={cn(
"scroll-mt-20 rounded-2xl border bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden transition-colors",
isTopicPost
? "border-sky-200/60 ring-1 ring-sky-100"
: post.isAnswer
? "border-emerald-300/60 ring-1 ring-emerald-100"
: "border-border/50",
// 嵌套引用样式:被回复的帖子带左边框
!isTopicPost && post.replyToId && "border-l-2 border-l-sky-300/50"
)}
>
<div className="grid grid-cols-[auto_1fr]">
{/* Left: user card */}
<aside className="hidden sm:flex w-32 lg:w-36 shrink-0 border-r border-border/30 bg-gradient-to-b from-slate-50/50 to-white">
<UserInfoCard
user={post.user}
variant={isTopicPost ? "topic" : "post"}
className="w-full"
/>
</aside>
{/* Right: content */}
<div className="min-w-0 px-4 py-4 sm:px-6 sm:py-5">
{/* Mobile avatar */}
<div className="sm:hidden mb-3 flex items-center gap-2">
<UserInfoCard user={post.user} variant="post" className="!py-0 !px-0 flex-row gap-2" />
</div>
{/* Header */}
<header className="flex items-center justify-between gap-3 pb-3 border-b border-border/30">
<div className="flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground/60">
{isTopicPost && topicMeta && (
<>
{topicMeta.isPinned && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200/60">
<Pin className="h-2.5 w-2.5" />
置顶
</span>
)}
{topicMeta.isFeatured && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-gradient-to-r from-rose-50 to-pink-50 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 border border-rose-200/60">
<Sparkles className="h-2.5 w-2.5" />
精华
</span>
)}
{topicMeta.isLocked && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 border border-slate-200/60">
<Lock className="h-2.5 w-2.5" />
锁定
</span>
)}
<span className="rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 border border-sky-200/40">
{topicMeta.categoryName}
</span>
</>
)}
{isTopicAuthor && (
<span className="rounded-md bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 border border-violet-200/60">
楼主
</span>
)}
{post.isAnswer && (
<span className="rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 border border-emerald-200/60">
✓ 最佳答案
</span>
)}
<TimeAgo date={post.createdAt} prefix="发表于" />
</div>
{isTopicPost && topicMeta && (
<div className="hidden sm:flex items-center gap-2 text-[10px] text-muted-foreground/40">
<MessageSquare className="h-3 w-3" />
<span className="tabular-nums">{topicMeta.viewCount}</span>
</div>
)}
</header>
{/* Content */}
{isTopicPost && topicMeta && (
<h1 className="mt-3 text-xl sm:text-2xl font-bold tracking-tight text-foreground/90">
{topicMeta.title}
</h1>
)}
{isTopicPost && topicMeta && topicMeta.tags && topicMeta.tags.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{topicMeta.tags.map((t) => (
<TagBadge key={t.id} name={t.name} slug={t.slug} size="sm" />
))}
</div>
)}
{/* 嵌套引用预览 */}
{!isTopicPost && post.replyToId && post.replyToUserName && (
<ReplyQuote
replyToId={post.replyToId}
replyToUserName={post.replyToUserName}
replyToContent={post.replyToContent || ""}
topicId={topicId}
/>
)}
<div className="mt-3 text-[14.5px] leading-7 text-foreground/85 whitespace-pre-wrap break-words">
<MentionContent content={post.content} />
</div>
{/* Actions */}
<PostActionBar
postId={post.id}
topicId={topicId}
isTopicPost={isTopicPost}
isLocked={isLocked || (topicMeta?.isLocked ?? false)}
initialLiked={false}
initialLikeCount={post.likeCount}
authorId={post.user.id}
isTopicAuthor={isTopicAuthor}
isAccepted={post.isAnswer}
canBestAnswer={canBestAnswer}
isOwnPost={isOwnPost}
canEdit={isOwnPost}
canDelete={isOwnPost}
initialContent={post.content}
initialTitle={isTopicPost ? topicMeta?.title : undefined}
categorySlug={topicMeta?.categorySlug}
replyToUserName={displayName}
replyToContent={post.content}
/>
</div>
</div>
</article>
);
}

Some files were not shown because too many files have changed in this diff Show More