修复若干问题

This commit is contained in:
zqlit committed 2026-05-21 13:35:18 +08:00
1 parent 013dce4f9f
commit d9b7026d21
12 files changed
+581 -40

No files matched your search

+255 -12
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key,
CornerDownRight, Send
CornerDownRight, Send, Edit, X, Check
} from "lucide-react";
interface ArtalkPage {
@@ -39,6 +39,19 @@ export default function CommentsPage() {
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);
@@ -188,6 +201,29 @@ export default function CommentsPage() {
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); }
@@ -257,6 +293,96 @@ export default function CommentsPage() {
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);
@@ -308,8 +434,22 @@ export default function CommentsPage() {
{/* 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} /> 退出
@@ -324,7 +464,70 @@ export default function CommentsPage() {
</div>
)}
{/* Two columns */}
{/* 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-white 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-white 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-white 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">
@@ -339,15 +542,26 @@ export default function CommentsPage() {
) : (
<div className="flex-1 overflow-y-auto bg-white border border-line rounded-md">
{pages.map((p) => (
<button key={p.id} type="button" onClick={() => setSelectedPage(p)}
className={`w-full text-left px-3.5 py-2.5 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 pl-[11px]" : ""
<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" : ""
}`}>
<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={() => 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>
)}
@@ -357,8 +571,36 @@ export default function CommentsPage() {
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{selectedPage ? (
<>
<div className="text-[11px] font-medium text-muted uppercase tracking-wider mb-2.5 shrink-0">
{selectedPage.title} <span className="font-normal normal-case">· {comments.length} 条</span>
<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-white 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 ? (
@@ -460,6 +702,7 @@ export default function CommentsPage() {
)}
</div>
</div>
)}
</div>
);
}