709 lines
32 KiB
TypeScript
709 lines
32 KiB
TypeScript
"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<ArtalkPage[]>([]);
|
|
const [selectedPage, setSelectedPage] = useState<ArtalkPage | null>(null);
|
|
const [comments, setComments] = useState<ArtalkComment[]>([]);
|
|
const [loadingPages, setLoadingPages] = useState(false);
|
|
const [loadingComments, setLoadingComments] = useState(false);
|
|
const [pagesTotal, setPagesTotal] = useState(0);
|
|
const [error, setError] = useState<string | null>(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<string | null>(() => lsGet(TOKEN_KEY));
|
|
const [user, setUser] = useState<UserInfo | null>(() => {
|
|
const raw = lsGet(USER_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
});
|
|
|
|
// Reply state
|
|
const [replyingTo, setReplyingTo] = useState<number | null>(null);
|
|
const [replyContent, setReplyContent] = useState("");
|
|
const [sendingReply, setSendingReply] = useState(false);
|
|
const replyRef = useRef<HTMLTextAreaElement>(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<string, number>();
|
|
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<number>();
|
|
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<string, unknown> = { 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<string, unknown> = {};
|
|
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<string, unknown> = {
|
|
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 (
|
|
<div className="min-h-full flex items-center justify-center px-8">
|
|
<div className="w-[340px] bg-card border border-line rounded-lg p-8">
|
|
<div className="text-center mb-6">
|
|
<div className="w-10 h-10 bg-accent/8 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<Key size={18} className="text-accent" />
|
|
</div>
|
|
<h2 className="text-[15px] font-bold text-text">评论管理</h2>
|
|
<p className="text-[12px] text-muted mt-1">Artalk 管理员登录</p>
|
|
</div>
|
|
<form onSubmit={handleLogin} className="space-y-4">
|
|
<div>
|
|
<label className="block text-[12px] font-medium text-text/70 mb-1.5">邮箱</label>
|
|
<input type="email" value={loginForm.email}
|
|
onChange={(e) => 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" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[12px] font-medium text-text/70 mb-1.5">密码</label>
|
|
<input type="password" value={loginForm.password}
|
|
onChange={(e) => 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" />
|
|
</div>
|
|
<label className="flex items-center gap-2 text-[12px] text-muted cursor-pointer">
|
|
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} className="rounded accent-accent" />
|
|
记住登录
|
|
</label>
|
|
{loginError && <div className="p-3 rounded-md bg-[#fee2e2] text-[#991b1b] border border-[#fecaca] text-[12px]">{loginError}</div>}
|
|
<button type="submit" disabled={loggingIn}
|
|
className="w-full py-2.5 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
|
|
{loggingIn ? <><Loader2 size={14} className="animate-spin" /> 登录中...</> : "登录"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Main ---
|
|
return (
|
|
<div className="h-full flex flex-col px-8 py-6">
|
|
{/* Top bar */}
|
|
<div className="flex items-center justify-between mb-5 shrink-0">
|
|
<h2 className="text-lg font-bold text-text">评论管理</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" onClick={() => setViewMode("comments")}
|
|
className={`px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
|
|
viewMode === "comments" ? "bg-accent text-white border-accent" : "border-line text-muted hover:text-accent"
|
|
}`}>管理</button>
|
|
<button type="button" onClick={fetchStats}
|
|
className={`px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
|
|
viewMode === "stats" ? "bg-accent text-white border-accent" : "border-line text-muted hover:text-accent"
|
|
}`}>统计</button>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{user && <span className="text-[12px] text-muted">{user.name}</span>}
|
|
<button type="button" onClick={handleCleanup} disabled={cleaning}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent hover:border-accent/20 transition-colors disabled:opacity-50">
|
|
{cleaning ? "清理中..." : "去重清理"}
|
|
</button>
|
|
<button type="button" onClick={handleLogout}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-danger hover:border-danger/20 transition-colors">
|
|
<LogOut size={13} /> 退出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 px-4 py-2.5 rounded-md bg-[#fee2e2] text-[#991b1b] border border-[#fecaca] text-[12px] flex items-center justify-between shrink-0">
|
|
<span>{error}</span>
|
|
<button type="button" onClick={() => setError(null)} className="underline">关闭</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats view */}
|
|
{viewMode === "stats" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
{loadingStats ? (
|
|
<div className="flex items-center gap-2 text-[13px] text-muted py-20 justify-center">
|
|
<Loader2 size={14} className="animate-spin" /> 加载统计数据...
|
|
</div>
|
|
) : stats ? (
|
|
<div className="space-y-6">
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{[
|
|
{ label: "文章总数", value: stats.totalArticles },
|
|
{ label: "评论总数", value: stats.totalComments },
|
|
{ label: "评论用户", value: stats.uniqueUsers },
|
|
].map((s) => (
|
|
<div key={s.label} className="bg-card border border-line rounded-lg p-4 text-center">
|
|
<div className="text-[11px] text-muted mb-1">{s.label}</div>
|
|
<div className="text-[22px] font-bold text-accent">{s.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Top articles & top users side by side */}
|
|
<div className="flex gap-5 min-h-0">
|
|
{/* Top articles */}
|
|
<div className="flex-1 bg-card border border-line rounded-lg p-5">
|
|
<h3 className="text-[13px] font-bold text-text mb-4">评论最多的文章 Top 20</h3>
|
|
<div className="space-y-1">
|
|
{stats.topArticles.map((a, i) => (
|
|
<div key={i} className="flex items-center gap-3 py-1.5 text-[13px]">
|
|
<span className={`w-6 text-center font-bold text-[11px] rounded-full ${
|
|
i < 3 ? "bg-accent text-white" : "text-muted"
|
|
}`}>{i + 1}</span>
|
|
<span className="flex-1 text-text truncate">{a.title}</span>
|
|
<span className="text-muted font-medium shrink-0">{a.comment_count} 条</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top commenters */}
|
|
<div className="flex-1 bg-card border border-line rounded-lg p-5">
|
|
<h3 className="text-[13px] font-bold text-text mb-4">最活跃的用户 Top 30</h3>
|
|
<div className="space-y-1">
|
|
{stats.topUsers.map((u, i) => (
|
|
<div key={i} className="flex items-center gap-3 py-1.5 text-[13px]">
|
|
<span className={`w-6 text-center font-bold text-[11px] rounded-full ${
|
|
i < 3 ? "bg-accent text-white" : "text-muted"
|
|
}`}>{i + 1}</span>
|
|
<span className="flex-1 text-text truncate">{u.nick}</span>
|
|
<span className="text-muted font-medium shrink-0">{u.count} 条</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two columns — only in comments mode */}
|
|
{viewMode === "comments" && (
|
|
<div className="flex gap-5 flex-1 min-h-0">
|
|
{/* Left: Articles */}
|
|
<div className="w-[240px] shrink-0 flex flex-col min-h-0">
|
|
<div className="text-[11px] font-medium text-muted uppercase tracking-wider mb-2.5 shrink-0">
|
|
文章列表 {pagesTotal > 0 && <span className="font-normal normal-case">· {pagesTotal}</span>}
|
|
</div>
|
|
|
|
{loadingPages ? (
|
|
<div className="flex items-center gap-2 text-[12px] text-muted py-12 justify-center">
|
|
<Loader2 size={13} className="animate-spin" /> 加载中
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto bg-card border border-line rounded-md">
|
|
{pages.map((p) => (
|
|
<div key={p.id}
|
|
className={`flex items-center border-b border-line/50 text-[13px] transition-colors hover:bg-surface ${
|
|
selectedPage?.key === p.key ? "bg-accent/5 border-l-[3px] border-l-accent" : ""
|
|
}`}>
|
|
<button type="button" onClick={() => setSelectedPage(p)}
|
|
className="flex-1 text-left px-3.5 py-2.5 min-w-0">
|
|
<div className="text-text truncate font-medium">{p.title || "(无标题)"}</div>
|
|
<div className="text-[11px] text-muted mt-0.5">
|
|
{p.comment_count != null ? `${p.comment_count} 条评论` : "..."}
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); handleDeletePage(p); }}
|
|
className="shrink-0 px-2 py-1 mr-1 text-[11px] text-muted/30 hover:text-danger rounded transition-colors"
|
|
title="删除此页面"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Comments */}
|
|
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
|
{selectedPage ? (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-2.5 shrink-0">
|
|
{editingTitle ? (
|
|
<div className="flex items-center gap-1.5 flex-1">
|
|
<input
|
|
type="text"
|
|
value={editTitleText}
|
|
onChange={(e) => 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
|
|
/>
|
|
<button type="button" onClick={handleSaveTitle} disabled={savingTitle}
|
|
className="p-1 rounded text-success hover:bg-success/10" title="保存">
|
|
{savingTitle ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
|
</button>
|
|
<button type="button" onClick={() => setEditingTitle(false)}
|
|
className="p-1 rounded text-muted hover:text-text" title="取消">
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<span className="text-[11px] font-medium text-muted uppercase tracking-wider">{selectedPage.title}</span>
|
|
<button type="button" onClick={startEditTitle}
|
|
className="p-0.5 rounded text-muted/40 hover:text-accent transition-colors" title="编辑标题">
|
|
<Edit size={11} />
|
|
</button>
|
|
</>
|
|
)}
|
|
<span className="text-[11px] text-muted/50 font-normal normal-case ml-auto">{comments.length} 条</span>
|
|
</div>
|
|
|
|
{loadingComments ? (
|
|
<div className="flex items-center gap-2 text-[12px] text-muted py-12 justify-center">
|
|
<Loader2 size={13} className="animate-spin" /> 加载评论
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto space-y-1.5">
|
|
{comments.length === 0 && (
|
|
<div className="flex items-center justify-center h-32 bg-card border border-line rounded-md text-[13px] text-muted">
|
|
暂无评论
|
|
</div>
|
|
)}
|
|
{comments.map((c) => (
|
|
<div key={c.id} className={`px-4 py-3 rounded-md border ${
|
|
c.is_collapsed ? "bg-surface border-line/40 opacity-55" : "bg-card border-line"
|
|
}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-medium text-[13px] text-text">{c.nick}</span>
|
|
{c.rid > 0 && <span className="text-[10px] text-accent bg-accent/8 px-1.5 py-px rounded">回复</span>}
|
|
{c.is_collapsed && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded">已隐藏</span>}
|
|
</div>
|
|
{/* Parent comment preview */}
|
|
{c.rid > 0 && (() => {
|
|
const parent = comments.find((x) => x.id === c.rid);
|
|
return parent ? (
|
|
<div className="mb-2 pl-2.5 border-l-2 border-accent/15 text-[12px] text-muted">
|
|
<span className="font-medium">{parent.nick}</span>
|
|
<span className="mx-1">:</span>
|
|
{stripHtml(parent.content).slice(0, 60)}
|
|
{(parent.content || "").replace(/<[^>]*>/g, "").length > 60 ? "..." : ""}
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
<p className="text-[13px] text-text/75 leading-relaxed break-words">{stripHtml(c.content)}</p>
|
|
<div className="flex items-center gap-3 mt-2 text-[11px] text-muted/70">
|
|
<span>{fmtDate(c.date)}</span>
|
|
<span>{c.ua?.includes("Mobile") ? "📱" : "💻"}</span>
|
|
<button type="button" onClick={() => startReply(c.id)}
|
|
className="text-accent/60 hover:text-accent transition-colors flex items-center gap-0.5">
|
|
<CornerDownRight size={11} /> 回复
|
|
</button>
|
|
</div>
|
|
|
|
{/* Inline reply form */}
|
|
{replyingTo === c.id && (
|
|
<div className="mt-3 pt-3 border-t border-line/50">
|
|
<textarea
|
|
ref={replyRef}
|
|
value={replyContent}
|
|
onChange={(e) => setReplyContent(e.target.value)}
|
|
rows={3}
|
|
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] text-text focus:outline-none focus:border-accent/30 resize-none"
|
|
placeholder={`回复 ${c.nick}...`}
|
|
/>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className="text-[11px] text-muted">
|
|
以 {user?.name} 身份回复
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" onClick={() => { setReplyingTo(null); setReplyContent(""); }}
|
|
className="px-3 py-1 text-[12px] rounded border border-line text-muted hover:bg-surface transition-colors">
|
|
取消
|
|
</button>
|
|
<button type="button" onClick={handleReply} disabled={sendingReply || !replyContent.trim()}
|
|
className="flex items-center gap-1 px-3 py-1 text-[12px] font-medium rounded bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
|
{sendingReply ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
|
|
{sendingReply ? "发送中" : "回复"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
<button type="button" onClick={() => handleToggleVisibility(c)}
|
|
className="p-1 rounded hover:bg-surface text-muted/50 hover:text-text transition-colors"
|
|
title={c.is_collapsed ? "显示" : "隐藏"}>
|
|
{c.is_collapsed ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
</button>
|
|
<button type="button" onClick={() => handleDelete(c)}
|
|
className="p-1 rounded hover:bg-danger/8 text-muted/50 hover:text-danger transition-colors" title="删除">
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center bg-card border border-line rounded-md text-[13px] text-muted">
|
|
<MessageSquare size={18} className="mr-2 text-muted/30" />
|
|
选择左侧文章查看评论
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|