修复若干问题
This commit is contained in:
1 parent
013dce4f9f
commit
d9b7026d21
12 files changed
+581
-40
No files matched your search
+255
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user