修复若干问题

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

+104
View File
@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { ARTALK_SERVER, ARTALK_SITE } from "@/lib/config";
async function getArtalkData(path: string, token: string) {
const url = `${ARTALK_SERVER}${path}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Artalk API error: ${res.status}`);
return res.json();
}
async function deleteArtalkPage(id: number, token: string) {
const url = `${ARTALK_SERVER}/api/v2/pages/${id}`;
const res = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ site_name: ARTALK_SITE }),
});
return res.ok;
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const token = (body as Record<string, string>).token;
if (!token) {
return NextResponse.json({ error: "token required" }, { status: 400 });
}
// 1. Login to get a valid token
const allPages: { id: number; key: string; title: string; comment_count?: number }[] = [];
let offset = 0;
const limit = 200;
// Fetch all pages
while (true) {
const data = await getArtalkData(
`/api/v2/pages?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=${limit}&offset=${offset}`,
token
);
const pages = data.pages || data.data || [];
if (pages.length === 0) break;
allPages.push(...pages);
offset += limit;
if (pages.length < limit) break;
}
// 2. Group by title
const byTitle = new Map<string, typeof allPages>();
for (const p of allPages) {
const t = (p.title || "").trim();
if (!byTitle.has(t)) byTitle.set(t, []);
byTitle.get(t)!.push(p);
}
// 3. For each duplicate group, keep the one with most comments, delete the rest (especially 0-comment ones)
let deleted = 0;
const deletedIds: number[] = [];
for (const [title, pages] of byTitle) {
if (pages.length <= 1) continue;
// Sort: most comments first, then by id desc (newest first)
pages.sort((a, b) => {
const ca = a.comment_count ?? 0;
const cb = b.comment_count ?? 0;
if (ca !== cb) return cb - ca;
return b.id - a.id;
});
// Keep the first one, delete the rest
const [keep, ...toDelete] = pages;
for (const p of toDelete) {
try {
const ok = await deleteArtalkPage(p.id, token);
if (ok) {
deleted++;
deletedIds.push(p.id);
}
} catch { /* skip failed deletions */ }
}
}
// 4. Also clean up pages with 0 comments that are NOT part of duplicate groups
// (only if they look like test/404 pages — very short titles or empty keys)
// Skip this step to be safe — only deduplicate.
return NextResponse.json({
total: allPages.length,
duplicateGroups: [...byTitle.values()].filter((ps) => ps.length > 1).length,
deleted,
deletedIds,
});
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { updatePage } from "@/lib/artalk";
import { ARTALK_SITE } from "@/lib/config";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { key, title, admin_only } = body;
if (!title) {
return NextResponse.json({ error: "title is required" }, { status: 400 });
}
const result = await updatePage(parseInt(id, 10), {
key: key || "",
title,
admin_only: admin_only ?? false,
});
return NextResponse.json(result);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { ARTALK_SERVER, ARTALK_SITE } from "@/lib/config";
async function artalkFetch(path: string, token: string) {
const res = await fetch(`${ARTALK_SERVER}${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Artalk error: ${res.status}`);
return res.json();
}
interface ArtalkPage {
id: number;
key: string;
title: string;
}
interface ArtalkComment {
nick: string;
}
export async function GET(request: NextRequest) {
try {
const token = request.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "token required" }, { status: 400 });
}
// Step 1: Fetch all pages
const allPages: ArtalkPage[] = [];
let offset = 0;
while (true) {
const data = await artalkFetch(
`/api/v2/pages?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=200&offset=${offset}`,
token
);
const pages = data.pages || data.data || [];
if (pages.length === 0) break;
allPages.push(...pages);
offset += 200;
if (pages.length < 200) break;
}
// Step 2: Fetch comments per page with concurrency limit
const pageCommentCounts: { title: string; count: number }[] = [];
const userMap = new Map<string, { nick: string; count: number }>();
// Batch pages with concurrency of 10
for (let i = 0; i < allPages.length; i += 10) {
const batch = allPages.slice(i, i + 10);
const results = await Promise.allSettled(
batch.map((p) =>
artalkFetch(
`/api/v2/comments?site_name=${encodeURIComponent(ARTALK_SITE)}&page_key=${encodeURIComponent(p.key)}&limit=200&flat_mode=true`,
token
)
)
);
for (let j = 0; j < batch.length; j++) {
const page = batch[j];
const result = results[j];
if (result.status !== "fulfilled") continue;
const comments: ArtalkComment[] = result.value.comments || result.value.data || [];
const count = comments.length;
if (count > 0) {
pageCommentCounts.push({ title: page.title || "(无标题)", count });
}
for (const c of comments) {
const key = (c.nick || "匿名").trim();
const existing = userMap.get(key);
if (existing) existing.count++;
else userMap.set(key, { nick: c.nick || "匿名", count: 1 });
}
}
}
// Sort
const topArticles = pageCommentCounts
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const topUsers = [...userMap.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 30);
const totalComments = pageCommentCounts.reduce((s, a) => s + a.count, 0);
return NextResponse.json({
topArticles,
topUsers,
totalArticles: allPages.length,
totalComments,
uniqueUsers: userMap.size,
});
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+38 -8
View File
@@ -11,10 +11,39 @@ interface ImageFile {
size: number;
url: string;
slug: string;
localUrl: string;
}
export async function GET() {
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const serveFile = searchParams.get("file");
// Mode 2: Serve a specific image file (relative path from CONTENT_DIR)
if (serveFile) {
const fullPath = path.join(CONTENT_DIR, serveFile);
if (!fullPath.startsWith(CONTENT_DIR)) {
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
}
if (!fs.existsSync(fullPath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const ext = path.extname(fullPath).toLowerCase();
const mimeMap: Record<string, string> = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp",
};
const buffer = fs.readFileSync(fullPath);
return new NextResponse(buffer, {
headers: {
"Content-Type": mimeMap[ext] || "application/octet-stream",
"Cache-Control": "public, max-age=3600",
"Content-Length": String(buffer.length),
},
});
}
// Mode 1: List all images
const images: ImageFile[] = [];
const exts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"]);
@@ -38,9 +67,9 @@ export async function GET() {
scan(full, folder ? `${folder}/${entry}` : entry, childSlug);
} else if (exts.has(path.extname(entry).toLowerCase())) {
const rel = path.relative(CONTENT_DIR, full).replace(/\\/g, "/");
// Hugo serves page resources at /<slug>/<filename>
const url = slug ? `/${slug}/${entry}` : `/${rel}`;
images.push({ name: entry, path: full, folder, size: stat.size, url, slug });
const localUrl = `/api/images?file=${encodeURIComponent(rel)}`;
images.push({ name: entry, path: rel, folder, size: stat.size, url, slug, localUrl });
}
}
}
@@ -58,15 +87,16 @@ export async function GET() {
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const filePath = searchParams.get("path");
if (!filePath) return NextResponse.json({ error: "path required" }, { status: 400 });
const relPath = searchParams.get("path");
if (!relPath) return NextResponse.json({ error: "path required" }, { status: 400 });
if (!filePath.startsWith(CONTENT_DIR)) {
const fullPath = path.join(CONTENT_DIR, relPath);
if (!fullPath.startsWith(CONTENT_DIR)) {
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "File not found" }, { status: 404 });
+2 -4
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getFeeds, addFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
import { getFeeds, addFeed, updateFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
export async function GET(request: NextRequest) {
try {
@@ -43,9 +43,7 @@ export async function PUT(request: NextRequest) {
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
// Delete old, add new (RSS API has no update endpoint for feeds)
await deleteFeed(oldUrl);
await addFeed(newUrl || oldUrl, feedTitle || "");
await updateFeed(oldUrl, newUrl || oldUrl, feedTitle || "");
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
+6 -11
View File
@@ -44,17 +44,12 @@ export async function PUT(request: NextRequest) {
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
// If URL changed, delete old and create new
if (newUrl && newUrl !== oldUrl) {
await deleteLink(oldUrl);
await addLink({ name: name || "", url: newUrl, description, hidden });
} else {
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (hidden !== undefined) data.hidden = hidden;
await updateLink(oldUrl, data);
}
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (hidden !== undefined) data.hidden = hidden;
if (newUrl !== undefined) data.url = newUrl;
await updateLink(oldUrl, data);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
+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>
);
}
+2 -1
View File
@@ -12,6 +12,7 @@ interface ImageFile {
folder: string;
size: number;
url: string;
localUrl: string;
}
export default function ImagesPage() {
@@ -88,7 +89,7 @@ export default function ImagesPage() {
<div key={img.path} className="bg-white border border-line rounded-lg overflow-hidden group hover:border-accent/20 transition-all">
<div className="aspect-square bg-surface flex items-center justify-center overflow-hidden">
<img
src={`${HUGO_SITE}${img.url}`}
src={img.localUrl}
alt={img.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
+12
View File
@@ -163,3 +163,15 @@ export async function createComment(data: {
export async function deleteComment(id: number): Promise<void> {
await artalkMutate<void>(`/api/v2/comments/${id}`, "DELETE", { site_name: ARTALK_SITE });
}
export async function updatePage(
id: number,
data: { key: string; title: string; admin_only?: boolean }
): Promise<ArtalkPage> {
return artalkMutate<ArtalkPage>(`/api/v2/pages/${id}`, "PUT", {
key: data.key,
title: data.title,
admin_only: data.admin_only ?? false,
site_name: ARTALK_SITE,
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const ARTALK_SITE = "优世界";
export const ITEMS_PER_PAGE = 20;
export const RECYCLE_DAYS = 30;
export const HUGO_PREVIEW = "http://localhost:1313";
export const RSS_API_BASE = process.env.RSS_API_BASE || "https://rssapi.usj.cc";
export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
+15 -3
View File
@@ -64,6 +64,17 @@ export async function addFeed(url: string, feedTitle: string): Promise<void> {
});
}
export async function updateFeed(oldUrl: string, newUrl: string, feedTitle: string): Promise<void> {
const qs = withToken();
const body: Record<string, string> = { url: newUrl, feedTitle };
if (oldUrl && oldUrl !== newUrl) body.oldUrl = oldUrl;
await rssFetch(`/api/feeds?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function deleteFeed(url: string): Promise<void> {
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
@@ -94,13 +105,14 @@ export async function addLink(link: { name: string; url: string; description?: s
});
}
export async function updateLink(url: string, data: { name?: string; description?: string; hidden?: boolean }): Promise<void> {
// RSS API uses POST as upsert
export async function updateLink(oldUrl: string, data: { name?: string; url?: string; description?: string; hidden?: boolean }): Promise<void> {
const qs = withToken();
const body: Record<string, unknown> = { ...data };
if (data.url && data.url !== oldUrl) body.oldUrl = oldUrl;
await rssFetch(`/api/links?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, ...data }),
body: JSON.stringify(body),
});
}