全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trophy, Lock, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tier = "bronze" | "silver" | "gold" | "special";
|
||||
|
||||
interface AchievementItem {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
tier: Tier;
|
||||
category: string;
|
||||
target: number;
|
||||
progress: number;
|
||||
unlocked: boolean;
|
||||
unlockedAt: string | null;
|
||||
}
|
||||
|
||||
interface AchievementListProps {
|
||||
userId?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TIER_STYLES: Record<Tier, { ring: string; bg: string; text: string; badge: string }> = {
|
||||
bronze: {
|
||||
ring: "ring-amber-300/60",
|
||||
bg: "from-amber-50 to-orange-50",
|
||||
text: "text-amber-700",
|
||||
badge: "🥉 铜",
|
||||
},
|
||||
silver: {
|
||||
ring: "ring-slate-300/60",
|
||||
bg: "from-slate-50 to-zinc-100",
|
||||
text: "text-slate-700",
|
||||
badge: "🥈 银",
|
||||
},
|
||||
gold: {
|
||||
ring: "ring-yellow-400/60",
|
||||
bg: "from-yellow-50 to-amber-100",
|
||||
text: "text-amber-700",
|
||||
badge: "🥇 金",
|
||||
},
|
||||
special: {
|
||||
ring: "ring-fuchsia-300/60",
|
||||
bg: "from-fuchsia-50 via-violet-50 to-sky-50",
|
||||
text: "text-fuchsia-700",
|
||||
badge: "✨ 稀有",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AchievementList({ userId, className }: AchievementListProps) {
|
||||
const [unlocked, setUnlocked] = useState<AchievementItem[]>([]);
|
||||
const [locked, setLocked] = useState<AchievementItem[]>([]);
|
||||
const [totalUnlocked, setTotalUnlocked] = useState(0);
|
||||
const [totalAll, setTotalAll] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const url = userId ? `/api/forum/achievements?userId=${userId}` : "/api/forum/achievements";
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d) => {
|
||||
setUnlocked(d.unlocked || []);
|
||||
setLocked(d.locked || []);
|
||||
setTotalUnlocked(d.totalUnlocked || 0);
|
||||
setTotalAll(d.totalAll || 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [userId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl border border-border/50 bg-white p-4 text-xs text-muted-foreground/50", className)}>
|
||||
加载勋章中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden", className)}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold">成就勋章</h3>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{totalUnlocked} / {totalAll}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 已解锁 */}
|
||||
{unlocked.length > 0 && (
|
||||
<div className="p-3">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
已获得
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{unlocked.map((a) => {
|
||||
const s = TIER_STYLES[a.tier as Tier] || TIER_STYLES.bronze;
|
||||
return (
|
||||
<div
|
||||
key={a.key}
|
||||
title={`${a.name} · ${a.description}`}
|
||||
className={cn(
|
||||
"group relative aspect-square rounded-xl bg-gradient-to-br ring-1 flex flex-col items-center justify-center p-1.5 cursor-default transition-transform hover:scale-105",
|
||||
s.bg,
|
||||
s.ring
|
||||
)}
|
||||
>
|
||||
<span className="text-2xl leading-none mb-0.5">{a.icon}</span>
|
||||
<span className={cn("text-[10px] font-semibold text-center leading-tight truncate w-full", s.text)}>
|
||||
{a.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未解锁(按进度排序) */}
|
||||
{locked.length > 0 && (
|
||||
<div className="border-t border-border/30 p-3">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground/60 mb-2 flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
待解锁
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{locked.slice(0, 8).map((a) => {
|
||||
const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
|
||||
return (
|
||||
<div
|
||||
key={a.key}
|
||||
title={`${a.name} · 进度 ${a.progress}/${a.target}`}
|
||||
className="relative aspect-square rounded-xl bg-muted/30 border border-border/40 flex flex-col items-center justify-center p-1.5 opacity-80 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<span className="text-2xl leading-none mb-0.5 grayscale">{a.icon}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70 text-center leading-tight truncate w-full">
|
||||
{a.name}
|
||||
</span>
|
||||
<div className="absolute bottom-1 left-1.5 right-1.5">
|
||||
<div className="h-0.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-400 to-orange-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/50 text-center mt-0.5 tabular-nums">
|
||||
{a.progress}/{a.target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalUnlocked === 0 && (
|
||||
<div className="p-6 text-center text-xs text-muted-foreground/50">
|
||||
还没有获得勋章,去
|
||||
<a href="/community" className="text-sky-600 hover:underline mx-1">参与讨论</a>
|
||||
吧
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bot as BotIcon, ArrowRight, Sparkles } from "lucide-react";
|
||||
import BotAvatar from "./BotAvatar";
|
||||
import ActiveNowBadge from "./ActiveNowBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Bot {
|
||||
key: string;
|
||||
displayName: string;
|
||||
role: "passerby" | "expert";
|
||||
avatarUrl: string | null;
|
||||
avatarPrompt: string;
|
||||
activityLevel: string;
|
||||
activeHours: { weekday: string[]; weekend: string[] };
|
||||
userId: number | null;
|
||||
stats: { topics: number; posts: number };
|
||||
}
|
||||
|
||||
export default function ActiveBotsCard({ limit = 4, className }: { limit?: number; className?: string }) {
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/bots?sort=active`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
// 只取"专家"角色中活跃的 Bot,并优先展示当前活跃中的
|
||||
const experts: Bot[] = (data.bots || []).filter((b: Bot) => b.role === "expert");
|
||||
// 简单排序:客户端无法判断 activeHours,所以按互动数降序即可
|
||||
setBots(experts.slice(0, limit));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border border-border/40 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.04)]", className)}>
|
||||
<div className="flex items-center justify-between px-1 py-1 mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
||||
<span className="text-[12px] font-semibold text-foreground/80">活跃居民</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/community/bots"
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
|
||||
>
|
||||
全部 <ArrowRight className="h-2.5 w-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: limit }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 animate-pulse">
|
||||
<div className="h-8 w-8 rounded-full bg-muted/40" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-16 bg-muted/40 rounded mb-1" />
|
||||
<div className="h-2.5 w-10 bg-muted/40 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : bots.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<BotIcon className="h-6 w-6 text-muted-foreground/30 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground/50">还没有活跃的居民</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{bots.map((bot) => {
|
||||
const totalActivity = bot.stats.topics + bot.stats.posts;
|
||||
return (
|
||||
<Link
|
||||
key={bot.key}
|
||||
href={`/community/bot/${bot.userId || bot.key}`}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
>
|
||||
<BotAvatar
|
||||
key={bot.key}
|
||||
displayName={bot.displayName}
|
||||
avatarPrompt={bot.avatarPrompt}
|
||||
avatarUrl={bot.avatarUrl}
|
||||
size="sm"
|
||||
showBadge={false}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[11px] font-medium truncate text-foreground/85">
|
||||
{bot.displayName}
|
||||
</p>
|
||||
<ActiveNowBadge activeHours={bot.activeHours} />
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/50">
|
||||
{totalActivity} 条互动
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActiveNowBadgeProps {
|
||||
activeHours: { weekday: string[]; weekend: string[] } | null | undefined;
|
||||
className?: string;
|
||||
showNextLabel?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveState {
|
||||
active: boolean;
|
||||
nextLabel: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前时间判断 Bot 是否处于活跃时段
|
||||
* 注意:依赖客户端时钟,会在挂载后立即计算并随后每分钟刷新
|
||||
*/
|
||||
function computeState(
|
||||
activeHours: ActiveNowBadgeProps["activeHours"],
|
||||
now: Date
|
||||
): ActiveState {
|
||||
if (!activeHours) return { active: false, nextLabel: null };
|
||||
|
||||
const day = now.getDay();
|
||||
const isWeekend = day === 0 || day === 6;
|
||||
const ranges = isWeekend ? activeHours.weekend : activeHours.weekday;
|
||||
if (!ranges || ranges.length === 0) return { active: false, nextLabel: null };
|
||||
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range === "全天" || range === "全天活跃") return { active: true, nextLabel: null };
|
||||
const m = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
||||
if (!m) continue;
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||||
if (start === end) continue;
|
||||
if (start < end) {
|
||||
if (currentMinutes >= start && currentMinutes < end) {
|
||||
return { active: true, nextLabel: null };
|
||||
}
|
||||
} else {
|
||||
if (currentMinutes >= start || currentMinutes < end) {
|
||||
return { active: true, nextLabel: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不在活跃时段,计算下次活跃时间
|
||||
const tomorrowRanges = isWeekend ? activeHours.weekday : activeHours.weekend;
|
||||
const findNext = (rs: string[], label: string) => {
|
||||
for (const r of rs) {
|
||||
if (r === "全天" || r === "全天活跃") return `${label}全天`;
|
||||
const m = r.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
||||
if (!m) continue;
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
if (label === "今天" && start <= currentMinutes) continue;
|
||||
return `${label} ${m[1]}:${m[2]}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
active: false,
|
||||
nextLabel: findNext(ranges, "今天") || findNext(tomorrowRanges, "明天"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ActiveNowBadge({ activeHours, className, showNextLabel = false }: ActiveNowBadgeProps) {
|
||||
const [state, setState] = useState<ActiveState>({ active: false, nextLabel: null });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const tick = () => setState(computeState(activeHours, new Date()));
|
||||
tick();
|
||||
// 每分钟刷新一次
|
||||
const id = setInterval(tick, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [activeHours]);
|
||||
|
||||
if (!mounted) {
|
||||
// 避免 hydration mismatch,先渲染一个不可见占位
|
||||
return <span className={cn("inline-block h-4 w-12", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200/60",
|
||||
className
|
||||
)}
|
||||
title="现在正在活跃"
|
||||
>
|
||||
<Circle className="h-1.5 w-1.5 fill-emerald-500 text-emerald-500 animate-pulse" />
|
||||
活跃中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showNextLabel && state.nextLabel) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground border border-border/40",
|
||||
className
|
||||
)}
|
||||
title={`下次活跃:${state.nextLabel}`}
|
||||
>
|
||||
{state.nextLabel} 上线
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ActivityHeatmapProps {
|
||||
data: Array<{ date: string; count: number; topics: number; posts: number; comments: number }>;
|
||||
weeks?: number;
|
||||
}
|
||||
|
||||
export default function ActivityHeatmap({ data, weeks = 13 }: ActivityHeatmapProps) {
|
||||
const grid = useMemo(() => {
|
||||
if (data.length === 0) return { cells: [], months: [] as Array<{ col: number; label: string }> };
|
||||
const totalDays = weeks * 7;
|
||||
const tail = data.slice(-totalDays);
|
||||
const cells: Array<{ date: string; count: number; col: number; row: number }> = [];
|
||||
let startCol = 0;
|
||||
const months: Array<{ col: number; label: string }> = [];
|
||||
let lastMonth = -1;
|
||||
tail.forEach((d, idx) => {
|
||||
const date = new Date(d.date);
|
||||
const col = Math.floor(idx / 7);
|
||||
const row = date.getDay();
|
||||
cells.push({ date: d.date, count: d.count, col, row });
|
||||
const m = date.getMonth();
|
||||
if (m !== lastMonth) {
|
||||
months.push({ col, label: `${m + 1}月` });
|
||||
lastMonth = m;
|
||||
startCol = col;
|
||||
}
|
||||
});
|
||||
return { cells, months };
|
||||
}, [data, weeks]);
|
||||
|
||||
const max = useMemo(() => Math.max(1, ...grid.cells.map(c => c.count)), [grid.cells]);
|
||||
|
||||
const colorFor = (count: number) => {
|
||||
if (count === 0) return "bg-slate-100";
|
||||
const ratio = count / max;
|
||||
if (ratio < 0.2) return "bg-emerald-100";
|
||||
if (ratio < 0.4) return "bg-emerald-200";
|
||||
if (ratio < 0.7) return "bg-emerald-400";
|
||||
return "bg-emerald-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-flex flex-col gap-1 min-w-full">
|
||||
<div className="flex gap-1 pl-6 mb-1">
|
||||
{Array.from({ length: weeks }).map((_, col) => {
|
||||
const month = grid.months.find(m => m.col === col);
|
||||
return (
|
||||
<div key={col} className="w-3.5 text-[10px] text-muted-foreground/60">
|
||||
{month ? month.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1 pr-1.5 text-[10px] text-muted-foreground/50 leading-[14px]">
|
||||
<span style={{ height: 14 }}></span>
|
||||
<span style={{ height: 14 }}>一</span>
|
||||
<span style={{ height: 14 }}></span>
|
||||
<span style={{ height: 14 }}>三</span>
|
||||
<span style={{ height: 14 }}></span>
|
||||
<span style={{ height: 14 }}>五</span>
|
||||
<span style={{ height: 14 }}></span>
|
||||
</div>
|
||||
{Array.from({ length: weeks }).map((_, col) => (
|
||||
<div key={col} className="flex flex-col gap-1">
|
||||
{Array.from({ length: 7 }).map((_, row) => {
|
||||
const cell = grid.cells.find(c => c.col === col && c.row === row);
|
||||
if (!cell) {
|
||||
return <div key={row} className="w-3.5 h-3.5 rounded-sm bg-slate-50/40" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={row}
|
||||
className={`w-3.5 h-3.5 rounded-sm ${colorFor(cell.count)} hover:ring-1 hover:ring-primary/40 transition-all`}
|
||||
title={`${cell.date}:${cell.count} 次活动`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] text-muted-foreground/60 pl-6">
|
||||
<span>少</span>
|
||||
<div className="w-3 h-3 rounded-sm bg-slate-100" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-100" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-200" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-400" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-600" />
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Check, CheckCircle2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LoginModal } from "@/components/common/LoginModal";
|
||||
|
||||
interface BestAnswerButtonProps {
|
||||
topicId: number;
|
||||
postId: number;
|
||||
postUserId: number;
|
||||
initialAccepted: boolean;
|
||||
isTopicAuthor: boolean;
|
||||
isOwnPost: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function BestAnswerButton({
|
||||
topicId,
|
||||
postId,
|
||||
postUserId,
|
||||
initialAccepted,
|
||||
isTopicAuthor,
|
||||
isOwnPost,
|
||||
className,
|
||||
}: BestAnswerButtonProps) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [accepted, setAccepted] = useState(initialAccepted);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
// 只能在以下情况显示按钮:
|
||||
// 1. 当前用户是话题楼主 且 回复作者不是自己
|
||||
const canAccept = isTopicAuthor && !isOwnPost;
|
||||
// 如果是最佳答案,但当前用户不是楼主,仅显示徽章
|
||||
if (accepted && !canAccept) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200/60 px-2.5 h-7 text-[11px] font-semibold text-emerald-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span>最佳答案</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccept) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!session?.user?.id) {
|
||||
setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (pending) return;
|
||||
|
||||
const next = !accepted;
|
||||
setAccepted(next);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/forum/topics/${topicId}/posts/${postId}/answer`,
|
||||
{ method: next ? "POST" : "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
setAccepted(!next);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.error("采纳失败", data);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setAccepted(!next);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 h-7 text-[11px] font-semibold transition-all",
|
||||
accepted
|
||||
? "bg-emerald-50 border-emerald-200/60 text-emerald-700 hover:bg-emerald-100"
|
||||
: "bg-white border-border/60 text-muted-foreground hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50/50",
|
||||
className
|
||||
)}
|
||||
title={accepted ? "取消采纳" : "采纳为最佳答案(+15 积分)"}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 transition-all",
|
||||
accepted && "fill-emerald-500 text-emerald-500"
|
||||
)}
|
||||
/>
|
||||
<span>{accepted ? "已采纳" : "采纳"}</span>
|
||||
</button>
|
||||
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bookmark, BookmarkCheck } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LoginModal } from "@/components/common/LoginModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BookmarkButtonProps {
|
||||
topicId: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function BookmarkButton({
|
||||
topicId,
|
||||
size = "sm",
|
||||
className,
|
||||
}: BookmarkButtonProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [bookmarked, setBookmarked] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`);
|
||||
if (res.ok && !cancelled) {
|
||||
const data = await res.json();
|
||||
setBookmarked(!!data.bookmarked);
|
||||
}
|
||||
} catch {
|
||||
// 静默
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status, topicId]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (status !== "authenticated") {
|
||||
setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
const next = !bookmarked;
|
||||
// 乐观更新
|
||||
setBookmarked(next);
|
||||
try {
|
||||
const res = await fetch(`/api/forum/topics/${topicId}/bookmark`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 回滚
|
||||
setBookmarked(!next);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setBookmarked(!!data.bookmarked);
|
||||
if (data.bookmarked) {
|
||||
// 提示用户:可到收藏夹查看
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setBookmarked(!next);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sizeClasses = size === "sm" ? "h-7 px-2.5 text-xs gap-1" : "h-8 px-3 text-sm gap-1.5";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={pending || !loaded}
|
||||
title={bookmarked ? "取消收藏" : "收藏话题"}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border transition-all font-medium",
|
||||
sizeClasses,
|
||||
bookmarked
|
||||
? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
||||
: "border-border/60 bg-white text-muted-foreground hover:border-amber-200 hover:text-amber-700 hover:bg-amber-50/50",
|
||||
(pending || !loaded) && "opacity-70",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{bookmarked ? (
|
||||
<BookmarkCheck className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
) : (
|
||||
<Bookmark className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
)}
|
||||
<span>{bookmarked ? "已收藏" : "收藏"}</span>
|
||||
</button>
|
||||
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getBotAvatarUrl, getBotColorScheme } from "@/lib/bot-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BotAvatarProps {
|
||||
key: string;
|
||||
displayName: string;
|
||||
avatarPrompt?: string;
|
||||
avatarUrl?: string | null;
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
className?: string;
|
||||
showBadge?: boolean;
|
||||
rounded?: "full" | "xl" | "2xl";
|
||||
}
|
||||
|
||||
const SIZE_MAP: Record<NonNullable<BotAvatarProps["size"]>, string> = {
|
||||
xs: "h-6 w-6 text-[10px]",
|
||||
sm: "h-8 w-8 text-xs",
|
||||
md: "h-10 w-10 text-sm",
|
||||
lg: "h-14 w-14 text-base",
|
||||
xl: "h-20 w-20 text-xl",
|
||||
"2xl": "h-28 w-28 text-3xl",
|
||||
};
|
||||
|
||||
export default function BotAvatar({
|
||||
key,
|
||||
displayName,
|
||||
avatarPrompt,
|
||||
avatarUrl,
|
||||
size = "md",
|
||||
className,
|
||||
showBadge = true,
|
||||
rounded = "full",
|
||||
}: BotAvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const scheme = getBotColorScheme(key);
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
// 优先使用用户已设置的 avatarUrl,否则使用动态生成的 URL
|
||||
const finalAvatarUrl = !imgError
|
||||
? avatarUrl || getBotAvatarUrl(avatarPrompt || "", key, "square_hd")
|
||||
: null;
|
||||
|
||||
const radiusClass =
|
||||
rounded === "full" ? "rounded-full" : rounded === "xl" ? "rounded-xl" : "rounded-2xl";
|
||||
|
||||
if (finalAvatarUrl) {
|
||||
return (
|
||||
<div className={cn("relative shrink-0", className)}>
|
||||
<img
|
||||
src={finalAvatarUrl}
|
||||
alt={displayName}
|
||||
onError={() => setImgError(true)}
|
||||
className={cn(
|
||||
radiusClass,
|
||||
"object-cover border-2 border-white shadow-sm bg-gradient-to-br",
|
||||
scheme.from,
|
||||
scheme.to,
|
||||
SIZE_MAP[size]
|
||||
)}
|
||||
/>
|
||||
{showBadge && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
|
||||
<span className="leading-none">🤖</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative shrink-0", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
radiusClass,
|
||||
"flex items-center justify-center font-bold text-white shadow-sm border-2 border-white bg-gradient-to-br",
|
||||
scheme.from,
|
||||
scheme.to,
|
||||
SIZE_MAP[size]
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
{showBadge && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-gradient-to-br from-sky-500 to-violet-500 border-2 border-white text-[9px] flex items-center justify-center shadow-sm">
|
||||
<span className="leading-none">🤖</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Bell, BellOff, EyeOff, Eye } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BotPreferenceProps {
|
||||
botUserId: number;
|
||||
botKey?: string;
|
||||
botName: string;
|
||||
className?: string;
|
||||
variant?: "default" | "compact";
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "bot-preferences";
|
||||
|
||||
interface BotPref {
|
||||
muted: boolean;
|
||||
followedAt: number | null;
|
||||
}
|
||||
|
||||
function readPrefs(): Record<string, BotPref> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePrefs(prefs: Record<string, BotPref>) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
export function useBotPreferences() {
|
||||
const [prefs, setPrefs] = useState<Record<string, BotPref>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setPrefs(readPrefs());
|
||||
}, []);
|
||||
|
||||
const getPref = (botKey: string): BotPref => {
|
||||
return prefs[botKey] || { muted: false, followedAt: null };
|
||||
};
|
||||
|
||||
const toggleMute = (botKey: string) => {
|
||||
setPrefs((prev) => {
|
||||
const current = prev[botKey] || { muted: false, followedAt: null };
|
||||
const next = { ...prev, [botKey]: { ...current, muted: !current.muted } };
|
||||
writePrefs(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFollow = (botKey: string) => {
|
||||
setPrefs((prev) => {
|
||||
const current = prev[botKey] || { muted: false, followedAt: null };
|
||||
const next = {
|
||||
...prev,
|
||||
[botKey]: {
|
||||
...current,
|
||||
followedAt: current.followedAt ? null : Date.now(),
|
||||
},
|
||||
};
|
||||
writePrefs(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return { prefs, getPref, toggleMute, toggleFollow };
|
||||
}
|
||||
|
||||
export default function BotPreferenceButtons({
|
||||
botUserId,
|
||||
botKey,
|
||||
botName,
|
||||
className,
|
||||
variant = "default",
|
||||
}: BotPreferenceProps) {
|
||||
const { data: session } = useSession();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const key = botKey || `id_${botUserId}`;
|
||||
const { getPref, toggleMute, toggleFollow } = useBotPreferences();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const pref = getPref(key);
|
||||
const isFollowing = !!pref.followedAt;
|
||||
const isMuted = !!pref.muted;
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!session?.user) return null;
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<button
|
||||
onClick={() => toggleFollow(key)}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
|
||||
isFollowing
|
||||
? "bg-sky-50 text-sky-700 border-sky-200/60"
|
||||
: "bg-white text-muted-foreground border-border/60 hover:border-sky-300 hover:text-sky-700"
|
||||
)}
|
||||
title={isFollowing ? `已关注 ${botName}` : `关注 ${botName}`}
|
||||
>
|
||||
{isFollowing ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
|
||||
{isFollowing ? "已关注" : "关注"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMute(key)}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-[10px] font-medium transition-colors border",
|
||||
isMuted
|
||||
? "bg-rose-50 text-rose-700 border-rose-200/60"
|
||||
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
|
||||
)}
|
||||
title={isMuted ? `已屏蔽 ${botName} 的回复` : `屏蔽 ${botName} 的回复`}
|
||||
>
|
||||
{isMuted ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
{isMuted ? "已屏蔽" : "屏蔽"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
||||
<button
|
||||
onClick={() => toggleFollow(key)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
|
||||
isFollowing
|
||||
? "bg-sky-50 text-sky-700 border-sky-200/60 hover:bg-sky-100"
|
||||
: "bg-white text-foreground/80 border-border/60 hover:border-sky-300 hover:text-sky-700"
|
||||
)}
|
||||
>
|
||||
{isFollowing ? <Bell className="h-3.5 w-3.5 fill-sky-500" /> : <BellOff className="h-3.5 w-3.5" />}
|
||||
{isFollowing ? "已关注" : "关注 TA"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMute(key)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all border inline-flex items-center gap-1.5",
|
||||
isMuted
|
||||
? "bg-rose-50 text-rose-700 border-rose-200/60 hover:bg-rose-100"
|
||||
: "bg-white text-muted-foreground border-border/60 hover:border-rose-300 hover:text-rose-700"
|
||||
)}
|
||||
>
|
||||
{isMuted ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{isMuted ? "已屏蔽回复" : "屏蔽回复"}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
仅保存在本设备
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Calendar, Flame, Sparkles, Trophy, Gift } from "lucide-react";
|
||||
import { LoginModal } from "@/components/common/LoginModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CheckInDay {
|
||||
date: string;
|
||||
checked: boolean;
|
||||
streak: number;
|
||||
pointsEarned: number;
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
interface CheckInState {
|
||||
checkedIn: boolean;
|
||||
todayPoints: number;
|
||||
currentStreak: number;
|
||||
totalCheckIns: number;
|
||||
nextStreak: number;
|
||||
nextPoints: number;
|
||||
calendar: CheckInDay[];
|
||||
}
|
||||
|
||||
const REWARDS = [
|
||||
{ day: 1, points: 5, label: "新启程" },
|
||||
{ day: 2, points: 6, label: "坚持" },
|
||||
{ day: 3, points: 7, label: "习惯" },
|
||||
{ day: 4, points: 8, label: "深耕" },
|
||||
{ day: 5, points: 9, label: "进阶" },
|
||||
{ day: 6, points: 10, label: "常驻" },
|
||||
{ day: 7, points: 15, label: "周满贯" },
|
||||
];
|
||||
|
||||
export default function CheckInCard() {
|
||||
const { status } = useSession();
|
||||
const [data, setData] = useState<CheckInState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
if (status !== "authenticated") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/forum/checkin");
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setData(d);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (status !== "authenticated") {
|
||||
setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (submitting || data?.checkedIn) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/forum/checkin", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d.error || "签到失败");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
await fetchState();
|
||||
}
|
||||
} catch {
|
||||
setError("网络错误");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status !== "authenticated" && !loading) {
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold">每日签到</h3>
|
||||
</div>
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground/60 mb-3">登录后开启你的连续签到之旅</p>
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white text-sm py-2 hover:from-amber-600 hover:to-orange-600 transition-colors shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
立即登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold">每日签到</h3>
|
||||
</div>
|
||||
<div className="p-4 text-xs text-muted-foreground/40">加载中…</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const weekday = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold">每日签到</h3>
|
||||
</div>
|
||||
{data.currentStreak > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200/60 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
|
||||
<Flame className="h-2.5 w-2.5" />
|
||||
连续 {data.currentStreak} 天
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 近 7 天日历 */}
|
||||
<div className="grid grid-cols-7 gap-1.5 mb-4">
|
||||
{data.calendar.map((d) => {
|
||||
const day = new Date(d.date);
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-lg py-2 text-[10px] font-medium transition-colors",
|
||||
d.checked
|
||||
? "bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700 border border-amber-200/60"
|
||||
: d.isToday
|
||||
? "border-2 border-dashed border-amber-300 text-amber-600"
|
||||
: "bg-muted/30 text-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
<span className="text-[9px]">周{weekday[day.getDay()]}</span>
|
||||
<span className={cn("text-xs", d.checked && "font-bold")}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{d.checked && <span className="text-[9px]">+{d.pointsEarned}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 签到按钮 */}
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={submitting || data.checkedIn}
|
||||
className={cn(
|
||||
"w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-sm py-2.5 font-semibold transition-all",
|
||||
data.checkedIn
|
||||
? "bg-gradient-to-r from-emerald-400 to-teal-400 text-white shadow-sm cursor-default"
|
||||
: "bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 shadow-sm shadow-amber-500/20 active:scale-95"
|
||||
)}
|
||||
>
|
||||
{data.checkedIn ? (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
今日已签到 · +{data.todayPoints} 积分
|
||||
</>
|
||||
) : submitting ? (
|
||||
"签到中…"
|
||||
) : (
|
||||
<>
|
||||
<Gift className="h-3.5 w-3.5" />
|
||||
签到领 {data.nextPoints} 积分
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 rounded-lg bg-rose-50 border border-rose-200/60 px-2 py-1.5 text-[10px] text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 累计统计 */}
|
||||
<div className="mt-3 flex items-center justify-around text-[10px] text-muted-foreground/60">
|
||||
<div className="text-center">
|
||||
<div className="text-base font-bold text-foreground/80 tabular-nums">
|
||||
{data.totalCheckIns}
|
||||
</div>
|
||||
<div>累计签到</div>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border/40" />
|
||||
<div className="text-center">
|
||||
<div className="text-base font-bold text-amber-600 tabular-nums">
|
||||
{data.currentStreak}
|
||||
</div>
|
||||
<div>连续天数</div>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border/40" />
|
||||
<div className="text-center">
|
||||
<div className="text-base font-bold text-orange-600 tabular-nums">
|
||||
{data.nextPoints}
|
||||
</div>
|
||||
<div>明日奖励</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 奖励阶梯预览 */}
|
||||
<div className="border-t border-border/30 px-4 py-3 bg-gradient-to-b from-amber-50/30 to-transparent">
|
||||
<div className="text-[10px] font-semibold text-amber-700/80 mb-2 flex items-center gap-1">
|
||||
<Trophy className="h-3 w-3" />
|
||||
7 天连签奖励
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{REWARDS.map((r) => {
|
||||
const reached = data.currentStreak >= r.day;
|
||||
const isToday = data.currentStreak + 1 === r.day && !data.checkedIn;
|
||||
return (
|
||||
<div
|
||||
key={r.day}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md py-1 text-[9px]",
|
||||
reached
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: isToday
|
||||
? "bg-orange-100 text-orange-700 ring-1 ring-orange-300"
|
||||
: "bg-muted/30 text-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-[10px]">{r.day}</span>
|
||||
<span>+{r.points}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Heart, Sparkles, ShieldCheck, X, BookOpen, Lightbulb } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RULES: Array<{ icon: string; title: string; desc: string }> = [
|
||||
{
|
||||
icon: "💡",
|
||||
title: "分享原创、注重质量",
|
||||
desc: "AI 工具使用心得、行业实战经验、踩坑记录最受欢迎",
|
||||
},
|
||||
{
|
||||
icon: "🤝",
|
||||
title: "友善沟通、文明讨论",
|
||||
desc: "观点可以不同,但请保持尊重,禁止人身攻击与谩骂",
|
||||
},
|
||||
{
|
||||
icon: "🚫",
|
||||
title: "禁止广告与引流",
|
||||
desc: "不发布垃圾广告、不借话题引流到外部群组或站点",
|
||||
},
|
||||
{
|
||||
icon: "📚",
|
||||
title: "善用标签、正确归类",
|
||||
desc: "为话题打上准确标签,便于其他用户检索与发现",
|
||||
},
|
||||
{
|
||||
icon: "✨",
|
||||
title: "善用 @ 提及、引用回复",
|
||||
desc: "通过 @ 主动联系他人,通过引用回复延续讨论",
|
||||
},
|
||||
{
|
||||
icon: "🛡️",
|
||||
title: "违规内容会被处理",
|
||||
desc: "重复/违规内容会被隐藏或删除,恶意用户将被封禁",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityGuidelines() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-sky-500" />
|
||||
<h3 className="text-sm font-semibold">社区公约</h3>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/40">
|
||||
共同维护
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-1.5">
|
||||
{(expanded ? RULES : RULES.slice(0, 3)).map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded-lg p-2 hover:bg-sky-50/30 transition-colors"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{r.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] font-medium text-foreground/85">
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="text-[10.5px] text-muted-foreground/60 leading-relaxed mt-0.5">
|
||||
{r.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{RULES.length > 3 && (
|
||||
<div className="border-t border-border/30 px-4 py-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-[11px] text-sky-600 hover:underline"
|
||||
>
|
||||
{expanded ? "收起" : `展开剩余 ${RULES.length - 3} 条`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border/30 px-4 py-2.5 bg-gradient-to-r from-amber-50/40 to-orange-50/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-amber-700/80">
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
遇到违规?点话题右下「举报」即可
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Bot as BotIcon, Sparkles } from "lucide-react";
|
||||
import BotAvatar from "./BotAvatar";
|
||||
import ActiveNowBadge from "./ActiveNowBadge";
|
||||
|
||||
interface Bot {
|
||||
key: string;
|
||||
displayName: string;
|
||||
role: "passerby" | "expert";
|
||||
avatarUrl: string | null;
|
||||
avatarPrompt: string;
|
||||
identity: string;
|
||||
catchphrase: string;
|
||||
activeHours: { weekday: string[]; weekend: string[] };
|
||||
userId: number | null;
|
||||
stats: { topics: number; posts: number };
|
||||
}
|
||||
|
||||
export default function FeaturedBots({ limit = 6 }: { limit?: number }) {
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/bots?role=expert&sort=active`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setBots((data.bots || []).slice(0, limit));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border/30 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">社区居民</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse text-center">
|
||||
<div className="h-14 w-14 rounded-full bg-muted/40 mx-auto mb-2" />
|
||||
<div className="h-3 w-16 bg-muted/40 rounded mx-auto mb-1" />
|
||||
<div className="h-2.5 w-20 bg-muted/40 rounded mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (bots.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/50 bg-gradient-to-br from-white via-violet-50/30 to-sky-50/30 shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-violet-200/30 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||
<h2 className="text-sm font-semibold text-foreground">认识一下社区的居民们</h2>
|
||||
<span className="text-[10px] text-muted-foreground/40">
|
||||
{bots.length} 位活跃专家
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/community/bots"
|
||||
className="text-[11px] text-muted-foreground/60 hover:text-sky-600 inline-flex items-center gap-0.5"
|
||||
>
|
||||
查看全部 <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 p-5">
|
||||
{bots.map((bot) => (
|
||||
<Link
|
||||
key={bot.key}
|
||||
href={`/community/bot/${bot.userId || bot.key}`}
|
||||
className="group flex flex-col items-center text-center p-3 rounded-xl hover:bg-white/60 transition-colors"
|
||||
>
|
||||
<div className="mb-2 transition-transform group-hover:scale-105">
|
||||
<BotAvatar
|
||||
key={bot.key}
|
||||
displayName={bot.displayName}
|
||||
avatarPrompt={bot.avatarPrompt}
|
||||
avatarUrl={bot.avatarUrl}
|
||||
size="lg"
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-foreground truncate w-full group-hover:text-sky-700 transition-colors">
|
||||
{bot.displayName}
|
||||
</p>
|
||||
{bot.catchphrase && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground/70 line-clamp-1 italic">
|
||||
"{bot.catchphrase}"
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center justify-center gap-1">
|
||||
<ActiveNowBadge activeHours={bot.activeHours} />
|
||||
<span className="text-[9px] text-muted-foreground/50 tabular-nums">
|
||||
{bot.stats.topics + bot.stats.posts} 互动
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Bell, BellOff, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LoginModal } from "@/components/common/LoginModal";
|
||||
|
||||
interface FollowButtonProps {
|
||||
categoryId: number;
|
||||
initialFollowed: boolean;
|
||||
followerCount: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FollowButton({
|
||||
categoryId,
|
||||
initialFollowed,
|
||||
followerCount,
|
||||
className,
|
||||
}: FollowButtonProps) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [followed, setFollowed] = useState(initialFollowed);
|
||||
const [count, setCount] = useState(followerCount);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [justChanged, setJustChanged] = useState(false);
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!session?.user?.id) {
|
||||
setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (pending) return;
|
||||
|
||||
const next = !followed;
|
||||
setFollowed(next);
|
||||
setCount(next ? count + 1 : Math.max(0, count - 1));
|
||||
setJustChanged(true);
|
||||
setTimeout(() => setJustChanged(false), 1500);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/forum/categories/${categoryId}/follow`,
|
||||
{ method: next ? "POST" : "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
setFollowed(!next);
|
||||
setCount(count);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (typeof data.followerCount === "number") setCount(data.followerCount);
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setFollowed(!next);
|
||||
setCount(count);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 h-8 text-xs font-medium transition-all border",
|
||||
followed
|
||||
? "bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100"
|
||||
: "bg-white border-border/60 text-muted-foreground hover:border-sky-200 hover:text-sky-700 hover:bg-sky-50/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{followed ? (
|
||||
<>
|
||||
{justChanged ? <Check className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5 fill-sky-500 text-sky-500" />}
|
||||
<span>已关注</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellOff className="h-3.5 w-3.5" />
|
||||
<span>关注</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-1 text-[10px] tabular-nums opacity-70 border-l border-current/20 pl-1.5">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
<LoginModal open={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { UserLevel } from "@prisma/client";
|
||||
import { Lock, Pin, Sparkles, MessageSquare } from "lucide-react";
|
||||
import UserInfoCard from "./UserInfoCard";
|
||||
import TimeAgo from "./TimeAgo";
|
||||
import PostActionBar from "./PostActionBar";
|
||||
import TagBadge from "./TagBadge";
|
||||
import MentionContent from "./MentionContent";
|
||||
import ReplyQuote from "./ReplyQuote";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ForumPostUser {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
level?: UserLevel;
|
||||
points?: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface ForumPostTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ForumPostData {
|
||||
id: number;
|
||||
content: string;
|
||||
isAnswer: boolean;
|
||||
likeCount: number;
|
||||
createdAt: Date | string;
|
||||
user: ForumPostUser;
|
||||
/** 被引用的回复 ID(用于嵌套引用展示) */
|
||||
replyToId?: number | null;
|
||||
replyToUserName?: string | null;
|
||||
replyToContent?: string | null;
|
||||
}
|
||||
|
||||
interface ForumPostBlockProps {
|
||||
post: ForumPostData;
|
||||
isTopicAuthor: boolean;
|
||||
currentUserId?: number | null;
|
||||
isFirst?: boolean;
|
||||
isTopicPost?: boolean;
|
||||
topicId: number;
|
||||
topicAuthorId: number;
|
||||
isLocked?: boolean;
|
||||
topicMeta?: {
|
||||
isPinned: boolean;
|
||||
isLocked: boolean;
|
||||
isFeatured: boolean;
|
||||
title: string;
|
||||
categoryName: string;
|
||||
viewCount: number;
|
||||
tags?: ForumPostTag[];
|
||||
categorySlug?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ForumPostBlock({
|
||||
post,
|
||||
isTopicAuthor,
|
||||
currentUserId,
|
||||
isFirst = false,
|
||||
isTopicPost = false,
|
||||
topicId,
|
||||
topicAuthorId,
|
||||
isLocked = false,
|
||||
topicMeta,
|
||||
}: ForumPostBlockProps) {
|
||||
const canBestAnswer = currentUserId === topicAuthorId;
|
||||
const isOwnPost = currentUserId === post.user.id;
|
||||
const displayName =
|
||||
post.user.name || post.user.email?.split("@")[0] || "用户";
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${post.id}`}
|
||||
className={cn(
|
||||
"scroll-mt-20 rounded-2xl border bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden transition-colors",
|
||||
isTopicPost
|
||||
? "border-sky-200/60 ring-1 ring-sky-100"
|
||||
: post.isAnswer
|
||||
? "border-emerald-300/60 ring-1 ring-emerald-100"
|
||||
: "border-border/50",
|
||||
// 嵌套引用样式:被回复的帖子带左边框
|
||||
!isTopicPost && post.replyToId && "border-l-2 border-l-sky-300/50"
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_1fr]">
|
||||
{/* Left: user card */}
|
||||
<aside className="hidden sm:flex w-32 lg:w-36 shrink-0 border-r border-border/30 bg-gradient-to-b from-slate-50/50 to-white">
|
||||
<UserInfoCard
|
||||
user={post.user}
|
||||
variant={isTopicPost ? "topic" : "post"}
|
||||
className="w-full"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Right: content */}
|
||||
<div className="min-w-0 px-4 py-4 sm:px-6 sm:py-5">
|
||||
{/* Mobile avatar */}
|
||||
<div className="sm:hidden mb-3 flex items-center gap-2">
|
||||
<UserInfoCard user={post.user} variant="post" className="!py-0 !px-0 flex-row gap-2" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between gap-3 pb-3 border-b border-border/30">
|
||||
<div className="flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground/60">
|
||||
{isTopicPost && topicMeta && (
|
||||
<>
|
||||
{topicMeta.isPinned && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200/60">
|
||||
<Pin className="h-2.5 w-2.5" />
|
||||
置顶
|
||||
</span>
|
||||
)}
|
||||
{topicMeta.isFeatured && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-gradient-to-r from-rose-50 to-pink-50 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 border border-rose-200/60">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
精华
|
||||
</span>
|
||||
)}
|
||||
{topicMeta.isLocked && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 border border-slate-200/60">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
锁定
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 border border-sky-200/40">
|
||||
{topicMeta.categoryName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isTopicAuthor && (
|
||||
<span className="rounded-md bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 border border-violet-200/60">
|
||||
楼主
|
||||
</span>
|
||||
)}
|
||||
{post.isAnswer && (
|
||||
<span className="rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 border border-emerald-200/60">
|
||||
✓ 最佳答案
|
||||
</span>
|
||||
)}
|
||||
<TimeAgo date={post.createdAt} prefix="发表于" />
|
||||
</div>
|
||||
{isTopicPost && topicMeta && (
|
||||
<div className="hidden sm:flex items-center gap-2 text-[10px] text-muted-foreground/40">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span className="tabular-nums">{topicMeta.viewCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
{isTopicPost && topicMeta && (
|
||||
<h1 className="mt-3 text-xl sm:text-2xl font-bold tracking-tight text-foreground/90">
|
||||
{topicMeta.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{isTopicPost && topicMeta && topicMeta.tags && topicMeta.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{topicMeta.tags.map((t) => (
|
||||
<TagBadge key={t.id} name={t.name} slug={t.slug} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 嵌套引用预览 */}
|
||||
{!isTopicPost && post.replyToId && post.replyToUserName && (
|
||||
<ReplyQuote
|
||||
replyToId={post.replyToId}
|
||||
replyToUserName={post.replyToUserName}
|
||||
replyToContent={post.replyToContent || ""}
|
||||
topicId={topicId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[14.5px] leading-7 text-foreground/85 whitespace-pre-wrap break-words">
|
||||
<MentionContent content={post.content} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<PostActionBar
|
||||
postId={post.id}
|
||||
topicId={topicId}
|
||||
isTopicPost={isTopicPost}
|
||||
isLocked={isLocked || (topicMeta?.isLocked ?? false)}
|
||||
initialLiked={false}
|
||||
initialLikeCount={post.likeCount}
|
||||
authorId={post.user.id}
|
||||
isTopicAuthor={isTopicAuthor}
|
||||
isAccepted={post.isAnswer}
|
||||
canBestAnswer={canBestAnswer}
|
||||
isOwnPost={isOwnPost}
|
||||
canEdit={isOwnPost}
|
||||
canDelete={isOwnPost}
|
||||
initialContent={post.content}
|
||||
initialTitle={isTopicPost ? topicMeta?.title : undefined}
|
||||
categorySlug={topicMeta?.categorySlug}
|
||||
replyToUserName={displayName}
|
||||
replyToContent={post.content}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user