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

This commit is contained in:
ZhuiGuangAI Dev
2026-06-12 17:27:47 +08:00
parent 464924aea0
commit 4c325c8699
454 changed files with 33647 additions and 980 deletions
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy, Lock, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
type Tier = "bronze" | "silver" | "gold" | "special";
interface AchievementItem {
key: string;
name: string;
description: string;
icon: string;
tier: Tier;
category: string;
target: number;
progress: number;
unlocked: boolean;
unlockedAt: string | null;
}
interface AchievementListProps {
userId?: number;
className?: string;
}
const TIER_STYLES: Record<Tier, { ring: string; bg: string; text: string; badge: string }> = {
bronze: {
ring: "ring-amber-300/60",
bg: "from-amber-50 to-orange-50",
text: "text-amber-700",
badge: "🥉 铜",
},
silver: {
ring: "ring-slate-300/60",
bg: "from-slate-50 to-zinc-100",
text: "text-slate-700",
badge: "🥈 银",
},
gold: {
ring: "ring-yellow-400/60",
bg: "from-yellow-50 to-amber-100",
text: "text-amber-700",
badge: "🥇 金",
},
special: {
ring: "ring-fuchsia-300/60",
bg: "from-fuchsia-50 via-violet-50 to-sky-50",
text: "text-fuchsia-700",
badge: "✨ 稀有",
},
};
export default function AchievementList({ userId, className }: AchievementListProps) {
const [unlocked, setUnlocked] = useState<AchievementItem[]>([]);
const [locked, setLocked] = useState<AchievementItem[]>([]);
const [totalUnlocked, setTotalUnlocked] = useState(0);
const [totalAll, setTotalAll] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
const url = userId ? `/api/forum/achievements?userId=${userId}` : "/api/forum/achievements";
fetch(url)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => {
setUnlocked(d.unlocked || []);
setLocked(d.locked || []);
setTotalUnlocked(d.totalUnlocked || 0);
setTotalAll(d.totalAll || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [userId]);
if (loading) {
return (
<div className={cn("rounded-2xl border border-border/50 bg-white p-4 text-xs text-muted-foreground/50", className)}>
加载勋章中…
</div>
);
}
return (
<section className={cn("rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">成就勋章</h3>
</div>
<span className="text-[10px] text-muted-foreground/50">
{totalUnlocked} / {totalAll}
</span>
</div>
{/* 已解锁 */}
{unlocked.length > 0 && (
<div className="p-3">
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
已获得
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{unlocked.map((a) => {
const s = TIER_STYLES[a.tier as Tier] || TIER_STYLES.bronze;
return (
<div
key={a.key}
title={`${a.name} · ${a.description}`}
className={cn(
"group relative aspect-square rounded-xl bg-gradient-to-br ring-1 flex flex-col items-center justify-center p-1.5 cursor-default transition-transform hover:scale-105",
s.bg,
s.ring
)}
>
<span className="text-2xl leading-none mb-0.5">{a.icon}</span>
<span className={cn("text-[10px] font-semibold text-center leading-tight truncate w-full", s.text)}>
{a.name}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 未解锁(按进度排序) */}
{locked.length > 0 && (
<div className="border-t border-border/30 p-3">
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
<Lock className="h-3 w-3" />
待解锁
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{locked.slice(0, 8).map((a) => {
const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
return (
<div
key={a.key}
title={`${a.name} · 进度 ${a.progress}/${a.target}`}
className="relative aspect-square rounded-xl bg-muted/30 border border-border/40 flex flex-col items-center justify-center p-1.5 opacity-80 hover:opacity-100 transition-opacity"
>
<span className="text-2xl leading-none mb-0.5 grayscale">{a.icon}</span>
<span className="text-[10px] text-muted-foreground/70 text-center leading-tight truncate w-full">
{a.name}
</span>
<div className="absolute bottom-1 left-1.5 right-1.5">
<div className="h-0.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 to-orange-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[8px] text-muted-foreground/50 text-center mt-0.5 tabular-nums">
{a.progress}/{a.target}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{totalUnlocked === 0 && (
<div className="p-6 text-center text-xs text-muted-foreground/50">
还没有获得勋章,去
<a href="/community" className="text-sky-600 hover:underline mx-1">参与讨论</a>
吧
</div>
)}
</section>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bot as BotIcon, ArrowRight, Sparkles } from "lucide-react";
import BotAvatar from "./BotAvatar";
import ActiveNowBadge from "./ActiveNowBadge";
import { cn } from "@/lib/utils";
interface Bot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
activityLevel: string;
activeHours: { weekday: string[]; weekend: string[] };
userId: number | null;
stats: { topics: number; posts: number };
}
export default function ActiveBotsCard({ limit = 4, className }: { limit?: number; className?: string }) {
const [bots, setBots] = useState<Bot[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bots?sort=active`)
.then((r) => r.json())
.then((data) => {
// 只取"专家"角色中活跃的 Bot,并优先展示当前活跃中的
const experts: Bot[] = (data.bots || []).filter((b: Bot) => b.role === "expert");
// 简单排序:客户端无法判断 activeHours,所以按互动数降序即可
setBots(experts.slice(0, limit));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [limit]);
return (
<div className={cn("rounded-xl border border-border/40 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.04)]", className)}>
<div className="flex items-center justify-between px-1 py-1 mb-2">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<span className="text-[12px] font-semibold text-foreground/80">活跃居民</span>
</div>
<Link
href="/community/bots"
className="text-[10px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
>
全部 <ArrowRight className="h-2.5 w-2.5" />
</Link>
</div>
{loading ? (
<div className="space-y-1.5">
{Array.from({ length: limit }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5 animate-pulse">
<div className="h-8 w-8 rounded-full bg-muted/40" />
<div className="flex-1">
<div className="h-3 w-16 bg-muted/40 rounded mb-1" />
<div className="h-2.5 w-10 bg-muted/40 rounded" />
</div>
</div>
))}
</div>
) : bots.length === 0 ? (
<div className="text-center py-4">
<BotIcon className="h-6 w-6 text-muted-foreground/30 mx-auto mb-1" />
<p className="text-[10px] text-muted-foreground/50">还没有活跃的居民</p>
</div>
) : (
<div className="space-y-0.5">
{bots.map((bot) => {
const totalActivity = bot.stats.topics + bot.stats.posts;
return (
<Link
key={bot.key}
href={`/community/bot/${bot.userId || bot.key}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="sm"
showBadge={false}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<p className="text-[11px] font-medium truncate text-foreground/85">
{bot.displayName}
</p>
<ActiveNowBadge activeHours={bot.activeHours} />
</div>
<p className="text-[9px] text-muted-foreground/50">
{totalActivity} 条互动
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
interface ActiveNowBadgeProps {
activeHours: { weekday: string[]; weekend: string[] } | null | undefined;
className?: string;
showNextLabel?: boolean;
}
interface ActiveState {
active: boolean;
nextLabel: string | null;
}
/**
* 根据当前时间判断 Bot 是否处于活跃时段
* 注意:依赖客户端时钟,会在挂载后立即计算并随后每分钟刷新
*/
function computeState(
activeHours: ActiveNowBadgeProps["activeHours"],
now: Date
): ActiveState {
if (!activeHours) return { active: false, nextLabel: null };
const day = now.getDay();
const isWeekend = day === 0 || day === 6;
const ranges = isWeekend ? activeHours.weekend : activeHours.weekday;
if (!ranges || ranges.length === 0) return { active: false, nextLabel: null };
const currentMinutes = now.getHours() * 60 + now.getMinutes();
for (const range of ranges) {
if (range === "全天" || range === "全天活跃") return { active: true, nextLabel: null };
const m = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
if (!m) continue;
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
if (start === end) continue;
if (start < end) {
if (currentMinutes >= start && currentMinutes < end) {
return { active: true, nextLabel: null };
}
} else {
if (currentMinutes >= start || currentMinutes < end) {
return { active: true, nextLabel: null };
}
}
}
// 不在活跃时段,计算下次活跃时间
const tomorrowRanges = isWeekend ? activeHours.weekday : activeHours.weekend;
const findNext = (rs: string[], label: string) => {
for (const r of rs) {
if (r === "全天" || r === "全天活跃") return `${label}全天`;
const m = r.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
if (!m) continue;
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
if (label === "今天" && start <= currentMinutes) continue;
return `${label} ${m[1]}:${m[2]}`;
}
return null;
};
return {
active: false,
nextLabel: findNext(ranges, "今天") || findNext(tomorrowRanges, "明天"),
};
}
export default function ActiveNowBadge({ activeHours, className, showNextLabel = false }: ActiveNowBadgeProps) {
const [state, setState] = useState<ActiveState>({ active: false, nextLabel: null });
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const tick = () => setState(computeState(activeHours, new Date()));
tick();
// 每分钟刷新一次
const id = setInterval(tick, 60_000);
return () => clearInterval(id);
}, [activeHours]);
if (!mounted) {
// 避免 hydration mismatch,先渲染一个不可见占位
return <span className={cn("inline-block h-4 w-12", className)} aria-hidden />;
}
if (state.active) {
return (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200/60",
className
)}
title="现在正在活跃"
>
<Circle className="h-1.5 w-1.5 fill-emerald-500 text-emerald-500 animate-pulse" />
活跃中
</span>
);
}
if (showNextLabel && state.nextLabel) {
return (
<span
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground border border-border/40",
className
)}
title={`下次活跃:${state.nextLabel}`}
>
{state.nextLabel} 上线
</span>
);
}
return null;
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useMemo } from "react";
interface ActivityHeatmapProps {
data: Array<{ date: string; count: number; topics: number; posts: number; comments: number }>;
weeks?: number;
}
export default function ActivityHeatmap({ data, weeks = 13 }: ActivityHeatmapProps) {
const grid = useMemo(() => {
if (data.length === 0) return { cells: [], months: [] as Array<{ col: number; label: string }> };
const totalDays = weeks * 7;
const tail = data.slice(-totalDays);
const cells: Array<{ date: string; count: number; col: number; row: number }> = [];
let startCol = 0;
const months: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
tail.forEach((d, idx) => {
const date = new Date(d.date);
const col = Math.floor(idx / 7);
const row = date.getDay();
cells.push({ date: d.date, count: d.count, col, row });
const m = date.getMonth();
if (m !== lastMonth) {
months.push({ col, label: `${m + 1}月` });
lastMonth = m;
startCol = col;
}
});
return { cells, months };
}, [data, weeks]);
const max = useMemo(() => Math.max(1, ...grid.cells.map(c => c.count)), [grid.cells]);
const colorFor = (count: number) => {
if (count === 0) return "bg-slate-100";
const ratio = count / max;
if (ratio < 0.2) return "bg-emerald-100";
if (ratio < 0.4) return "bg-emerald-200";
if (ratio < 0.7) return "bg-emerald-400";
return "bg-emerald-600";
};
return (
<div className="overflow-x-auto">
<div className="inline-flex flex-col gap-1 min-w-full">
<div className="flex gap-1 pl-6 mb-1">
{Array.from({ length: weeks }).map((_, col) => {
const month = grid.months.find(m => m.col === col);
return (
<div key={col} className="w-3.5 text-[10px] text-muted-foreground/60">
{month ? month.label : ""}
</div>
);
})}
</div>
<div className="flex gap-1">
<div className="flex flex-col gap-1 pr-1.5 text-[10px] text-muted-foreground/50 leading-[14px]">
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>一</span>
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>三</span>
<span style={{ height: 14 }}></span>
<span style={{ height: 14 }}>五</span>
<span style={{ height: 14 }}></span>
</div>
{Array.from({ length: weeks }).map((_, col) => (
<div key={col} className="flex flex-col gap-1">
{Array.from({ length: 7 }).map((_, row) => {
const cell = grid.cells.find(c => c.col === col && c.row === row);
if (!cell) {
return <div key={row} className="w-3.5 h-3.5 rounded-sm bg-slate-50/40" />;
}
return (
<div
key={row}
className={`w-3.5 h-3.5 rounded-sm ${colorFor(cell.count)} hover:ring-1 hover:ring-primary/40 transition-all`}
title={`${cell.date}:${cell.count} 次活动`}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-2 mt-2 text-[10px] text-muted-foreground/60 pl-6">
<span>少</span>
<div className="w-3 h-3 rounded-sm bg-slate-100" />
<div className="w-3 h-3 rounded-sm bg-emerald-100" />
<div className="w-3 h-3 rounded-sm bg-emerald-200" />
<div className="w-3 h-3 rounded-sm bg-emerald-400" />
<div className="w-3 h-3 rounded-sm bg-emerald-600" />
<span>多</span>
</div>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState, useTransition } from "react";
import { Check, CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
interface BestAnswerButtonProps {
topicId: number;
postId: number;
postUserId: number;
initialAccepted: boolean;
isTopicAuthor: boolean;
isOwnPost: boolean;
className?: string;
}
export default function BestAnswerButton({
topicId,
postId,
postUserId,
initialAccepted,
isTopicAuthor,
isOwnPost,
className,
}: BestAnswerButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [accepted, setAccepted] = useState(initialAccepted);
const [pending, startTransition] = useTransition();
const [showLogin, setShowLogin] = useState(false);
// 只能在以下情况显示按钮:
// 1. 当前用户是话题楼主 且 回复作者不是自己
const canAccept = isTopicAuthor && !isOwnPost;
// 如果是最佳答案,但当前用户不是楼主,仅显示徽章
if (accepted && !canAccept) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200/60 px-2.5 h-7 text-[11px] font-semibold text-emerald-700",
className
)}
>
<CheckCircle2 className="h-3 w-3" />
<span>最佳答案</span>
</div>
);
}
if (!canAccept) return null;
const handleClick = async () => {
if (!session?.user?.id) {
setShowLogin(true);
return;
}
if (pending) return;
const next = !accepted;
setAccepted(next);
startTransition(async () => {
try {
const res = await fetch(
`/api/forum/topics/${topicId}/posts/${postId}/answer`,
{ method: next ? "POST" : "DELETE" }
);
if (!res.ok) {
setAccepted(!next);
const data = await res.json().catch(() => ({}));
console.error("采纳失败", data);
return;
}
router.refresh();
} catch (err) {
setAccepted(!next);
console.error(err);
}
});
};
return (
<>
<button
type="button"
onClick={handleClick}
disabled={pending}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 h-7 text-[11px] font-semibold transition-all",
accepted
? "bg-emerald-50 border-emerald-200/60 text-emerald-700 hover:bg-emerald-100"
: "bg-white border-border/60 text-muted-foreground hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50/50",
className
)}
title={accepted ? "取消采纳" : "采纳为最佳答案(+15 积分)"}
>
<Check
className={cn(
"h-3 w-3 transition-all",
accepted && "fill-emerald-500 text-emerald-500"
)}
/>
<span>{accepted ? "已采纳" : "采纳"}</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
import { cn } from "@/lib/utils";
interface BookmarkButtonProps {
topicId: number;
size?: "sm" | "md";
className?: string;
}
export default function BookmarkButton({
topicId,
size = "sm",
className,
}: BookmarkButtonProps) {
const { data: session, status } = useSession();
const router = useRouter();
const [bookmarked, setBookmarked] = useState(false);
const [pending, setPending] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (status !== "authenticated") {
setLoaded(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`);
if (res.ok && !cancelled) {
const data = await res.json();
setBookmarked(!!data.bookmarked);
}
} catch {
// 静默
} finally {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [status, topicId]);
const handleClick = async () => {
if (status !== "authenticated") {
setShowLogin(true);
return;
}
if (pending) return;
setPending(true);
const next = !bookmarked;
// 乐观更新
setBookmarked(next);
try {
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`, {
method: "POST",
});
if (!res.ok) {
// 回滚
setBookmarked(!next);
} else {
const data = await res.json();
setBookmarked(!!data.bookmarked);
if (data.bookmarked) {
// 提示用户:可到收藏夹查看
router.refresh();
}
}
} catch {
setBookmarked(!next);
} finally {
setPending(false);
}
};
const sizeClasses = size === "sm" ? "h-7 px-2.5 text-xs gap-1" : "h-8 px-3 text-sm gap-1.5";
return (
<>
<button
type="button"
onClick={handleClick}
disabled={pending || !loaded}
title={bookmarked ? "取消收藏" : "收藏话题"}
className={cn(
"inline-flex items-center rounded-full border transition-all font-medium",
sizeClasses,
bookmarked
? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100"
: "border-border/60 bg-white text-muted-foreground hover:border-amber-200 hover:text-amber-700 hover:bg-amber-50/50",
(pending || !loaded) && "opacity-70",
className
)}
>
{bookmarked ? (
<BookmarkCheck className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
) : (
<Bookmark className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
)}
<span>{bookmarked ? "已收藏" : "收藏"}</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { getBotAvatarUrl, getBotColorScheme } from "@/lib/bot-utils";
import { cn } from "@/lib/utils";
interface BotAvatarProps {
key: string;
displayName: string;
avatarPrompt?: string;
avatarUrl?: string | null;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
className?: string;
showBadge?: boolean;
rounded?: "full" | "xl" | "2xl";
}
const SIZE_MAP: Record<NonNullable<BotAvatarProps["size"]>, string> = {
xs: "h-6 w-6 text-[10px]",
sm: "h-8 w-8 text-xs",
md: "h-10 w-10 text-sm",
lg: "h-14 w-14 text-base",
xl: "h-20 w-20 text-xl",
"2xl": "h-28 w-28 text-3xl",
};
export default function BotAvatar({
key,
displayName,
avatarPrompt,
avatarUrl,
size = "md",
className,
showBadge = true,
rounded = "full",
}: BotAvatarProps) {
const [imgError, setImgError] = useState(false);
const scheme = getBotColorScheme(key);
const initial = displayName.charAt(0).toUpperCase();
// 优先使用用户已设置的 avatarUrl,否则使用动态生成的 URL
const finalAvatarUrl = !imgError
? avatarUrl || getBotAvatarUrl(avatarPrompt || "", key, "square_hd")
: null;
const radiusClass =
rounded === "full" ? "rounded-full" : rounded === "xl" ? "rounded-xl" : "rounded-2xl";
if (finalAvatarUrl) {
return (
<div className={cn("relative shrink-0", className)}>
<img
src={finalAvatarUrl}
alt={displayName}
onError={() => setImgError(true)}
className={cn(
radiusClass,
"object-cover border-2 border-white shadow-sm bg-gradient-to-br",
scheme.from,
scheme.to,
SIZE_MAP[size]
)}
/>
{showBadge && (
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
<span className="leading-none">🤖</span>
</span>
)}
</div>
);
}
return (
<div className={cn("relative shrink-0", className)}>
<div
className={cn(
radiusClass,
"flex items-center justify-center font-bold text-white shadow-sm border-2 border-white bg-gradient-to-br",
scheme.from,
scheme.to,
SIZE_MAP[size]
)}
>
{initial}
</div>
{showBadge && (
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
<span className="leading-none">🤖</span>
</span>
)}
</div>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Bell, BellOff, EyeOff, Eye } from "lucide-react";
import { cn } from "@/lib/utils";
interface BotPreferenceProps {
botUserId: number;
botKey?: string;
botName: string;
className?: string;
variant?: "default" | "compact";
}
const STORAGE_KEY = "bot-preferences";
interface BotPref {
muted: boolean;
followedAt: number | null;
}
function readPrefs(): Record<string, BotPref> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function writePrefs(prefs: Record<string, BotPref>) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// 静默失败
}
}
export function useBotPreferences() {
const [prefs, setPrefs] = useState<Record<string, BotPref>>({});
useEffect(() => {
setPrefs(readPrefs());
}, []);
const getPref = (botKey: string): BotPref => {
return prefs[botKey] || { muted: false, followedAt: null };
};
const toggleMute = (botKey: string) => {
setPrefs((prev) => {
const current = prev[botKey] || { muted: false, followedAt: null };
const next = { ...prev, [botKey]: { ...current, muted: !current.muted } };
writePrefs(next);
return next;
});
};
const toggleFollow = (botKey: string) => {
setPrefs((prev) => {
const current = prev[botKey] || { muted: false, followedAt: null };
const next = {
...prev,
[botKey]: {
...current,
followedAt: current.followedAt ? null : Date.now(),
},
};
writePrefs(next);
return next;
});
};
return { prefs, getPref, toggleMute, toggleFollow };
}
export default function BotPreferenceButtons({
botUserId,
botKey,
botName,
className,
variant = "default",
}: BotPreferenceProps) {
const { data: session } = useSession();
const [mounted, setMounted] = useState(false);
const key = botKey || `id_${botUserId}`;
const { getPref, toggleMute, toggleFollow } = useBotPreferences();
useEffect(() => {
setMounted(true);
}, []);
const pref = getPref(key);
const isFollowing = !!pref.followedAt;
const isMuted = !!pref.muted;
if (!mounted) return null;
if (!session?.user) return null;
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1.5", className)}>
<button
onClick={() => toggleFollow(key)}
className={cn(
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
isFollowing
? "bg-sky-50 text-sky-700 border-sky-200/60"
: "bg-white text-muted-foreground border-border/60 hover:border-sky-300 hover:text-sky-700"
)}
title={isFollowing ? `已关注 ${botName}` : `关注 ${botName}`}
>
{isFollowing ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
{isFollowing ? "已关注" : "关注"}
</button>
<button
onClick={() => toggleMute(key)}
className={cn(
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
isMuted
? "bg-rose-50 text-rose-700 border-rose-200/60"
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
)}
title={isMuted ? `已屏蔽 ${botName} 的回复` : `屏蔽 ${botName} 的回复`}
>
{isMuted ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{isMuted ? "已屏蔽" : "屏蔽"}
</button>
</div>
);
}
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<button
onClick={() => toggleFollow(key)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
isFollowing
? "bg-sky-50 text-sky-700 border-sky-200/60 hover:bg-sky-100"
: "bg-white text-foreground/80 border-border/60 hover:border-sky-300 hover:text-sky-700"
)}
>
{isFollowing ? <Bell className="h-3.5 w-3.5 fill-sky-500" /> : <BellOff className="h-3.5 w-3.5" />}
{isFollowing ? "已关注" : "关注 TA"}
</button>
<button
onClick={() => toggleMute(key)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
isMuted
? "bg-rose-50 text-rose-700 border-rose-200/60 hover:bg-rose-100"
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
)}
>
{isMuted ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{isMuted ? "已屏蔽回复" : "屏蔽回复"}
</button>
<p className="text-[10px] text-muted-foreground/50">
仅保存在本设备
</p>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { Calendar, Flame, Sparkles, Trophy, Gift } from "lucide-react";
import { LoginModal } from "@/components/common/LoginModal";
import { cn } from "@/lib/utils";
interface CheckInDay {
date: string;
checked: boolean;
streak: number;
pointsEarned: number;
isToday: boolean;
}
interface CheckInState {
checkedIn: boolean;
todayPoints: number;
currentStreak: number;
totalCheckIns: number;
nextStreak: number;
nextPoints: number;
calendar: CheckInDay[];
}
const REWARDS = [
{ day: 1, points: 5, label: "新启程" },
{ day: 2, points: 6, label: "坚持" },
{ day: 3, points: 7, label: "习惯" },
{ day: 4, points: 8, label: "深耕" },
{ day: 5, points: 9, label: "进阶" },
{ day: 6, points: 10, label: "常驻" },
{ day: 7, points: 15, label: "周满贯" },
];
export default function CheckInCard() {
const { status } = useSession();
const [data, setData] = useState<CheckInState | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [showLogin, setShowLogin] = useState(false);
const fetchState = useCallback(async () => {
if (status !== "authenticated") {
setLoading(false);
return;
}
setLoading(true);
try {
const res = await fetch("/api/forum/checkin");
if (res.ok) {
const d = await res.json();
setData(d);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
fetchState();
}, [fetchState]);
const handleCheckIn = async () => {
if (status !== "authenticated") {
setShowLogin(true);
return;
}
if (submitting || data?.checkedIn) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/forum/checkin", { method: "POST" });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error || "签到失败");
setSubmitting(false);
return;
}
const d = await res.json();
if (d.success) {
await fetchState();
}
} catch {
setError("网络错误");
} finally {
setSubmitting(false);
}
};
if (status !== "authenticated" && !loading) {
return (
<>
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground/60 mb-3">登录后开启你的连续签到之旅</p>
<button
onClick={() => setShowLogin(true)}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white text-sm py-2 hover:from-amber-600 hover:to-orange-600 transition-colors shadow-sm"
>
<Sparkles className="h-3.5 w-3.5" />
立即登录
</button>
</div>
</section>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
if (loading || !data) {
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
<div className="p-4 text-xs text-muted-foreground/40">加载中…</div>
</section>
);
}
const weekday = ["日", "一", "二", "三", "四", "五", "六"];
return (
<>
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-semibold">每日签到</h3>
</div>
{data.currentStreak > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200/60 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
<Flame className="h-2.5 w-2.5" />
连续 {data.currentStreak} 天
</span>
)}
</div>
<div className="p-4">
{/* 近 7 天日历 */}
<div className="grid grid-cols-7 gap-1.5 mb-4">
{data.calendar.map((d) => {
const day = new Date(d.date);
return (
<div
key={d.date}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg py-2 text-[10px] font-medium transition-colors",
d.checked
? "bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700 border border-amber-200/60"
: d.isToday
? "border-2 border-dashed border-amber-300 text-amber-600"
: "bg-muted/30 text-muted-foreground/40"
)}
>
<span className="text-[9px]">周{weekday[day.getDay()]}</span>
<span className={cn("text-xs", d.checked && "font-bold")}>
{day.getDate()}
</span>
{d.checked && <span className="text-[9px]">+{d.pointsEarned}</span>}
</div>
);
})}
</div>
{/* 签到按钮 */}
<button
onClick={handleCheckIn}
disabled={submitting || data.checkedIn}
className={cn(
"w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-sm py-2.5 font-semibold transition-all",
data.checkedIn
? "bg-gradient-to-r from-emerald-400 to-teal-400 text-white shadow-sm cursor-default"
: "bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 shadow-sm shadow-amber-500/20 active:scale-95"
)}
>
{data.checkedIn ? (
<>
<Sparkles className="h-3.5 w-3.5" />
今日已签到 · +{data.todayPoints} 积分
</>
) : submitting ? (
"签到中…"
) : (
<>
<Gift className="h-3.5 w-3.5" />
签到领 {data.nextPoints} 积分
</>
)}
</button>
{error && (
<div className="mt-2 rounded-lg bg-rose-50 border border-rose-200/60 px-2 py-1.5 text-[10px] text-rose-700">
{error}
</div>
)}
{/* 累计统计 */}
<div className="mt-3 flex items-center justify-around text-[10px] text-muted-foreground/60">
<div className="text-center">
<div className="text-base font-bold text-foreground/80 tabular-nums">
{data.totalCheckIns}
</div>
<div>累计签到</div>
</div>
<div className="h-6 w-px bg-border/40" />
<div className="text-center">
<div className="text-base font-bold text-amber-600 tabular-nums">
{data.currentStreak}
</div>
<div>连续天数</div>
</div>
<div className="h-6 w-px bg-border/40" />
<div className="text-center">
<div className="text-base font-bold text-orange-600 tabular-nums">
{data.nextPoints}
</div>
<div>明日奖励</div>
</div>
</div>
</div>
{/* 奖励阶梯预览 */}
<div className="border-t border-border/30 px-4 py-3 bg-gradient-to-b from-amber-50/30 to-transparent">
<div className="text-[10px] font-semibold text-amber-700/80 mb-2 flex items-center gap-1">
<Trophy className="h-3 w-3" />
7 天连签奖励
</div>
<div className="grid grid-cols-7 gap-1">
{REWARDS.map((r) => {
const reached = data.currentStreak >= r.day;
const isToday = data.currentStreak + 1 === r.day && !data.checkedIn;
return (
<div
key={r.day}
className={cn(
"flex flex-col items-center gap-0.5 rounded-md py-1 text-[9px]",
reached
? "bg-amber-100 text-amber-700"
: isToday
? "bg-orange-100 text-orange-700 ring-1 ring-orange-300"
: "bg-muted/30 text-muted-foreground/50"
)}
>
<span className="font-bold text-[10px]">{r.day}</span>
<span>+{r.points}</span>
</div>
);
})}
</div>
</div>
</section>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Heart, Sparkles, ShieldCheck, X, BookOpen, Lightbulb } from "lucide-react";
import { cn } from "@/lib/utils";
const RULES: Array<{ icon: string; title: string; desc: string }> = [
{
icon: "💡",
title: "分享原创、注重质量",
desc: "AI 工具使用心得、行业实战经验、踩坑记录最受欢迎",
},
{
icon: "🤝",
title: "友善沟通、文明讨论",
desc: "观点可以不同,但请保持尊重,禁止人身攻击与谩骂",
},
{
icon: "🚫",
title: "禁止广告与引流",
desc: "不发布垃圾广告、不借话题引流到外部群组或站点",
},
{
icon: "📚",
title: "善用标签、正确归类",
desc: "为话题打上准确标签,便于其他用户检索与发现",
},
{
icon: "✨",
title: "善用 @ 提及、引用回复",
desc: "通过 @ 主动联系他人,通过引用回复延续讨论",
},
{
icon: "🛡️",
title: "违规内容会被处理",
desc: "重复/违规内容会被隐藏或删除,恶意用户将被封禁",
},
];
export default function CommunityGuidelines() {
const [expanded, setExpanded] = useState(false);
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<BookOpen className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-semibold">社区公约</h3>
<span className="ml-auto text-[10px] text-muted-foreground/40">
共同维护
</span>
</div>
<div className="p-3 space-y-1.5">
{(expanded ? RULES : RULES.slice(0, 3)).map((r, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-lg p-2 hover:bg-sky-50/30 transition-colors"
>
<span className="text-base shrink-0 mt-0.5">{r.icon}</span>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium text-foreground/85">
{r.title}
</div>
<div className="text-[10.5px] text-muted-foreground/60 leading-relaxed mt-0.5">
{r.desc}
</div>
</div>
</div>
))}
</div>
{RULES.length > 3 && (
<div className="border-t border-border/30 px-4 py-2">
<button
onClick={() => setExpanded(!expanded)}
className="text-[11px] text-sky-600 hover:underline"
>
{expanded ? "收起" : `展开剩余 ${RULES.length - 3} 条`}
</button>
</div>
)}
<div className="border-t border-border/30 px-4 py-2.5 bg-gradient-to-r from-amber-50/40 to-orange-50/30 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] text-amber-700/80">
<Lightbulb className="h-3 w-3" />
遇到违规?点话题右下「举报」即可
</div>
</div>
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowRight, Bot as BotIcon, Sparkles } from "lucide-react";
import BotAvatar from "./BotAvatar";
import ActiveNowBadge from "./ActiveNowBadge";
interface Bot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
identity: string;
catchphrase: string;
activeHours: { weekday: string[]; weekend: string[] };
userId: number | null;
stats: { topics: number; posts: number };
}
export default function FeaturedBots({ limit = 6 }: { limit?: number }) {
const [bots, setBots] = useState<Bot[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bots?role=expert&sort=active`)
.then((r) => r.json())
.then((data) => {
setBots((data.bots || []).slice(0, limit));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [limit]);
if (loading) {
return (
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex items-center justify-between border-b border-border/30 px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">社区居民</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse text-center">
<div className="h-14 w-14 rounded-full bg-muted/40 mx-auto mb-2" />
<div className="h-3 w-16 bg-muted/40 rounded mx-auto mb-1" />
<div className="h-2.5 w-20 bg-muted/40 rounded mx-auto" />
</div>
))}
</div>
</section>
);
}
if (bots.length === 0) return null;
return (
<section className="rounded-2xl border border-border/50 bg-gradient-to-br from-white via-violet-50/30 to-sky-50/30 shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex items-center justify-between border-b border-violet-200/30 px-5 py-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-500" />
<h2 className="text-sm font-semibold text-foreground">认识一下社区的居民们</h2>
<span className="text-[10px] text-muted-foreground/40">
{bots.length} 位活跃专家
</span>
</div>
<Link
href="/community/bots"
className="text-[11px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
>
查看全部 <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
{bots.map((bot) => (
<Link
key={bot.key}
href={`/community/bot/${bot.userId || bot.key}`}
className="group flex flex-col items-center text-center p-3 rounded-xl hover:bg-white/60 transition-colors"
>
<div className="mb-2 transition-transform group-hover:scale-105">
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="lg"
rounded="full"
/>
</div>
<p className="text-xs font-semibold text-foreground truncate w-full group-hover:text-sky-700 transition-colors">
{bot.displayName}
</p>
{bot.catchphrase && (
<p className="mt-0.5 text-[10px] text-muted-foreground/70 line-clamp-1 italic">
"{bot.catchphrase}"
</p>
)}
<div className="mt-1 flex items-center justify-center gap-1">
<ActiveNowBadge activeHours={bot.activeHours} />
<span className="text-[9px] text-muted-foreground/50 tabular-nums">
{bot.stats.topics + bot.stats.posts} 互动
</span>
</div>
</Link>
))}
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState, useTransition } from "react";
import { Bell, BellOff, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginModal } from "@/components/common/LoginModal";
interface FollowButtonProps {
categoryId: number;
initialFollowed: boolean;
followerCount: number;
className?: string;
}
export default function FollowButton({
categoryId,
initialFollowed,
followerCount,
className,
}: FollowButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [followed, setFollowed] = useState(initialFollowed);
const [count, setCount] = useState(followerCount);
const [pending, startTransition] = useTransition();
const [showLogin, setShowLogin] = useState(false);
const [justChanged, setJustChanged] = useState(false);
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!session?.user?.id) {
setShowLogin(true);
return;
}
if (pending) return;
const next = !followed;
setFollowed(next);
setCount(next ? count + 1 : Math.max(0, count - 1));
setJustChanged(true);
setTimeout(() => setJustChanged(false), 1500);
startTransition(async () => {
try {
const res = await fetch(
`/api/forum/categories/${categoryId}/follow`,
{ method: next ? "POST" : "DELETE" }
);
if (!res.ok) {
setFollowed(!next);
setCount(count);
} else {
const data = await res.json();
if (typeof data.followerCount === "number") setCount(data.followerCount);
router.refresh();
}
} catch {
setFollowed(!next);
setCount(count);
}
});
};
return (
<>
<button
type="button"
onClick={handleClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 h-8 text-xs font-medium transition-all border",
followed
? "bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100"
: "bg-white border-border/60 text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50",
className
)}
>
{followed ? (
<>
{justChanged ? <Check className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5 fill-sky-500 text-sky-500" />}
<span>已关注</span>
</>
) : (
<>
<BellOff className="h-3.5 w-3.5" />
<span>关注</span>
</>
)}
<span className="ml-1 text-[10px] tabular-nums opacity-70 border-l border-current/20 pl-1.5">
{count}
</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { UserLevel } from "@prisma/client";
import { Lock, Pin, Sparkles, MessageSquare } from "lucide-react";
import UserInfoCard from "./UserInfoCard";
import TimeAgo from "./TimeAgo";
import PostActionBar from "./PostActionBar";
import TagBadge from "./TagBadge";
import MentionContent from "./MentionContent";
import ReplyQuote from "./ReplyQuote";
import { cn } from "@/lib/utils";
export interface ForumPostUser {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
level?: UserLevel;
points?: number;
isBot?: boolean;
}
export interface ForumPostTag {
id: number;
name: string;
slug: string;
}
export interface ForumPostData {
id: number;
content: string;
isAnswer: boolean;
likeCount: number;
createdAt: Date | string;
user: ForumPostUser;
/** 被引用的回复 ID(用于嵌套引用展示) */
replyToId?: number | null;
replyToUserName?: string | null;
replyToContent?: string | null;
}
interface ForumPostBlockProps {
post: ForumPostData;
isTopicAuthor: boolean;
currentUserId?: number | null;
isFirst?: boolean;
isTopicPost?: boolean;
topicId: number;
topicAuthorId: number;
isLocked?: boolean;
topicMeta?: {
isPinned: boolean;
isLocked: boolean;
isFeatured: boolean;
title: string;
categoryName: string;
viewCount: number;
tags?: ForumPostTag[];
categorySlug?: string;
};
}
export default function ForumPostBlock({
post,
isTopicAuthor,
currentUserId,
isFirst = false,
isTopicPost = false,
topicId,
topicAuthorId,
isLocked = false,
topicMeta,
}: ForumPostBlockProps) {
const canBestAnswer = currentUserId === topicAuthorId;
const isOwnPost = currentUserId === post.user.id;
const displayName =
post.user.name || post.user.email?.split("@")[0] || "用户";
return (
<article
id={`post-${post.id}`}
className={cn(
"scroll-mt-20 rounded-2xl border bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden transition-colors",
isTopicPost
? "border-sky-200/60 ring-1 ring-sky-100"
: post.isAnswer
? "border-emerald-300/60 ring-1 ring-emerald-100"
: "border-border/50",
// 嵌套引用样式:被回复的帖子带左边框
!isTopicPost && post.replyToId && "border-l-2 border-l-sky-300/50"
)}
>
<div className="grid grid-cols-[auto_1fr]">
{/* Left: user card */}
<aside className="hidden sm:flex w-32 lg:w-36 shrink-0 border-r border-border/30 bg-gradient-to-b from-slate-50/50 to-white">
<UserInfoCard
user={post.user}
variant={isTopicPost ? "topic" : "post"}
className="w-full"
/>
</aside>
{/* Right: content */}
<div className="min-w-0 px-4 py-4 sm:px-6 sm:py-5">
{/* Mobile avatar */}
<div className="sm:hidden mb-3 flex items-center gap-2">
<UserInfoCard user={post.user} variant="post" className="!py-0 !px-0 flex-row gap-2" />
</div>
{/* Header */}
<header className="flex items-center justify-between gap-3 pb-3 border-b border-border/30">
<div className="flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground/60">
{isTopicPost && topicMeta && (
<>
{topicMeta.isPinned && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200/60">
<Pin className="h-2.5 w-2.5" />
置顶
</span>
)}
{topicMeta.isFeatured && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-gradient-to-r from-rose-50 to-pink-50 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 border border-rose-200/60">
<Sparkles className="h-2.5 w-2.5" />
精华
</span>
)}
{topicMeta.isLocked && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 border border-slate-200/60">
<Lock className="h-2.5 w-2.5" />
锁定
</span>
)}
<span className="rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 border border-sky-200/40">
{topicMeta.categoryName}
</span>
</>
)}
{isTopicAuthor && (
<span className="rounded-md bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 border border-violet-200/60">
楼主
</span>
)}
{post.isAnswer && (
<span className="rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 border border-emerald-200/60">
✓ 最佳答案
</span>
)}
<TimeAgo date={post.createdAt} prefix="发表于" />
</div>
{isTopicPost && topicMeta && (
<div className="hidden sm:flex items-center gap-2 text-[10px] text-muted-foreground/40">
<MessageSquare className="h-3 w-3" />
<span className="tabular-nums">{topicMeta.viewCount}</span>
</div>
)}
</header>
{/* Content */}
{isTopicPost && topicMeta && (
<h1 className="mt-3 text-xl sm:text-2xl font-bold tracking-tight text-foreground/90">
{topicMeta.title}
</h1>
)}
{isTopicPost && topicMeta && topicMeta.tags && topicMeta.tags.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{topicMeta.tags.map((t) => (
<TagBadge key={t.id} name={t.name} slug={t.slug} size="sm" />
))}
</div>
)}
{/* 嵌套引用预览 */}
{!isTopicPost && post.replyToId && post.replyToUserName && (
<ReplyQuote
replyToId={post.replyToId}
replyToUserName={post.replyToUserName}
replyToContent={post.replyToContent || ""}
topicId={topicId}
/>
)}
<div className="mt-3 text-[14.5px] leading-7 text-foreground/85 whitespace-pre-wrap break-words">
<MentionContent content={post.content} />
</div>
{/* Actions */}
<PostActionBar
postId={post.id}
topicId={topicId}
isTopicPost={isTopicPost}
isLocked={isLocked || (topicMeta?.isLocked ?? false)}
initialLiked={false}
initialLikeCount={post.likeCount}
authorId={post.user.id}
isTopicAuthor={isTopicAuthor}
isAccepted={post.isAnswer}
canBestAnswer={canBestAnswer}
isOwnPost={isOwnPost}
canEdit={isOwnPost}
canDelete={isOwnPost}
initialContent={post.content}
initialTitle={isTopicPost ? topicMeta?.title : undefined}
categorySlug={topicMeta?.categorySlug}
replyToUserName={displayName}
replyToContent={post.content}
/>
</div>
</div>
</article>
);
}
+159
View File
@@ -0,0 +1,159 @@
import Link from "next/link";
import { UserLevel } from "@prisma/client";
import { Pin, Lock, MessageSquare, Eye, Heart } from "lucide-react";
import UserAvatar from "./UserAvatar";
import TimeAgo from "./TimeAgo";
import TagBadge from "./TagBadge";
import { cn } from "@/lib/utils";
export interface ForumTopicRowUser {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
level?: UserLevel;
isBot?: boolean;
}
export interface ForumTopicRowCategory {
id?: number;
name: string;
slug: string;
icon?: string | null;
}
export interface ForumTopicRowTag {
id: number;
name: string;
slug: string;
}
export interface ForumTopicRowData {
id: number;
title: string;
isPinned?: boolean;
isLocked?: boolean;
isFeatured?: boolean;
viewCount: number;
replyCount: number;
likeCount?: number;
lastReplyAt?: Date | string | null;
createdAt: Date | string;
category: ForumTopicRowCategory;
user: ForumTopicRowUser;
tags?: ForumTopicRowTag[];
}
interface ForumTopicRowProps {
topic: ForumTopicRowData;
href?: string;
showCategory?: boolean;
showTags?: boolean;
compact?: boolean;
}
export default function ForumTopicRow({
topic,
href,
showCategory = true,
showTags = true,
compact = false,
}: ForumTopicRowProps) {
const linkHref = href || `/community/topic/${topic.id}`;
const displayName =
topic.user.name || topic.user.email?.split("@")[0] || "用户";
return (
<Link
href={linkHref}
className={cn(
"group flex items-start gap-3 px-4 py-3.5 transition-colors",
"hover:bg-sky-50/40"
)}
>
<UserAvatar
name={topic.user.name}
email={topic.user.email}
avatarUrl={topic.user.avatarUrl}
level={topic.user.level}
isBot={topic.user.isBot}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{topic.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>
)}
{topic.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">
✨ 精华
</span>
)}
{topic.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>
)}
{showCategory && (
<span className="inline-flex items-center gap-0.5 rounded-md bg-sky-50/80 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 border border-sky-200/40">
{topic.category.icon || "📌"} {topic.category.name}
</span>
)}
<h3
className={cn(
"text-sm font-medium text-foreground/90 truncate group-hover:text-sky-700 transition-colors",
compact ? "max-w-[60ch]" : "max-w-[80ch]"
)}
title={topic.title}
>
{topic.title}
</h3>
</div>
<div className="mt-1.5 flex items-center gap-2.5 flex-wrap text-[11px] text-muted-foreground/60">
<span className="font-medium text-foreground/70 hover:text-foreground">
{displayName}
</span>
<TimeAgo date={topic.createdAt} />
{topic.lastReplyAt && (
<span className="text-muted-foreground/40">
· 最后回复 <TimeAgo date={topic.lastReplyAt} className="text-muted-foreground/50" />
</span>
)}
{showTags && topic.tags && topic.tags.length > 0 && (
<span className="inline-flex items-center gap-1 flex-wrap ml-1">
{topic.tags.slice(0, 3).map((t) => (
<TagBadge key={t.id} name={t.name} slug={t.slug} />
))}
{topic.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground/40">+{topic.tags.length - 3}</span>
)}
</span>
)}
</div>
</div>
<div className="hidden sm:flex items-center gap-3 text-[11px] text-muted-foreground/50 shrink-0 self-center">
<span className="inline-flex items-center gap-1" title="回复数">
<MessageSquare className="h-3 w-3" />
<span className="tabular-nums font-medium">{topic.replyCount}</span>
</span>
{topic.likeCount !== undefined && topic.likeCount > 0 && (
<span className="inline-flex items-center gap-1" title="点赞数">
<Heart className="h-3 w-3" />
<span className="tabular-nums font-medium">{topic.likeCount}</span>
</span>
)}
<span className="inline-flex items-center gap-1" title="浏览数">
<Eye className="h-3 w-3" />
<span className="tabular-nums font-medium">{topic.viewCount}</span>
</span>
</div>
</Link>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useState, useTransition } from "react";
import { Heart } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
import { useRouter } from "next/navigation";
interface LikeButtonProps {
targetType: "topic" | "post";
targetId: number;
initialLiked: boolean;
initialCount: number;
authorId?: number;
size?: "sm" | "md" | "lg";
className?: string;
}
export default function LikeButton({
targetType,
targetId,
initialLiked,
initialCount,
authorId,
size = "md",
className,
}: LikeButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [pending, startTransition] = useTransition();
const [showLogin, setShowLogin] = useState(false);
const sizeClasses = {
sm: "h-7 px-2.5 text-xs gap-1",
md: "h-8 px-3 text-sm gap-1.5",
lg: "h-9 px-4 text-sm gap-2",
};
const iconSizes = { sm: "h-3.5 w-3.5", md: "h-4 w-4", lg: "h-4 w-4" };
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!session?.user?.id) {
setShowLogin(true);
return;
}
if (authorId && session.user.id === String(authorId)) {
return;
}
if (pending) return;
const nextLiked = !liked;
const nextCount = nextLiked ? count + 1 : Math.max(0, count - 1);
setLiked(nextLiked);
setCount(nextCount);
startTransition(async () => {
try {
const res = await fetch(
`/api/forum/${targetType}s/${targetId}/like`,
{ method: nextLiked ? "POST" : "DELETE" }
);
if (!res.ok) {
setLiked(!nextLiked);
setCount(count);
const data = await res.json().catch(() => ({}));
console.error("点赞失败", data);
return;
}
const data = await res.json();
if (typeof data.count === "number") setCount(data.count);
router.refresh();
} catch (err) {
setLiked(!nextLiked);
setCount(count);
console.error(err);
}
});
};
const isAuthor = authorId && session?.user?.id === String(authorId);
return (
<>
<button
type="button"
onClick={handleClick}
disabled={!!isAuthor}
title={
isAuthor
? "不能给自己的内容点赞"
: liked
? "取消点赞"
: "点赞"
}
className={cn(
"inline-flex items-center justify-center rounded-full font-medium transition-all border",
sizeClasses[size],
liked
? "bg-rose-50 border-rose-200 text-rose-600 hover:bg-rose-100"
: "bg-white border-border/60 text-muted-foreground hover:border-rose-200 hover:text-rose-600 hover:bg-rose-50/50",
isAuthor && "opacity-50 cursor-not-allowed",
className
)}
>
<Heart
className={cn(
iconSizes[size],
"transition-all",
liked && "fill-rose-500 text-rose-500 scale-110"
)}
/>
<span className="tabular-nums">{count > 0 ? count : "点赞"}</span>
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { cn } from "@/lib/utils";
interface MentionContentProps {
content: string;
className?: string;
}
const MENTION_REGEX = /@([\w一-龥]{1,30})/g;
/**
* 将正文按 @mention 切分,渲染为带高亮的 React 节点。
* @ 后面跟随的名字在 1-30 位(字母数字下划线或中文)。
*/
export default function MentionContent({ content, className }: MentionContentProps) {
if (!content) return null;
const matches = Array.from(content.matchAll(MENTION_REGEX));
const parts: Array<{ type: "text" | "mention"; value: string; name: string }> = [];
let lastIdx = 0;
for (const m of matches) {
const idx = m.index ?? 0;
if (idx > lastIdx) {
parts.push({ type: "text", value: content.slice(lastIdx, idx), name: "" });
}
parts.push({ type: "mention", value: m[0], name: m[1] || "" });
lastIdx = idx + m[0].length;
}
if (lastIdx < content.length) {
parts.push({ type: "text", value: content.slice(lastIdx), name: "" });
}
return (
<span className={className}>
{parts.map((p, i) => {
if (p.type === "mention") {
return (
<span
key={i}
className="inline-flex items-center rounded-md bg-sky-50 px-1 py-0 text-sky-700 font-medium"
>
{p.value}
</span>
);
}
return <span key={i}>{p.value}</span>;
})}
</span>
);
}
+290
View File
@@ -0,0 +1,290 @@
"use client";
import {
useEffect,
useRef,
useState,
useCallback,
type ChangeEvent,
type KeyboardEvent,
type RefObject,
} from "react";
import { AtSign } from "lucide-react";
import { cn } from "@/lib/utils";
interface MentionUser {
id: number;
name: string;
email: string;
avatarUrl?: string | null;
level?: string;
points?: number;
isBot?: boolean;
}
interface MentionTextareaProps {
value: string;
onChange: (next: string) => void;
placeholder?: string;
rows?: number;
maxLength?: number;
className?: string;
disabled?: boolean;
textareaClassName?: string;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
}
const MENTION_TRIGGER = "@";
const MENTION_REGEX = /@([\w一-龥]{1,30})/g;
export function extractMentions(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 default function MentionTextarea({
value,
onChange,
placeholder,
rows = 4,
maxLength = 5000,
className,
disabled,
textareaClassName,
textareaRef,
}: MentionTextareaProps) {
const innerRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<MentionUser[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionStart, setMentionStart] = useState<number | null>(null);
const [fetching, setFetching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 检测光标位置是否在 @ 触发中
const detectMention = useCallback(() => {
const ta = innerRef.current;
if (!ta) return;
const cursor = ta.selectionStart;
const text = ta.value;
// 从光标向前查找最近的 @
let i = cursor - 1;
let start = -1;
while (i >= 0) {
const ch = text[i];
if (ch === MENTION_TRIGGER) {
// 必须在词首或空格后
if (i === 0 || /\s/.test(text[i - 1])) {
start = i;
}
break;
}
if (/\s/.test(ch)) break;
i--;
}
if (start >= 0) {
const query = text.slice(start + 1, cursor);
if (query.length <= 30) {
setMentionStart(start);
setMentionQuery(query);
return;
}
}
setMentionStart(null);
setMentionQuery("");
setShowSuggestions(false);
}, []);
// 拉取用户建议
useEffect(() => {
if (mentionStart === null) {
setShowSuggestions(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setFetching(true);
try {
const res = await fetch(
`/api/users/search?q=${encodeURIComponent(mentionQuery)}&limit=8`
);
if (res.ok) {
const data = await res.json();
setSuggestions(data.users || []);
setActiveIdx(0);
setShowSuggestions((data.users || []).length > 0);
}
} catch {
// 静默
} finally {
setFetching(false);
}
}, 150);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [mentionQuery, mentionStart]);
// 点击外部关闭
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
if (showSuggestions) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [showSuggestions]);
const insertMention = (user: MentionUser) => {
if (mentionStart === null) return;
const ta = innerRef.current;
if (!ta) return;
const cursor = ta.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursor);
const mention = `@${user.name} `;
const next = before + mention + after;
onChange(next.slice(0, maxLength));
// 恢复光标位置
requestAnimationFrame(() => {
const newPos = mentionStart + mention.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
});
setShowSuggestions(false);
setMentionStart(null);
setMentionQuery("");
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value.slice(0, maxLength);
onChange(next);
detectMention();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => (i + 1) % suggestions.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(suggestions[activeIdx]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowSuggestions(false);
return;
}
}
};
const handleSelect = () => {
detectMention();
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<textarea
ref={(node) => {
(innerRef as any).current = node;
if (typeof textareaRef === "function") {
(textareaRef as any)(node);
} else if (textareaRef) {
(textareaRef as any).current = node;
}
}}
data-reply-textarea
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onClick={handleSelect}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
disabled={disabled}
className={cn(
"w-full resize-y rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100",
textareaClassName
)}
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-30 left-0 right-0 bottom-full mb-1 max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-white shadow-lg">
{fetching && suggestions.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground/50">搜索中...</div>
) : (
<>
<div className="px-3 py-1.5 text-[10px] text-muted-foreground/40 border-b border-border/30 flex items-center gap-1">
<AtSign className="h-2.5 w-2.5" />
<span>提及用户(上下选择 · 回车确认)</span>
</div>
{suggestions.map((u, idx) => (
<button
type="button"
key={u.id}
onClick={() => insertMention(u)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors",
activeIdx === idx
? "bg-sky-50 text-sky-700"
: "hover:bg-slate-50 text-foreground/80"
)}
>
{u.avatarUrl ? (
<img
src={u.avatarUrl}
alt=""
className="h-5 w-5 rounded-full object-cover border border-border/30 shrink-0"
/>
) : (
<div className={cn(
"h-5 w-5 rounded-full text-[10px] font-semibold flex items-center justify-center shrink-0 text-white",
u.isBot
? "bg-gradient-to-br from-sky-400 via-violet-500 to-fuchsia-500"
: "bg-primary/10 text-primary"
)}>
{u.isBot ? "🤖" : (u.name || u.email).charAt(0).toUpperCase()}
</div>
)}
<span className="font-medium truncate">{u.name}</span>
{u.isBot && (
<span className="text-[9px] px-1 py-0 rounded bg-violet-100 text-violet-700 shrink-0">
Bot
</span>
)}
<span className="text-[10px] text-muted-foreground/40 truncate">
{u.email}
</span>
</button>
))}
</>
)}
</div>
)}
</div>
);
}
+417
View File
@@ -0,0 +1,417 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, X, Save, FileText, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
import TagInput from "./TagInput";
import { cn } from "@/lib/utils";
interface CategoryOption {
id: number;
name: string;
slug: string;
icon?: string | null;
}
interface DraftInfo {
id: number;
title: string | null;
content: string;
categoryId: number | null;
updatedAt: string;
}
interface NewTopicDialogProps {
categories: CategoryOption[];
defaultCategorySlug?: string;
variant?: "primary" | "outline";
className?: string;
}
const DRAFT_AUTOSAVE_DELAY = 2000; // 2s
export default function NewTopicDialog({
categories,
defaultCategorySlug,
variant = "primary",
className,
}: NewTopicDialogProps) {
const { data: session } = useSession();
const router = useRouter();
const [open, setOpen] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [categorySlug, setCategorySlug] = useState(
defaultCategorySlug || categories[0]?.slug || ""
);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// 草稿状态
const [draft, setDraft] = useState<DraftInfo | null>(null);
const [draftRestored, setDraftRestored] = useState(false);
const [savingDraft, setSavingDraft] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialCheckDone = useRef(false);
const isLoggedIn = !!session?.user?.id;
// 打开时检查草稿
const handleOpen = async () => {
if (!isLoggedIn) {
setShowLogin(true);
return;
}
setOpen(true);
setError("");
setDraftRestored(false);
// 重置字段
setTitle("");
setContent("");
setTags([]);
if (!initialCheckDone.current) {
initialCheckDone.current = true;
}
// 拉取草稿
try {
const res = await fetch("/api/forum/drafts");
if (res.ok) {
const data = await res.json();
const first = data.drafts?.[0];
if (first && first.content?.trim()) {
setDraft({
id: first.id,
title: first.title,
content: first.content,
categoryId: first.categoryId,
updatedAt: first.updatedAt,
});
} else {
setDraft(null);
}
}
} catch {
// 静默
}
};
const handleClose = () => {
if (submitting) return;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
setOpen(false);
setError("");
};
// 恢复草稿
const restoreDraft = () => {
if (!draft) return;
setTitle(draft.title || "");
setContent(draft.content);
if (draft.categoryId) {
const cat = categories.find((c) => c.id === draft.categoryId);
if (cat) setCategorySlug(cat.slug);
}
setDraftRestored(true);
};
// 丢弃草稿
const discardDraft = async () => {
if (!confirm("确定要丢弃当前草稿吗?此操作无法恢复。")) return;
try {
await fetch("/api/forum/drafts", { method: "DELETE" });
setDraft(null);
} catch {
// 静默
}
};
// 自动保存草稿
useEffect(() => {
if (!open || !isLoggedIn) return;
if (!content.trim() && !title.trim() && tags.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
try {
setSavingDraft(true);
const cat = categories.find((c) => c.slug === categorySlug);
const res = await fetch("/api/forum/drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim() || undefined,
content: content.trim(),
categoryId: cat?.id ?? null,
}),
});
if (res.ok) {
const data = await res.json();
setLastSavedAt(Date.now());
if (data.draft) {
setDraft({
id: data.draft.id,
title: data.draft.title,
content: data.draft.content,
categoryId: data.draft.categoryId,
updatedAt: data.draft.updatedAt,
});
}
}
} catch {
// 静默
} finally {
setSavingDraft(false);
}
}, DRAFT_AUTOSAVE_DELAY);
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [open, isLoggedIn, title, content, categorySlug, tags, categories]);
// 关闭后清理
useEffect(() => {
if (!open) {
setDraftRestored(false);
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!categorySlug) {
setError("请选择板块");
return;
}
if (!title.trim()) {
setError("请输入标题");
return;
}
if (!content.trim()) {
setError("请输入内容");
return;
}
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/forum/topics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
categorySlug,
title: title.trim(),
content: content.trim(),
tags,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "发布失败");
setSubmitting(false);
return;
}
const data = await res.json();
// 发布成功后清理草稿
try {
await fetch("/api/forum/drafts", { method: "DELETE" });
} catch {
// 静默
}
setOpen(false);
setTitle("");
setContent("");
setTags([]);
setDraft(null);
setLastSavedAt(null);
setSubmitting(false);
router.push(`/community/topic/${data.id}`);
router.refresh();
} catch (err) {
setError("网络错误,请稍后重试");
setSubmitting(false);
}
};
const draftSavedLabel = savingDraft
? "保存中..."
: lastSavedAt
? `已自动保存 · ${new Date(lastSavedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
: "";
return (
<>
<Button
type="button"
onClick={handleOpen}
variant={variant === "primary" ? "default" : "outline"}
className={cn("gap-1.5", className)}
>
<Plus className="h-4 w-4" />
<span>发布话题</span>
</Button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 animate-in fade-in-0">
<div
className="absolute inset-0"
onClick={handleClose}
aria-label="关闭"
/>
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white shadow-[0_24px_48px_rgba(0,0,0,0.18)] border border-border/60 animate-in zoom-in-95 fade-in-0">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border/40 bg-white/95 backdrop-blur-sm px-5 py-3">
<h2 className="text-base font-semibold">发布新话题</h2>
<div className="flex items-center gap-3">
{draftSavedLabel && (
<span className="hidden sm:inline-flex items-center gap-1 text-[10px] text-muted-foreground/50">
<Save className="h-3 w-3" />
{draftSavedLabel}
</span>
)}
<button
type="button"
onClick={handleClose}
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted/50"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 草稿恢复提示 */}
{draft && !draftRestored && (
<div className="mx-5 mt-4 flex items-start gap-3 rounded-lg border border-amber-200/60 bg-amber-50/80 px-3 py-2.5">
<FileText className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-amber-900 font-medium">
发现未完成的草稿
{draft.title ? `《${draft.title.slice(0, 20)}${draft.title.length > 20 ? "..." : ""}》` : ""}
</p>
<p className="text-[10px] text-amber-700/70 mt-0.5">
更新于 {new Date(draft.updatedAt).toLocaleString("zh-CN")}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={restoreDraft}
className="rounded-md bg-amber-500 text-white text-xs font-medium px-2.5 py-1 hover:bg-amber-600 transition-colors"
>
恢复
</button>
<button
type="button"
onClick={discardDraft}
className="rounded-md border border-amber-300/60 bg-white text-amber-800 text-xs font-medium px-2 py-1 hover:bg-amber-100 transition-colors inline-flex items-center gap-1"
title="丢弃草稿"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
选择板块
</label>
<div className="flex flex-wrap gap-1.5">
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setCategorySlug(cat.slug)}
className={cn(
"inline-flex items-center gap-1 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-all",
categorySlug === cat.slug
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-border/60 bg-white text-muted-foreground hover:border-sky-200 hover:text-sky-700"
)}
>
<span>{cat.icon || "📌"}</span>
<span>{cat.name}</span>
</button>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
标题 <span className="text-muted-foreground/40">({title.length}/100)</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, 100))}
placeholder="一句话说清楚你想讨论什么..."
className="w-full h-10 rounded-lg border border-border/60 bg-white px-3 text-sm focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
maxLength={100}
required
/>
</div>
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
正文 <span className="text-muted-foreground/40">({content.length}/10000)</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value.slice(0, 10000))}
placeholder="详细描述你的问题、观点或经验。支持换行分段。后续将支持富文本与图片上传。"
rows={10}
className="w-full resize-y rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
maxLength={10000}
required
/>
</div>
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 flex items-center justify-between">
<span>标签 <span className="text-muted-foreground/40">({tags.length}/5)</span></span>
<span className="text-[10px] text-muted-foreground/40 font-normal">回车确认 · 最多 5 个</span>
</label>
<TagInput value={tags} onChange={setTags} maxTags={5} />
</div>
{error && (
<div className="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 border-t border-border/40 pt-4">
<p className="text-[11px] text-muted-foreground/50">
发布即同意社区规范,AI/灌水内容将被扣除积分
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={submitting}
>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "发布中..." : "立即发布"}
</Button>
</div>
</div>
</form>
</div>
</div>
)}
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Sparkles, X, ArrowRight, Gift, MessageSquare, Users, Award, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "forum:onboarded:v1";
interface OnboardStep {
icon: React.ComponentType<{ className?: string }>;
title: string;
desc: string;
link?: { href: string; label: string };
}
const STEPS: OnboardStep[] = [
{
icon: MessageSquare,
title: "发话题、问问题",
desc: "选择对应板块发布内容,使用 @ 提及用户,回复可被引用",
},
{
icon: Award,
title: "积极参与、赚积分",
desc: "发话题 +10、回复 +5、被点赞 +2、被采纳最佳答案 +15",
},
{
icon: Gift,
title: "每日签到",
desc: "每天访问社区中心即可领取 5~15 积分,连续 7 天解锁周满贯奖励",
link: { href: "/community/checkin", label: "去签到" },
},
{
icon: Users,
title: "找到同好",
desc: "关注板块、收藏话题,与志同道合的人深度交流",
},
];
/**
* 新人引导 Toast:首次访问社区中心展示一次
*/
export default function NewcomerOnboard() {
const pathname = usePathname();
const [visible, setVisible] = useState(false);
const [step, setStep] = useState(0);
useEffect(() => {
// 只在 /community 根路径触发
if (pathname !== "/community") return;
if (typeof window === "undefined") return;
const onboarded = localStorage.getItem(STORAGE_KEY);
if (onboarded) return;
// 延迟 800ms 弹出
const t = setTimeout(() => setVisible(true), 800);
return () => clearTimeout(t);
}, [pathname]);
const handleClose = () => {
setVisible(false);
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, "1");
}
};
const handleNext = () => {
if (step < STEPS.length - 1) {
setStep(step + 1);
} else {
handleClose();
}
};
if (!visible) return null;
const current = STEPS[step];
const Icon = current.icon;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 w-[min(420px,calc(100vw-2rem))]">
<div className="rounded-2xl border border-amber-200/60 bg-gradient-to-br from-amber-50 via-white to-orange-50/40 shadow-2xl shadow-amber-500/10 p-5">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm">
<Icon className="h-4 w-4" />
</div>
<div>
<div className="text-[10px] font-semibold text-amber-700/80 uppercase tracking-wider">
新人指南 · {step + 1} / {STEPS.length}
</div>
<div className="text-sm font-bold text-foreground/90">
{current.title}
</div>
</div>
</div>
<button
onClick={handleClose}
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>
<p className="text-xs text-muted-foreground/80 leading-relaxed mb-3">
{current.desc}
</p>
{current.link && (
<Link
href={current.link.href}
onClick={handleClose}
className="inline-flex items-center gap-1 text-xs text-sky-600 hover:underline mb-2"
>
{current.link.label} <ChevronRight className="h-3 w-3" />
</Link>
)}
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-1 flex-1">
{STEPS.map((_, i) => (
<div
key={i}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
i <= step ? "bg-amber-400" : "bg-amber-100"
)}
/>
))}
</div>
<button
onClick={handleNext}
className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white text-xs px-3 h-7 font-medium hover:from-amber-600 hover:to-orange-600 shadow-sm"
>
{step < STEPS.length - 1 ? "下一步" : "开始探索"}
<ArrowRight className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useState, useEffect } from "react";
import { MessageSquare, CornerDownRight } from "lucide-react";
import LikeButton from "./LikeButton";
import BestAnswerButton from "./BestAnswerButton";
import BookmarkButton from "./BookmarkButton";
import ReportDialog from "./ReportDialog";
import PostActions from "./PostActions";
import { cn } from "@/lib/utils";
interface PostActionBarProps {
postId: number;
topicId: number;
isTopicPost: boolean;
isLocked: boolean;
initialLiked: boolean;
initialLikeCount: number;
authorId: number;
isTopicAuthor: boolean;
isAccepted: boolean;
canBestAnswer: boolean;
isOwnPost: boolean;
canEdit: boolean;
canDelete: boolean;
initialContent?: string;
initialTitle?: string;
categorySlug?: string;
replyToUserName?: string;
replyToContent?: string;
className?: string;
}
export default function PostActionBar({
postId,
topicId,
isTopicPost,
isLocked,
initialLiked,
initialLikeCount,
authorId,
isTopicAuthor,
isAccepted,
canBestAnswer,
isOwnPost,
canEdit,
canDelete,
initialContent,
initialTitle,
categorySlug,
replyToUserName,
replyToContent,
className,
}: PostActionBarProps) {
const [quoteHint, setQuoteHint] = useState(false);
// 接收来自 ReplyForm 的反馈
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ postId: number; userName: string; content: string }>;
if (ce.detail?.postId === postId) {
setQuoteHint(true);
const t = setTimeout(() => setQuoteHint(false), 2000);
return () => clearTimeout(t);
}
};
window.addEventListener("forum:reply-cancel", handler);
return () => window.removeEventListener("forum:reply-cancel", handler);
}, [postId]);
const handleReply = () => {
if (isLocked) return;
window.dispatchEvent(
new CustomEvent("forum:reply-to", {
detail: {
postId,
topicId,
userName: replyToUserName || "用户",
content: replyToContent || "",
},
})
);
};
return (
<div className={cn("mt-5 flex items-center gap-2 -ml-1 flex-wrap", className)}>
<LikeButton
targetType={isTopicPost ? "topic" : "post"}
targetId={postId}
initialLiked={initialLiked}
initialCount={initialLikeCount}
authorId={authorId}
size="sm"
/>
{!isTopicPost && (
<BestAnswerButton
topicId={topicId}
postId={postId}
postUserId={authorId}
initialAccepted={isAccepted}
isTopicAuthor={canBestAnswer}
isOwnPost={isOwnPost}
/>
)}
{!isTopicPost && (
<button
type="button"
onClick={handleReply}
disabled={isLocked}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border transition-all font-medium",
"h-7 px-2.5 text-xs",
quoteHint
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-border/60 bg-white text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50",
isLocked && "opacity-50 cursor-not-allowed hover:border-border/60 hover:text-muted-foreground hover:bg-white"
)}
title={isLocked ? "话题已锁定" : "回复该楼层"}
>
{quoteHint ? (
<>
<CornerDownRight className="h-3 w-3" />
<span>已跳转</span>
</>
) : (
<>
<MessageSquare className="h-3 w-3" />
<span>回复</span>
</>
)}
</button>
)}
{isTopicPost && (
<BookmarkButton topicId={topicId} size="sm" />
)}
<ReportDialog
topicId={isTopicPost ? topicId : undefined}
postId={!isTopicPost ? postId : undefined}
size="sm"
/>
{isOwnPost && (
<PostActions
topicId={topicId}
postId={postId}
isTopicPost={isTopicPost}
canEdit={canEdit}
canDelete={canDelete}
initialContent={initialContent ?? ""}
initialTitle={isTopicPost ? initialTitle : undefined}
categorySlug={categorySlug}
className="ml-auto"
/>
)}
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { MoreHorizontal, Pencil, Trash2, X, Save } from "lucide-react";
import { cn } from "@/lib/utils";
interface PostActionsProps {
topicId: number;
postId: number;
isTopicPost: boolean;
canEdit: boolean;
canDelete: boolean;
initialContent: string;
initialTitle?: string;
categorySlug?: string;
className?: string;
}
export default function PostActions({
topicId,
postId,
isTopicPost,
canEdit,
canDelete,
initialContent,
initialTitle,
categorySlug,
className,
}: PostActionsProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [title, setTitle] = useState(initialTitle || "");
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
if (menuOpen) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [menuOpen]);
if (!canEdit && !canDelete) return null;
const openEdit = () => {
setTitle(initialTitle || "");
setContent(initialContent);
setError("");
setEditOpen(true);
setMenuOpen(false);
};
const handleSave = async () => {
setSaving(true);
setError("");
try {
const endpoint = isTopicPost
? `/api/forum/topics/${topicId}`
: `/api/forum/topics/${topicId}/posts/${postId}`;
const body = isTopicPost
? { title: title.trim(), content: content.trim() }
: { content: content.trim() };
const res = await fetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "保存失败");
setSaving(false);
return;
}
setEditOpen(false);
setSaving(false);
router.refresh();
} catch {
setError("网络错误");
setSaving(false);
}
};
const handleDelete = async () => {
const msg = isTopicPost
? "确定要删除该话题吗?所有回复也将被删除,此操作无法恢复。"
: "确定要删除该回复吗?此操作无法恢复。";
if (!confirm(msg)) return;
setMenuOpen(false);
try {
const endpoint = isTopicPost
? `/api/forum/topics/${topicId}`
: `/api/forum/topics/${topicId}/posts/${postId}`;
const res = await fetch(endpoint, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || "删除失败");
return;
}
if (isTopicPost) {
router.push(categorySlug ? `/community/${categorySlug}` : "/community");
} else {
router.refresh();
}
} catch {
alert("网络错误");
}
};
return (
<>
<div ref={menuRef} className={cn("relative inline-block", className)}>
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-white px-2.5 h-7 text-xs font-medium text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50 transition-all"
aria-label="更多操作"
>
<MoreHorizontal className="h-3 w-3" />
</button>
{menuOpen && (
<div className="absolute right-0 top-full mt-1 z-20 min-w-[120px] rounded-lg border border-border/60 bg-white shadow-lg overflow-hidden">
{canEdit && (
<button
type="button"
onClick={openEdit}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-foreground/80 hover:bg-slate-50 transition-colors"
>
<Pencil className="h-3 w-3" />
<span>编辑</span>
</button>
)}
{canDelete && (
<button
type="button"
onClick={handleDelete}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-rose-600 hover:bg-rose-50 transition-colors border-t border-border/30"
>
<Trash2 className="h-3 w-3" />
<span>删除</span>
</button>
)}
</div>
)}
</div>
{/* 编辑弹窗 */}
{editOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div
className="absolute inset-0"
onClick={() => !saving && setEditOpen(false)}
/>
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white shadow-2xl border border-border/60">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border/40 bg-white/95 backdrop-blur-sm px-5 py-3">
<h2 className="text-base font-semibold">编辑{isTopicPost ? "话题" : "回复"}</h2>
<button
type="button"
onClick={() => !saving && setEditOpen(false)}
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-4">
{isTopicPost && (
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
标题 <span className="text-muted-foreground/40">({title.length}/100)</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, 100))}
className="w-full h-10 rounded-lg border border-border/60 bg-white px-3 text-sm focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
maxLength={100}
/>
</div>
)}
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
内容 <span className="text-muted-foreground/40">({content.length}/{isTopicPost ? 10000 : 5000})</span>
</label>
<textarea
value={content}
onChange={(e) =>
setContent(e.target.value.slice(0, isTopicPost ? 10000 : 5000))
}
rows={isTopicPost ? 10 : 6}
className="w-full resize-y rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
</div>
{error && (
<div className="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-end gap-2 border-t border-border/40 pt-4">
<button
type="button"
onClick={() => !saving && setEditOpen(false)}
disabled={saving}
className="rounded-lg border border-border/60 bg-white px-4 py-1.5 text-sm text-foreground/80 hover:bg-muted/50 disabled:opacity-50"
>
取消
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || !content.trim() || (isTopicPost && !title.trim())}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary text-white px-4 py-1.5 text-sm hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="h-3.5 w-3.5" />
{saving ? "保存中..." : "保存"}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { CornerDownRight, Quote } from "lucide-react";
import { cn } from "@/lib/utils";
interface ReplyQuoteProps {
replyToId: number;
replyToUserName: string;
replyToContent: string;
topicId: number;
className?: string;
}
function truncate(text: string, max = 200) {
const s = text.replace(/\s+/g, " ").trim();
if (s.length <= max) return s;
return s.slice(0, max) + "…";
}
/**
* 在回复正文中展示「引用了 @xxx 的回复」+ 简短摘要。
* 点击跳转到被引用的回复。
*/
export default function ReplyQuote({
replyToId,
replyToUserName,
replyToContent,
topicId,
className,
}: ReplyQuoteProps) {
return (
<Link
href={`/community/topic/${topicId}#post-${replyToId}`}
className={cn(
"group mt-1 mb-3 block max-w-2xl rounded-lg border-l-2 border-sky-300 bg-slate-50/70 hover:bg-sky-50/60 transition-colors",
className
)}
>
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60 mb-1">
<Quote className="h-3 w-3" />
<span>
引用 <span className="font-semibold text-sky-700">@{replyToUserName}</span> 的回复
</span>
<CornerDownRight className="h-2.5 w-2.5 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-[12.5px] text-muted-foreground/70 leading-relaxed line-clamp-2 italic">
“{truncate(replyToContent, 160)}”
</p>
</div>
</Link>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { Flag, X, Send } from "lucide-react";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
import { cn } from "@/lib/utils";
interface ReportDialogProps {
topicId?: number;
postId?: number;
size?: "sm" | "md";
className?: string;
}
const REASONS: Array<{ key: string; label: string; desc: string }> = [
{ key: "spam", label: "垃圾广告", desc: "推广信息、刷屏、引流" },
{ key: "abuse", label: "人身攻击", desc: "侮辱、谩骂、骚扰他人" },
{ key: "inappropriate", label: "不当内容", desc: "色情、暴力、血腥等" },
{ key: "illegal", label: "违法违规", desc: "违反法律法规或平台规则" },
{ key: "duplicate", label: "重复内容", desc: "已存在相同话题" },
{ key: "other", label: "其他问题", desc: "其他需要处理的情况" },
];
export default function ReportDialog({
topicId,
postId,
size = "sm",
className,
}: ReportDialogProps) {
const { data: session } = useSession();
const [open, setOpen] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [reason, setReason] = useState("");
const [detail, setDetail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleOpen = () => {
if (!session?.user?.id) {
setShowLogin(true);
return;
}
setOpen(true);
setReason("");
setDetail("");
setError("");
setSuccess(false);
};
const handleSubmit = async () => {
if (!reason) {
setError("请选择举报原因");
return;
}
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/forum/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topicId,
postId,
reason,
detail: detail.trim() || undefined,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "提交失败");
setSubmitting(false);
return;
}
setSuccess(true);
setSubmitting(false);
setTimeout(() => {
setOpen(false);
}, 1500);
} catch {
setError("网络错误");
setSubmitting(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={handleOpen}
className={cn(
"inline-flex items-center rounded-full border border-border/60 bg-white text-muted-foreground hover:border-rose-200 hover:text-rose-700 hover:bg-rose-50/50 transition-all font-medium",
sizeClasses,
className
)}
title="举报"
>
<Flag className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
<span>举报</span>
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="absolute inset-0" onClick={() => !submitting && setOpen(false)} />
<div className="relative z-10 w-full max-w-md rounded-2xl bg-white shadow-2xl border border-border/60">
<div className="flex items-center justify-between border-b border-border/40 px-5 py-3">
<h2 className="text-base font-semibold inline-flex items-center gap-2">
<Flag className="h-4 w-4 text-rose-600" />
举报{postId ? "回复" : "话题"}
</h2>
<button
type="button"
onClick={() => !submitting && setOpen(false)}
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-4">
{success ? (
<div className="rounded-lg bg-emerald-50 border border-emerald-200/60 px-3 py-3 text-xs text-emerald-700">
✅ 举报已提交,我们会尽快处理
</div>
) : (
<>
<div>
<label className="text-xs font-medium text-foreground/80 mb-2 block">
选择举报原因
</label>
<div className="space-y-1.5">
{REASONS.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setReason(r.key)}
className={cn(
"flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-all",
reason === r.key
? "border-rose-300 bg-rose-50/60"
: "border-border/50 hover:border-rose-200 hover:bg-rose-50/30"
)}
>
<div
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border-2 shrink-0 transition-colors",
reason === r.key
? "border-rose-500 bg-rose-500"
: "border-border"
)}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{r.label}</div>
<div className="text-[10px] text-muted-foreground/60 mt-0.5">
{r.desc}
</div>
</div>
</button>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-foreground/80 mb-1.5 block">
补充说明 <span className="text-muted-foreground/40">(可选)</span>
</label>
<textarea
value={detail}
onChange={(e) => setDetail(e.target.value.slice(0, 500))}
placeholder="如有需要,请补充说明具体情况..."
rows={3}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-xs focus:outline-none focus:border-rose-300 focus:ring-2 focus:ring-rose-100"
/>
<div className="text-[10px] text-muted-foreground/40 text-right mt-0.5">
{detail.length}/500
</div>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200/60 px-3 py-2 text-xs text-rose-700">
{error}
</div>
)}
</>
)}
</div>
{!success && (
<div className="flex items-center justify-end gap-2 border-t border-border/40 px-5 py-3">
<button
type="button"
onClick={() => !submitting && setOpen(false)}
disabled={submitting}
className="rounded-lg border border-border/60 bg-white px-4 py-1.5 text-sm text-foreground/80 hover:bg-muted/50 disabled:opacity-50"
>
取消
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitting || !reason}
className="inline-flex items-center gap-1.5 rounded-lg bg-rose-500 text-white px-4 py-1.5 text-sm hover:bg-rose-600 disabled:opacity-50 transition-colors"
>
<Send className="h-3.5 w-3.5" />
{submitting ? "提交中..." : "提交举报"}
</button>
</div>
)}
</div>
</div>
)}
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchBoxProps {
initialValue?: string;
className?: string;
placeholder?: string;
autoFocus?: boolean;
}
export default function SearchBox({
initialValue,
className,
placeholder = "搜索话题标题或正文…",
autoFocus,
}: SearchBoxProps) {
const router = useRouter();
const params = useSearchParams();
const [q, setQ] = useState(initialValue ?? params.get("q") ?? "");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus();
}
}, [autoFocus]);
const submit = (e?: React.FormEvent) => {
e?.preventDefault();
const term = q.trim();
if (!term) return;
router.push(`/community/search?q=${encodeURIComponent(term)}`);
};
return (
<form
onSubmit={submit}
className={cn(
"flex items-center gap-2 rounded-full border border-border/50 bg-white px-3 h-9 transition-colors focus-within:border-sky-300 focus-within:ring-2 focus-within:ring-sky-100",
className
)}
>
<Search className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
<input
ref={inputRef}
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent text-sm focus:outline-none placeholder:text-muted-foreground/40"
/>
{q && (
<button
type="button"
onClick={() => {
setQ("");
inputRef.current?.focus();
}}
className="rounded-full p-0.5 text-muted-foreground/40 hover:bg-muted/50 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
<button
type="submit"
className="rounded-full bg-sky-500 text-white text-xs px-3 h-6 hover:bg-sky-600 transition-colors"
>
搜索
</button>
</form>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { Share2, Copy, Check, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface ShareDialogProps {
url: string;
title: string;
description?: string;
className?: string;
}
/**
* 话题分享按钮:点击展开分享面板
* - 复制链接
* - 站内私信
* - 分享到社交平台
*/
export default function ShareDialog({ url, title, description, className }: ShareDialogProps) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// 兜底:用 prompt
window.prompt("复制链接", url);
}
};
const shareLinks = [
{
key: "weibo",
label: "微博",
icon: "W",
href: `https://service.weibo.com/share/share.php?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`,
},
{
key: "twitter",
label: "Twitter",
icon: "T",
href: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
},
];
return (
<div className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="分享"
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-white text-muted-foreground px-2.5 py-1 text-xs font-medium hover:border-sky-300 hover:text-sky-600 transition-colors"
>
<Share2 className="h-3.5 w-3.5" />
分享
</button>
{open && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setOpen(false)}
/>
<div className="absolute right-0 top-9 z-40 w-72 rounded-xl border border-border/60 bg-white shadow-xl p-3 space-y-3">
<div className="text-xs font-semibold text-foreground/80">分享话题</div>
{/* 链接复制 */}
<div className="flex items-center gap-1.5">
<div className="flex-1 truncate rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-[11px] text-muted-foreground">
<Link2 className="inline h-3 w-3 mr-1" />
{url}
</div>
<button
onClick={copyLink}
className="inline-flex items-center gap-1 rounded-md bg-sky-500 text-white px-2.5 py-1.5 text-xs font-medium hover:bg-sky-600 transition-colors"
>
{copied ? (
<>
<Check className="h-3 w-3" />
已复制
</>
) : (
<>
<Copy className="h-3 w-3" />
复制
</>
)}
</button>
</div>
{description && (
<div className="rounded-md bg-muted/30 px-2 py-1.5 text-[11px] text-muted-foreground/70 line-clamp-2">
{description}
</div>
)}
{/* 外部分享 */}
<div className="grid grid-cols-2 gap-2">
{shareLinks.map((s) => (
<a
key={s.key}
href={s.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-border/60 px-2 py-1.5 text-xs text-foreground/80 hover:border-sky-300 hover:text-sky-600 transition-colors"
>
<span className="inline-flex items-center justify-center h-4 w-4 rounded bg-gradient-to-br from-sky-400 to-indigo-500 text-white text-[10px] font-bold">
{s.icon}
</span>
{s.label}
</a>
))}
</div>
</div>
</>
)}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import BotAvatar from "./BotAvatar";
import ActiveNowBadge from "./ActiveNowBadge";
interface SimilarBot {
key: string;
displayName: string;
role: "passerby" | "expert";
avatarUrl: string | null;
avatarPrompt: string;
catchphrase: string;
activeHours: { weekday: string[]; weekend: string[] };
userId: number | null;
expertise: string[];
primaryForums: string[];
}
interface SimilarBotsProps {
currentBotKey: string;
expertise: string[];
primaryForums: string[];
limit?: number;
}
export default function SimilarBots({ currentBotKey, expertise, primaryForums, limit = 6 }: SimilarBotsProps) {
const [bots, setBots] = useState<SimilarBot[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bots?role=expert`)
.then((r) => r.json())
.then((data) => {
const all: SimilarBot[] = data.bots || [];
// 计算相似度:相同 expertise 数量 + 1.5 * 共同板块数量
const scored = all
.filter((b) => b.key !== currentBotKey)
.map((b) => {
const sharedExpertise = (b.expertise || []).filter((e) => expertise.includes(e)).length;
const sharedForums = (b.primaryForums || []).filter((f) => primaryForums.includes(f)).length;
return { bot: b, score: sharedExpertise + sharedForums * 1.5 };
})
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((s) => s.bot);
setBots(scored);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [currentBotKey, expertise, primaryForums, limit]);
if (loading) return null;
if (bots.length === 0) return null;
return (
<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 justify-between mb-4">
<h2 className="text-sm font-semibold flex items-center gap-2">
<span>👯 聊得来的人</span>
<span className="text-[10px] font-normal text-muted-foreground/60">
基于擅长领域和活跃板块
</span>
</h2>
<Link
href="/community/bots"
className="text-[11px] text-muted-foreground/60 hover:text-sky-600"
>
全部居民 →
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{bots.map((bot) => (
<Link
key={bot.key}
href={`/community/bot/${bot.userId || bot.key}`}
className="group flex flex-col items-center text-center p-2.5 rounded-lg hover:bg-muted/30 transition-colors"
>
<div className="mb-1.5 transition-transform group-hover:scale-105">
<BotAvatar
key={bot.key}
displayName={bot.displayName}
avatarPrompt={bot.avatarPrompt}
avatarUrl={bot.avatarUrl}
size="md"
rounded="full"
/>
</div>
<p className="text-xs font-medium text-foreground truncate w-full group-hover:text-sky-700 transition-colors">
{bot.displayName}
</p>
{bot.catchphrase && (
<p className="text-[10px] text-muted-foreground/60 line-clamp-1 italic">
"{bot.catchphrase}"
</p>
)}
<div className="mt-0.5">
<ActiveNowBadge activeHours={bot.activeHours} />
</div>
</Link>
))}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Bell, BellOff, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
interface SubscribeButtonProps {
topicId: number;
initialSubscribed?: boolean;
className?: string;
}
/**
* 话题订阅按钮:点击切换订阅状态
*/
export default function SubscribeButton({
topicId,
initialSubscribed = false,
className,
}: SubscribeButtonProps) {
const { status } = useSession();
const [subscribed, setSubscribed] = useState(initialSubscribed);
const [loading, setLoading] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const handleClick = async () => {
if (status !== "authenticated") {
setShowLogin(true);
return;
}
if (loading) return;
setLoading(true);
const next = !subscribed;
// 乐观更新
setSubscribed(next);
try {
const res = await fetch(`/api/forum/topics/${topicId}/subscription`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscribe: next }),
});
if (!res.ok) throw new Error("failed");
const d = await res.json();
setSubscribed(!!d.subscribed);
} catch {
// 回滚
setSubscribed(!next);
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={handleClick}
disabled={loading}
title={subscribed ? "取消订阅" : "订阅话题,新回复会通知我"}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
subscribed
? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100"
: "border-border/60 bg-white text-muted-foreground hover:border-amber-300 hover:text-amber-600",
className
)}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : subscribed ? (
<Bell className="h-3.5 w-3.5 fill-amber-400 text-amber-500" />
) : (
<BellOff className="h-3.5 w-3.5" />
)}
{subscribed ? "已订阅" : "订阅"}
</button>
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { Hash } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagBadgeProps {
name: string;
slug: string;
size?: "xs" | "sm";
className?: string;
showHash?: boolean;
}
export default function TagBadge({
name,
slug,
size = "xs",
className,
showHash = true,
}: TagBadgeProps) {
return (
<Link
href={`/community/tag/${slug}`}
onClick={(e) => e.stopPropagation()}
className={cn(
"inline-flex items-center gap-0.5 rounded-md border border-border/50 bg-white/80 text-foreground/70 transition-colors hover:border-sky-300 hover:bg-sky-50/60 hover:text-sky-700",
size === "sm" ? "px-2 py-0.5 text-xs" : "px-1.5 py-0.5 text-[10px] font-medium",
className
)}
>
{showHash && <Hash className={size === "sm" ? "h-3 w-3 opacity-60" : "h-2.5 w-2.5 opacity-60"} />}
<span>{name}</span>
</Link>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from "next/link";
import { Hash } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { cn } from "@/lib/utils";
interface TagCloudProps {
className?: string;
limit?: number;
title?: string;
href?: string;
}
export default async function TagCloud({
className,
limit = 30,
title = "热门标签",
href = "/community/tags",
}: TagCloudProps) {
const tags = await prisma.forumTag.findMany({
orderBy: [{ topicCount: "desc" }, { name: "asc" }],
take: limit,
});
if (tags.length === 0) return null;
// 计算权重(用于字号差异)
const max = Math.max(...tags.map((t) => t.topicCount), 1);
const min = Math.min(...tags.map((t) => t.topicCount), 0);
const range = Math.max(max - min, 1);
return (
<div 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 border-b border-border/30 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{href && (
<Link
href={href}
className="text-[11px] text-muted-foreground/60 hover:text-sky-600"
>
查看全部
</Link>
)}
</div>
<div className="px-4 py-3 flex flex-wrap gap-1.5">
{tags.map((tag) => {
const ratio = (tag.topicCount - min) / range; // 0-1
// 字号:xs/sm/base 三档
const sizeClass =
ratio > 0.66
? "text-sm font-semibold"
: ratio > 0.33
? "text-xs font-medium"
: "text-[11px] font-normal";
// 颜色权重
const colorClass =
ratio > 0.66
? "border-sky-300/70 bg-sky-50 text-sky-700 hover:bg-sky-100"
: ratio > 0.33
? "border-border/60 bg-white text-foreground/80 hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50"
: "border-border/40 bg-muted/20 text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/40";
return (
<Link
key={tag.id}
href={`/community/tag/${tag.slug}`}
className={cn(
"inline-flex items-center gap-0.5 rounded-md border px-1.5 py-0.5 transition-colors",
sizeClass,
colorClass
)}
title={`${tag.name} · ${tag.topicCount} 帖`}
>
<Hash className="h-2.5 w-2.5 opacity-60" />
<span>{tag.name}</span>
{tag.topicCount > 0 && (
<span className="text-[9px] opacity-50 tabular-nums ml-0.5">
{tag.topicCount}
</span>
)}
</Link>
);
})}
</div>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { X, Hash, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagSuggestion {
id: number;
name: string;
slug: string;
topicCount: number;
}
interface TagInputProps {
value: string[];
onChange: (next: string[]) => void;
maxTags?: number;
maxLength?: number;
placeholder?: string;
className?: string;
}
export default function TagInput({
value,
onChange,
maxTags = 5,
maxLength = 20,
placeholder = "添加标签(回车确认)",
className,
}: TagInputProps) {
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const [fetching, setFetching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 拉取建议
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const q = input.trim();
if (!q) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
debounceRef.current = setTimeout(async () => {
setFetching(true);
try {
const res = await fetch(`/api/forum/tags?q=${encodeURIComponent(q)}&limit=8`);
if (res.ok) {
const data = await res.json();
const filtered = (data.tags || []).filter(
(t: TagSuggestion) => !value.includes(t.name)
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setActiveIdx(-1);
}
} catch {
// 静默失败
} finally {
setFetching(false);
}
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, value]);
// 点击外部关闭建议
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const addTag = (raw: string) => {
const trimmed = raw.trim().slice(0, maxLength);
if (!trimmed) return;
if (value.includes(trimmed)) return;
if (value.length >= maxTags) return;
onChange([...value, trimmed]);
setInput("");
setSuggestions([]);
setShowSuggestions(false);
setActiveIdx(-1);
};
const removeTag = (tag: string) => {
onChange(value.filter((t) => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (activeIdx >= 0 && suggestions[activeIdx]) {
addTag(suggestions[activeIdx].name);
} else if (input.trim()) {
addTag(input);
}
} else if (e.key === "Backspace" && !input && value.length > 0) {
e.preventDefault();
removeTag(value[value.length - 1]);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, -1));
} else if (e.key === "Escape") {
setShowSuggestions(false);
}
};
const canAddMore = value.length < maxTags;
return (
<div ref={containerRef} className={cn("relative", className)}>
<div
className={cn(
"flex flex-wrap items-center gap-1.5 min-h-10 w-full rounded-lg border border-border/60 bg-white px-2 py-1.5",
"focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors"
)}
>
<Hash className="h-3.5 w-3.5 text-muted-foreground/40 ml-1 shrink-0" />
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-sky-50 border border-sky-200/60 px-1.5 py-0.5 text-xs text-sky-700"
>
<span className="font-medium">{tag}</span>
<button
type="button"
onClick={() => removeTag(tag)}
className="rounded-sm hover:bg-sky-100 p-0.5 -mr-0.5 transition-colors"
aria-label={`移除标签 ${tag}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
{canAddMore && (
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder={value.length === 0 ? placeholder : ""}
maxLength={maxLength}
className="flex-1 min-w-[100px] bg-transparent text-sm focus:outline-none placeholder:text-muted-foreground/40"
/>
)}
{!canAddMore && (
<span className="text-[10px] text-muted-foreground/40 ml-auto pr-1">
已达上限
</span>
)}
</div>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-white shadow-lg">
{fetching && suggestions.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground/50">搜索中...</div>
) : (
<>
<div className="px-3 py-1.5 text-[10px] text-muted-foreground/40 border-b border-border/30">
选择已有标签
</div>
{suggestions.map((s, idx) => (
<button
type="button"
key={s.id}
onClick={() => addTag(s.name)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors",
activeIdx === idx
? "bg-sky-50 text-sky-700"
: "hover:bg-slate-50 text-foreground/80"
)}
>
<span className="inline-flex items-center gap-1.5">
<Hash className="h-3 w-3 text-muted-foreground/40" />
<span className="font-medium">{s.name}</span>
</span>
<span className="text-[10px] text-muted-foreground/40 tabular-nums">
{s.topicCount} 帖
</span>
</button>
))}
{input.trim() && !suggestions.some((s) => s.name === input.trim()) && (
<button
type="button"
onClick={() => addTag(input)}
className={cn(
"flex w-full items-center gap-1.5 border-t border-border/30 px-3 py-1.5 text-left text-xs transition-colors",
"bg-slate-50/50 text-foreground/80 hover:bg-sky-50 hover:text-sky-700"
)}
>
<Plus className="h-3 w-3" />
<span>新建标签:</span>
<span className="font-medium">{input.trim()}</span>
</button>
)}
</>
)}
</div>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface TimeAgoProps {
date: Date | string | null | undefined;
className?: string;
prefix?: string;
}
function calc(date: Date): string {
const now = Date.now();
const t = date.getTime();
const diff = Math.max(0, now - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return "刚刚";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}分钟前`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}小时前`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}天前`;
const mo = Math.floor(day / 30);
if (mo < 12) return `${mo}个月前`;
const yr = Math.floor(day / 365);
return `${yr}年前`;
}
export default function TimeAgo({ date, className, prefix }: TimeAgoProps) {
const [text, setText] = useState(() =>
date ? calc(new Date(date)) : ""
);
useEffect(() => {
if (!date) return;
const d = new Date(date);
setText(calc(d));
const tick = setInterval(() => setText(calc(d)), 60_000);
return () => clearInterval(tick);
}, [date]);
if (!date) return null;
const fullDate = new Date(date).toLocaleString("zh-CN");
return (
<span
className={cn("text-muted-foreground/60 tabular-nums", className)}
title={fullDate}
>
{prefix ? `${prefix} ` : ""}
{text}
</span>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Trophy, ArrowRight, Crown, Medal } from "lucide-react";
import BotAvatar from "./BotAvatar";
import { cn } from "@/lib/utils";
interface Leader {
userId: number;
name: string;
avatarUrl: string | null;
topicCount: number;
postCount: number;
totalLikes: number;
score: number;
}
export default function TopBotsMiniCard({ className }: { className?: string }) {
const [leaders, setLeaders] = useState<Leader[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/bot/leaderboard?days=7&limit=5`)
.then((r) => r.json())
.then((data) => setLeaders(data.leaders || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (!loading && leaders.length === 0) return null;
return (
<div className={cn("rounded-xl border border-border/40 bg-gradient-to-br from-amber-50/40 to-rose-50/40 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">
<Trophy className="h-3.5 w-3.5 text-amber-500" />
<span className="text-[12px] font-semibold text-foreground/80">本周之星</span>
</div>
<Link
href="/community/bots/leaderboard"
className="text-[10px] text-muted-foreground/60 hover:text-amber-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: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5 animate-pulse">
<div className="h-5 w-5 rounded-full bg-muted/40" />
<div className="h-7 w-7 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-12 bg-muted/40 rounded" />
</div>
</div>
))}
</div>
) : (
<div className="space-y-0.5">
{leaders.map((leader, idx) => {
const rank = idx + 1;
const isTop3 = rank <= 3;
const Icon = rank === 1 ? Crown : Medal;
const colorClass = rank === 1 ? "text-amber-500" : rank === 2 ? "text-slate-400" : rank === 3 ? "text-orange-500" : "text-muted-foreground/50";
return (
<Link
key={leader.userId}
href={`/community/bot/${leader.userId}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-all duration-200 hover:bg-white/80 hover:shadow-sm"
>
{isTop3 ? (
<Icon className={cn("h-3.5 w-3.5 shrink-0", colorClass)} />
) : (
<span className="text-[10px] font-bold text-muted-foreground/40 w-3.5 text-center shrink-0">
{rank}
</span>
)}
<BotAvatar
key={`${leader.userId}`}
displayName={leader.name}
avatarPrompt=""
avatarUrl={leader.avatarUrl}
size="sm"
showBadge={false}
/>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium truncate text-foreground/85">
{leader.name}
</p>
<p className="text-[9px] text-muted-foreground/50">
{leader.topicCount} 帖 · {leader.postCount} 回复 · {leader.totalLikes} 赞
</p>
</div>
<span className="text-[10px] font-bold text-amber-600 tabular-nums shrink-0">
{leader.score}
</span>
</Link>
);
})}
</div>
)}
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { Activity, Loader2 } from "lucide-react";
import ActivityHeatmap from "@/components/forum/ActivityHeatmap";
interface ActivityData {
heatmap: Array<{ date: string; count: number; topics: number; posts: number; comments: number }>;
checkIns: Array<{ date: string; streak: number; points: number }>;
summary: {
totalDays: number;
activeDays: number;
activeRate: number;
totalActions: number;
topicsCount: number;
postsCount: number;
commentsCount: number;
};
}
export default function UserActivityCard({ userId }: { userId: number }) {
const [data, setData] = useState<ActivityData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
setLoading(true);
fetch(`/api/user/${userId}/activity`)
.then(r => r.json())
.then(d => {
if (mounted && d.heatmap) setData(d);
})
.catch(() => {})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [userId]);
if (loading) {
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] flex items-center justify-center min-h-[160px]">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
);
}
if (!data) {
return null;
}
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
<Activity className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-semibold">活跃足迹</h3>
<p className="text-xs text-muted-foreground/60">最近 {data.summary.totalDays} 天</p>
</div>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="text-right">
<p className="text-lg font-bold text-foreground">{data.summary.activeDays}</p>
<p className="text-muted-foreground/50">活跃天数</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-foreground">{data.summary.activeRate}%</p>
<p className="text-muted-foreground/50">活跃率</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-foreground">{data.summary.totalActions}</p>
<p className="text-muted-foreground/50">总动作</p>
</div>
</div>
</div>
<ActivityHeatmap data={data.heatmap} />
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-lg bg-violet-50/60 border border-violet-100 p-2">
<p className="text-violet-600 font-semibold">{data.summary.topicsCount}</p>
<p className="text-muted-foreground/60">话题</p>
</div>
<div className="rounded-lg bg-blue-50/60 border border-blue-100 p-2">
<p className="text-blue-600 font-semibold">{data.summary.postsCount}</p>
<p className="text-muted-foreground/60">回复</p>
</div>
<div className="rounded-lg bg-amber-50/60 border border-amber-100 p-2">
<p className="text-amber-600 font-semibold">{data.summary.commentsCount}</p>
<p className="text-muted-foreground/60">评价/评论</p>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { UserLevel } from "@prisma/client";
import { cn } from "@/lib/utils";
interface UserAvatarProps {
name?: string | null;
email?: string | null;
avatarUrl?: string | null;
level?: UserLevel;
size?: "xs" | "sm" | "md" | "lg" | "xl";
isBot?: boolean;
className?: string;
}
const SIZE_MAP: Record<NonNullable<UserAvatarProps["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-12 w-12 text-base",
xl: "h-16 w-16 text-lg",
};
const BOT_GRADIENT = "bg-gradient-to-br from-sky-400 via-violet-500 to-fuchsia-500";
function colorFromId(seed: number | string | null | undefined): string {
const palette = [
"from-rose-400 to-orange-400",
"from-amber-400 to-rose-500",
"from-emerald-400 to-cyan-500",
"from-sky-400 to-indigo-500",
"from-violet-400 to-fuchsia-500",
"from-fuchsia-400 to-pink-500",
"from-teal-400 to-emerald-500",
"from-indigo-400 to-purple-500",
];
if (!seed) return palette[0];
const str = String(seed);
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
}
return palette[hash % palette.length];
}
export default function UserAvatar({
name,
email,
avatarUrl,
level,
size = "md",
isBot = false,
className,
}: UserAvatarProps) {
const displayName = name || email?.split("@")[0] || "U";
const initial = displayName.charAt(0).toUpperCase();
const gradient = colorFromId(name || email || initial);
if (avatarUrl) {
return (
<div className={cn("relative shrink-0", className)}>
<img
src={avatarUrl}
alt={displayName}
className={cn(
"rounded-full object-cover border-2 border-white shadow-sm",
SIZE_MAP[size]
)}
/>
{isBot && (
<span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[8px] flex items-center justify-center">
<span className="leading-none">🤖</span>
</span>
)}
</div>
);
}
return (
<div className={cn("relative shrink-0", className)}>
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white shadow-sm border-2 border-white bg-gradient-to-br",
isBot ? BOT_GRADIENT : gradient,
SIZE_MAP[size]
)}
>
{isBot ? <span className="leading-none">🤖</span> : initial}
</div>
{level && !isBot && (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 rounded-full bg-white border-2 border-white text-[8px] flex items-center justify-center shadow-sm",
size === "xs" ? "h-3 w-3" : size === "sm" ? "h-4 w-4" : "h-5 w-5"
)}
title={`Level ${String(level).replace("LV", "")}`}
>
{String(level).replace("LV", "")}
</span>
)}
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Trophy, Loader2, Lock, Star } from "lucide-react";
interface Achievement {
key: string;
name: string;
icon: string;
tier: string;
progress: number;
unlockedAt: string | null;
}
const TIER_STYLES: Record<string, { ring: string; bg: string; text: string; label: string }> = {
bronze: { ring: "ring-amber-700/40", bg: "bg-amber-50", text: "text-amber-800", label: "青铜" },
silver: { ring: "ring-slate-400/40", bg: "bg-slate-50", text: "text-slate-700", label: "白银" },
gold: { ring: "ring-yellow-500/50", bg: "bg-yellow-50", text: "text-yellow-800", label: "黄金" },
diamond: { ring: "ring-violet-500/50", bg: "bg-violet-50", text: "text-violet-800", label: "钻石" },
};
export default function UserBadges({ userId, limit = 8 }: { userId: number; limit?: number }) {
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
fetch(`/api/forum/achievements?userId=${userId}`)
.then(r => r.json())
.then(d => {
if (mounted) setAchievements(d.unlocked || []);
})
.catch(() => {})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [userId]);
if (loading) {
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] flex items-center justify-center min-h-[120px]">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
);
}
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-50 text-amber-600">
<Trophy className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-semibold">成就徽章</h3>
<p className="text-xs text-muted-foreground/60">已解锁 {achievements.length} 个</p>
</div>
</div>
{achievements.length > 0 && (
<Link href={`/user/${userId}#achievements`} className="text-xs text-primary hover:underline">
查看全部
</Link>
)}
</div>
{achievements.length === 0 ? (
<div className="text-center py-6">
<Lock className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground/60">尚未解锁任何成就</p>
<p className="text-xs text-muted-foreground/40 mt-1">在社区多多参与互动吧</p>
</div>
) : (
<div className="grid grid-cols-4 gap-2">
{achievements.slice(0, limit).map(a => {
const style = TIER_STYLES[a.tier] || TIER_STYLES.bronze;
return (
<div
key={a.key}
className={`relative rounded-lg ${style.bg} ${style.ring} ring-1 p-2 text-center hover:scale-105 transition-transform cursor-pointer`}
title={a.name}
>
<div className="text-2xl mb-1">{a.icon}</div>
<p className={`text-[10px] font-medium ${style.text} truncate`}>{a.name}</p>
<div className="absolute -top-1 -right-1">
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Link from "next/link";
import { UserLevel } from "@prisma/client";
import { getLevelInfo } from "@/lib/level-config";
import UserAvatar from "./UserAvatar";
import { cn } from "@/lib/utils";
interface UserInfoCardProps {
user: {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
level?: UserLevel;
points?: number;
isBot?: boolean;
bio?: string | null;
};
variant?: "topic" | "post";
className?: string;
}
export default function UserInfoCard({
user,
variant = "post",
className,
}: UserInfoCardProps) {
const points = user.points ?? 0;
const levelInfo = getLevelInfo(points);
const displayName = user.name || user.email?.split("@")[0] || "用户";
const href = user.isBot ? "#" : `/user/${user.id}`;
const card = (
<div
className={cn(
"flex flex-col items-center text-center gap-2 px-2",
variant === "topic" ? "py-3" : "py-4",
className
)}
>
<UserAvatar
name={user.name}
email={user.email}
avatarUrl={user.avatarUrl}
level={user.level}
isBot={user.isBot}
size={variant === "topic" ? "lg" : "md"}
/>
<div className="min-w-0 w-full">
<div
className={cn(
"font-semibold truncate",
variant === "topic" ? "text-sm" : "text-xs"
)}
title={displayName}
>
{displayName}
</div>
{user.isBot ? (
<div className="mt-1 inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-sky-100 to-violet-100 border border-sky-200/50 px-2 py-0.5 text-[9px] font-medium text-sky-700">
<span>🤖</span>
<span>AI 助手</span>
</div>
) : (
<div className="mt-1 flex flex-col items-center gap-1">
<div
className={cn(
"inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[9px] font-semibold",
levelInfo.bgColor,
levelInfo.color,
levelInfo.borderColor
)}
>
<span>{levelInfo.icon}</span>
<span>{levelInfo.label}</span>
</div>
<div className="text-[10px] text-muted-foreground/50 tabular-nums">
{points.toLocaleString()} 分
</div>
</div>
)}
</div>
</div>
);
if (user.isBot) return card;
return (
<Link href={href} className="block hover:opacity-90 transition-opacity">
{card}
</Link>
);
}