0520发布文章
This commit is contained in:
1 parent
993ac5581d
commit
013dce4f9f
89 files changed
+11923
-54
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user