0520发布文章

This commit is contained in:
zqlit committed 2026-05-21 00:27:35 +08:00
1 parent 993ac5581d
commit 013dce4f9f
89 files changed
+11923 -54

No files matched your search

+465
View File
@@ -0,0 +1,465 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key,
CornerDownRight, Send
} 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 [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));
} 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 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-white 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-3">
{user && <span className="text-[12px] text-muted">{user.name}</span>}
<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>
)}
{/* Two columns */}
<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-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 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>
))}
</div>
)}
</div>
{/* Right: Comments */}
<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>
{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-white 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-white 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-white border border-line rounded-md text-[13px] text-muted">
<MessageSquare size={18} className="mr-2 text-muted/30" />
选择左侧文章查看评论
</div>
)}
</div>
</div>
</div>
);
}