全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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: "/" })}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user