"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key, CornerDownRight, Send, Edit, X, Check } from "lucide-react"; interface ArtalkPage { id: number; key: string; title: string; url: string; comment_count?: number; date: string; } interface ArtalkComment { id: number; content: string; nick: string; email: string; page_key: string; page_title: string; is_collapsed: boolean; is_pending: boolean; rid: number; date: string; ua: string; } interface UserInfo { name: string; email: string; } const CACHE_KEY = "artalk_admin_cred"; const TOKEN_KEY = "artalk_admin_token"; const USER_KEY = "artalk_admin_user"; function lsGet(k: string) { try { return localStorage.getItem(k); } catch { return null; } } function lsSet(k: string, v: string) { try { localStorage.setItem(k, v); } catch {} } function lsDel(k: string) { try { localStorage.removeItem(k); } catch {} } export default function CommentsPage() { const [loggedIn, setLoggedIn] = useState(false); const [loginForm, setLoginForm] = useState({ email: "", password: "" }); const [loginError, setLoginError] = useState(""); const [loggingIn, setLoggingIn] = useState(false); const [remember, setRemember] = useState(true); const [pages, setPages] = useState([]); const [selectedPage, setSelectedPage] = useState(null); const [comments, setComments] = useState([]); const [loadingPages, setLoadingPages] = useState(false); const [loadingComments, setLoadingComments] = useState(false); const [pagesTotal, setPagesTotal] = useState(0); const [error, setError] = useState(null); const [editingTitle, setEditingTitle] = useState(false); const [editTitleText, setEditTitleText] = useState(""); const [savingTitle, setSavingTitle] = useState(false); const [cleaning, setCleaning] = useState(false); const [viewMode, setViewMode] = useState<"comments" | "stats">("comments"); const [stats, setStats] = useState<{ topArticles: { title: string; comment_count: number }[]; topUsers: { nick: string; count: number }[]; totalArticles: number; totalComments: number; uniqueUsers: number; } | null>(null); const [loadingStats, setLoadingStats] = useState(false); const [token, setToken] = useState(() => lsGet(TOKEN_KEY)); const [user, setUser] = useState(() => { const raw = lsGet(USER_KEY); return raw ? JSON.parse(raw) : null; }); // Reply state const [replyingTo, setReplyingTo] = useState(null); const [replyContent, setReplyContent] = useState(""); const [sendingReply, setSendingReply] = useState(false); const replyRef = useRef(null); // Auto-login useEffect(() => { const cachedToken = lsGet(TOKEN_KEY); const cachedUser = lsGet(USER_KEY); if (cachedToken && cachedUser) { setToken(cachedToken); setUser(JSON.parse(cachedUser)); setLoggedIn(true); return; } const cachedCred = lsGet(CACHE_KEY); if (cachedCred) { try { const cred = JSON.parse(cachedCred); setLoginForm(cred); doLogin(cred); } catch {} } }, []); const doLogin = async (cred: { email: string; password: string }, showError = true) => { setLoggingIn(true); setLoginError(""); try { const res = await fetch("/api/comments/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cred), }); if (!res.ok) { const d = await res.json(); throw new Error(d.error || "登录失败"); } const d = await res.json(); const t = d.token; const u = d.user; if (remember) { lsSet(CACHE_KEY, JSON.stringify(cred)); lsSet(TOKEN_KEY, t); lsSet(USER_KEY, JSON.stringify({ name: u.name, email: u.email })); } setToken(t); setUser({ name: u.name, email: u.email }); setLoggedIn(true); } catch (err: unknown) { if (showError) setLoginError(err instanceof Error ? err.message : "登录失败"); else { lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); } } finally { setLoggingIn(false); } }; const handleLogin = (e: React.FormEvent) => { e.preventDefault(); if (!loginForm.email || !loginForm.password) { setLoginError("请填写邮箱和密码"); return; } doLogin(loginForm); }; const handleLogout = () => { lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); setToken(null); setUser(null); setLoggedIn(false); setPages([]); setComments([]); setSelectedPage(null); setLoginForm({ email: "", password: "" }); }; // Fetch pages + sort by latest comment activity const fetchPages = useCallback(async () => { setLoadingPages(true); setError(null); try { // Fetch both Artalk pages and Hugo posts in parallel const p = new URLSearchParams({ limit: "300" }); if (token) p.set("token", token); const [artalkRes, postsRes] = await Promise.all([ fetch(`/api/comments?${p}`), fetch("/api/posts?perPage=9999"), ]); if (!artalkRes.ok) { if (artalkRes.status === 401) { setLoggedIn(false); lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); return; } throw new Error("加载失败"); } const artalkData = await artalkRes.json(); let allPages: ArtalkPage[] = artalkData.pages || []; setPagesTotal(artalkData.total || 0); // Match with Hugo posts to get MD file dates for sorting if (postsRes.ok) { const postsData = await postsRes.json(); const posts: { title: string; date: string }[] = postsData.posts || []; // Build a map: title -> post date const dateMap = new Map(); for (const post of posts) { const t = post.title.trim(); dateMap.set(t, new Date(post.date).getTime()); } // Sort Artalk pages by matched MD date (newest first) allPages.sort((a, b) => { const da = dateMap.get((a.title || "").trim()); const db = dateMap.get((b.title || "").trim()); if (da && db) return db - da; if (da) return -1; if (db) return 1; return 0; }); } setPages(allPages); if (!selectedPage && allPages.length > 0) { setSelectedPage(allPages[0]); } } catch (err: unknown) { setError(err instanceof Error ? err.message : "加载失败"); } finally { setLoadingPages(false); } }, [token]); useEffect(() => { if (loggedIn && token) fetchPages(); }, [fetchPages, loggedIn, token]); // Load comments when page selected useEffect(() => { if (!selectedPage) return; setLoadingComments(true); setError(null); (async () => { try { const p = new URLSearchParams({ page_key: selectedPage.key, limit: "200" }); if (token) p.set("token", token); const res = await fetch(`/api/comments?${p}`); if (!res.ok) throw new Error("加载失败"); const data = await res.json(); const seen = new Set(); const unique: ArtalkComment[] = []; for (const c of data.comments || []) { if (!seen.has(c.id)) { seen.add(c.id); unique.push(c); } } // Sort newest first unique.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); setComments(unique); // Update comment count for this page setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: unique.length } : p)); // Auto-sync title from blog post try { const slug = selectedPage.key.replace(/^\//, "").replace(/\.html$/, ""); if (slug) { const postRes = await fetch(`/api/posts/${slug}`); if (postRes.ok) { const post = await postRes.json(); const realTitle = post.frontMatter?.title || ""; if (realTitle && realTitle.trim() !== (selectedPage.title || "").trim()) { // Title differs — auto-update Artalk page await fetch(`/api/comments/pages/${selectedPage.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: selectedPage.key, title: realTitle, admin_only: false }), }); // Update local state setSelectedPage((prev) => prev ? { ...prev, title: realTitle } : prev); setPages((prev) => prev.map((p) => p.id === selectedPage.id ? { ...p, title: realTitle } : p)); } } } } catch { /* silent */ } } catch (err: unknown) { setError(err instanceof Error ? err.message : "加载失败"); } finally { setLoadingComments(false); } })(); }, [selectedPage, token]); const handleToggleVisibility = async (c: ArtalkComment) => { try { const body: Record = { is_collapsed: !c.is_collapsed }; if (token) body.token = token; await fetch(`/api/comments/${c.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); setComments((prev) => prev.map((x) => x.id === c.id ? { ...x, is_collapsed: !x.is_collapsed } : x)); } catch { setError("操作失败"); } }; const handleDelete = async (c: ArtalkComment) => { if (!confirm(`确定删除「${c.nick}」的评论?`)) return; try { const body: Record = {}; if (token) body.token = token; await fetch(`/api/comments/${c.id}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); setComments((prev) => prev.filter((x) => x.id !== c.id)); if (selectedPage) { setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: Math.max(0, (p.comment_count ?? 0) - 1) } : p)); } } catch { setError("删除失败"); } }; const handleReply = async () => { if (!replyContent.trim() || !selectedPage || !user) return; setSendingReply(true); try { const body: Record = { page_key: selectedPage.key, content: replyContent, name: user.name, email: user.email, }; if (replyingTo) body.rid = replyingTo; if (token) body.token = token; const res = await fetch("/api/comments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error("回复失败"); const newComment = await res.json(); // Add new comment to list setComments((prev) => [newComment, ...prev]); if (selectedPage) { setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: (p.comment_count ?? 0) + 1 } : p)); } setReplyContent(""); setReplyingTo(null); } catch (err: unknown) { setError(err instanceof Error ? err.message : "回复失败"); } finally { setSendingReply(false); } }; const startReply = (commentId: number) => { setReplyingTo(commentId); setReplyContent(""); setTimeout(() => replyRef.current?.focus(), 50); }; const handleSaveTitle = async () => { if (!selectedPage || !editTitleText.trim()) return; setSavingTitle(true); try { const res = await fetch(`/api/comments/pages/${selectedPage.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: selectedPage.key, title: editTitleText.trim(), admin_only: false, }), }); if (!res.ok) throw new Error("更新失败"); setSelectedPage({ ...selectedPage, title: editTitleText.trim() }); setPages((prev) => prev.map((p) => (p.id === selectedPage.id ? { ...p, title: editTitleText.trim() } : p)) ); setEditingTitle(false); } catch (err: unknown) { setError(err instanceof Error ? err.message : "更新标题失败"); } finally { setSavingTitle(false); } }; const startEditTitle = () => { if (!selectedPage) return; setEditTitleText(selectedPage.title || ""); setEditingTitle(true); }; const handleCleanup = async () => { if (!confirm("将删除重复且评论数为0的页面,确定继续?")) return; if (!token) return; setCleaning(true); setError(null); try { const res = await fetch("/api/comments/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "清理失败"); setError(null); alert(`清理完成:共 ${data.total} 个页面,${data.duplicateGroups} 组重复,删除了 ${data.deleted} 个。`); fetchPages(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "清理失败"); } finally { setCleaning(false); } }; const handleDeletePage = async (page: ArtalkPage) => { if (!confirm(`确定删除页面「${page.title || "(无标题)"}」吗?\n此操作不可撤销,该页面的所有评论也将被删除。`)) return; if (!token) return; try { const res = await fetch(`/api/comments/pages/${page.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: page.key, title: page.title, admin_only: false }), }); // Delete via Artalk directly const delRes = await fetch(`https://artalk.usj.cc/api/v2/pages/${page.id}`, { method: "DELETE", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ site_name: "优世界" }), }); if (!delRes.ok) throw new Error("删除失败"); setPages((prev) => prev.filter((p) => p.id !== page.id)); if (selectedPage?.id === page.id) { setSelectedPage(null); setComments([]); } } catch (err: unknown) { setError(err instanceof Error ? err.message : "删除页面失败"); } }; const fetchStats = async () => { if (!token) return; setLoadingStats(true); setViewMode("stats"); try { const res = await fetch(`/api/comments/stats?token=${encodeURIComponent(token)}`); if (!res.ok) throw new Error("加载失败"); setStats(await res.json()); } catch (err: unknown) { setError(err instanceof Error ? err.message : "加载统计失败"); } finally { setLoadingStats(false); } }; const fmtDate = (d: string) => d ? new Date(d).toLocaleString("zh-CN") : "-"; const stripHtml = (h: string) => h.replace(/<[^>]*>/g, "").slice(0, 300); // --- Login --- if (!loggedIn) { return (

评论管理

Artalk 管理员登录

setLoginForm({ ...loginForm, email: e.target.value })} className="w-full bg-bg border border-line rounded-md px-3 py-2.5 text-[13px] text-text focus:outline-none focus:border-accent/30" placeholder="管理员邮箱" autoComplete="email" />
setLoginForm({ ...loginForm, password: e.target.value })} className="w-full bg-bg border border-line rounded-md px-3 py-2.5 text-[13px] text-text focus:outline-none focus:border-accent/30" placeholder="管理员密码" autoComplete="current-password" />
{loginError &&
{loginError}
}
); } // --- Main --- return (
{/* Top bar */}

评论管理

{user && {user.name}}
{error && (
{error}
)} {/* Stats view */} {viewMode === "stats" && (
{loadingStats ? (
加载统计数据...
) : stats ? (
{/* Summary cards */}
{[ { label: "文章总数", value: stats.totalArticles }, { label: "评论总数", value: stats.totalComments }, { label: "评论用户", value: stats.uniqueUsers }, ].map((s) => (
{s.label}
{s.value}
))}
{/* Top articles & top users side by side */}
{/* Top articles */}

评论最多的文章 Top 20

{stats.topArticles.map((a, i) => (
{i + 1} {a.title} {a.comment_count} 条
))}
{/* Top commenters */}

最活跃的用户 Top 30

{stats.topUsers.map((u, i) => (
{i + 1} {u.nick} {u.count} 条
))}
) : null}
)} {/* Two columns — only in comments mode */} {viewMode === "comments" && (
{/* Left: Articles */}
文章列表 {pagesTotal > 0 && · {pagesTotal}}
{loadingPages ? (
加载中
) : (
{pages.map((p) => (
))}
)}
{/* Right: Comments */}
{selectedPage ? ( <>
{editingTitle ? (
setEditTitleText(e.target.value)} className="flex-1 bg-card border border-line rounded-md px-2 py-1 text-[13px] text-text focus:outline-none focus:border-accent/30" onKeyDown={(e) => { if (e.key === "Enter") handleSaveTitle(); if (e.key === "Escape") setEditingTitle(false); }} autoFocus />
) : ( <> {selectedPage.title} )} {comments.length} 条
{loadingComments ? (
加载评论
) : (
{comments.length === 0 && (
暂无评论
)} {comments.map((c) => (
{c.nick} {c.rid > 0 && 回复} {c.is_collapsed && 已隐藏}
{/* Parent comment preview */} {c.rid > 0 && (() => { const parent = comments.find((x) => x.id === c.rid); return parent ? (
{parent.nick} : {stripHtml(parent.content).slice(0, 60)} {(parent.content || "").replace(/<[^>]*>/g, "").length > 60 ? "..." : ""}
) : null; })()}

{stripHtml(c.content)}

{fmtDate(c.date)} {c.ua?.includes("Mobile") ? "📱" : "💻"}
{/* Inline reply form */} {replyingTo === c.id && (