added:增加Linux-server

This commit is contained in:
Vaica committed 2026-06-21 20:42:44 +08:00
1 parent 6fe4e9d23f
commit d78f3f811b
90 files changed
+16784 -1

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { aiChat, AI_PROMPTS } from "@/lib/ai";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, text, title, content } = body;
if (!action) {
return NextResponse.json({ error: "action is required" }, { status: 400 });
}
let systemPrompt: string;
let userMessage: string;
switch (action) {
case "polish":
systemPrompt = AI_PROMPTS.polish;
userMessage = text || "";
break;
case "expand":
systemPrompt = AI_PROMPTS.expand;
userMessage = text || "";
break;
case "shorten":
systemPrompt = AI_PROMPTS.shorten;
userMessage = text || "";
break;
case "fix":
systemPrompt = AI_PROMPTS.fix;
userMessage = text || "";
break;
case "summary":
systemPrompt = "你是一个编辑。根据文章标题和内容写简短摘要(100字以内)。只返回摘要,不要解释。";
userMessage = `标题:${title || ""}\n\n内容片段:${(content || "").slice(0, 2000)}`;
break;
case "tags":
systemPrompt = "你是一个编辑。根据文章标题和内容提取3-5个中文标签,用顿号分隔。只返回标签,不要解释。";
userMessage = `标题:${title || ""}\n\n内容片段:${(content || "").slice(0, 2000)}`;
break;
default:
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
}
if (!userMessage.trim()) {
return NextResponse.json({ error: "No text provided" }, { status: 400 });
}
const result = await aiChat(systemPrompt, userMessage);
return NextResponse.json({ result });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "AI error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { hugo } from "@/lib/hugo";
export async function POST() {
try {
hugo.stop();
const result = await hugo.start();
return NextResponse.json({ ok: result.ok });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { hugo } from "@/lib/hugo";
import { TG_BOT_TOKEN } from "@/lib/config";
export async function GET() {
const hugoStatus = hugo.status();
return NextResponse.json({
bot: {
enabled: !!TG_BOT_TOKEN,
polling: !!TG_BOT_TOKEN,
},
hugo: hugoStatus,
});
}
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { updateComment, deleteComment } from "@/lib/artalk";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { token, ...data } = body;
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
const result = await updateComment(parseInt(id, 10), data);
return NextResponse.json(result);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
if (body?.token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(body.token);
}
await deleteComment(parseInt(id, 10));
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -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,24 @@
import { NextRequest, NextResponse } from "next/server";
import { login } from "@/lib/artalk";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: "请填写邮箱和密码" },
{ status: 400 }
);
}
const { token, user } = await login(email, password);
return NextResponse.json({ token, user });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "登录失败" },
{ status: 401 }
);
}
}
@@ -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 });
}
}
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { getPages, getComments, createComment } from "@/lib/artalk";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const pageKey = searchParams.get("page_key");
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "50", 10);
const token = searchParams.get("token");
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
if (pageKey) {
const offset = (page - 1) * limit;
const result = await getComments(pageKey, { offset, limit });
return NextResponse.json(result);
} else {
const offset = (page - 1) * limit;
const result = await getPages({ offset, limit });
return NextResponse.json(result);
}
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, ...data } = body;
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
const comment = await createComment(data);
return NextResponse.json(comment);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -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 }
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { deployQueue } from "@/lib/git";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title } = body;
if (!title) {
return NextResponse.json({ error: "Title is required" }, { status: 400 });
}
const result = await deployQueue.enqueue(`发布: ${title}`);
if (result.success) {
return NextResponse.json({ success: true, output: result.output });
}
if (result.conflict) {
return NextResponse.json(
{
error: result.error,
detail: result.output,
},
{ status: 409 }
);
}
return NextResponse.json({ error: result.error }, { status: 500 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Deploy failed" },
{ status: 500 }
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { hugo } from "@/lib/hugo";
import { HUGO_PREVIEW } from "@/lib/config";
export async function GET() {
try {
const result = await hugo.start();
if (result.alreadyRunning || result.ok) {
return NextResponse.json({ running: true, url: HUGO_PREVIEW });
}
return NextResponse.json({ running: false, url: HUGO_PREVIEW });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
export async function DELETE() {
try {
hugo.stop();
return NextResponse.json({ running: false });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { CONTENT_DIR } from "@/lib/config";
interface ImageFile {
name: string;
path: string;
folder: string;
size: number;
url: string;
slug: string;
localUrl: string;
}
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"]);
function scan(dir: string, folder: string, slug: string) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
if (entry.startsWith(".")) continue;
const stat = fs.statSync(full);
if (stat.isDirectory()) {
// Try to read slug from index.md in this folder
let childSlug = slug;
const indexPath = path.join(full, "index.md");
if (fs.existsSync(indexPath)) {
try {
const raw = fs.readFileSync(indexPath, "utf-8");
const { data } = matter(raw);
if (data.slug) childSlug = String(data.slug);
} catch {}
}
scan(full, folder ? `${folder}/${entry}` : entry, childSlug);
} else if (exts.has(path.extname(entry).toLowerCase())) {
const rel = path.relative(CONTENT_DIR, full).replace(/\\/g, "/");
const url = slug ? `/${slug}/${entry}` : `/${rel}`;
const localUrl = `/api/images?file=${encodeURIComponent(rel)}`;
images.push({ name: entry, path: rel, folder, size: stat.size, url, slug, localUrl });
}
}
}
scan(CONTENT_DIR, "", "");
images.sort((a, b) => b.size - a.size);
return NextResponse.json({ images });
} catch {
return NextResponse.json({ images: [] });
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const relPath = searchParams.get("path");
if (!relPath) return NextResponse.json({ error: "path required" }, { status: 400 });
const fullPath = path.join(CONTENT_DIR, relPath);
if (!fullPath.startsWith(CONTENT_DIR)) {
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
}
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "File not found" }, { status: 404 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { getPost, updatePost } from "@/lib/posts";
import { moveToRecycle } from "@/lib/recycle";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const post = getPost(slug);
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json(post);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const body = await request.json();
const { frontMatter, content } = body;
const result = updatePost(slug, frontMatter || {}, content);
if (!result.success) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json({ success: true, dirPath: result.dirPath });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const post = getPost(slug);
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
moveToRecycle(slug, post.dirPath, post.frontMatter.title);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { updatePost, listPosts, getPost } from "@/lib/posts";
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { slugs, action, categories, tags, draft } = body;
if (!slugs || !Array.isArray(slugs) || slugs.length === 0) {
return NextResponse.json({ error: "slugs array required" }, { status: 400 });
}
const results: string[] = [];
for (const slug of slugs) {
const post = getPost(String(slug));
if (!post) { results.push(`Not found: ${slug}`); continue; }
const updates: Record<string, unknown> = {};
if (action === "categories" && categories !== undefined) {
updates.categories = categories;
} else if (action === "tags" && tags !== undefined) {
updates.tags = tags;
} else if (action === "draft" && draft !== undefined) {
updates.draft = draft;
} else if (action === "all") {
if (categories !== undefined) updates.categories = categories;
if (tags !== undefined) updates.tags = tags;
if (draft !== undefined) updates.draft = draft;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid updates" }, { status: 400 });
}
updatePost(String(slug), updates);
results.push(`Updated: ${slug}`);
}
return NextResponse.json({ success: true, results });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { listPosts, createPost, getPostCount } from "@/lib/posts";
import { cleanExpired } from "@/lib/recycle";
export async function GET(request: NextRequest) {
try {
cleanExpired(); // housekeeping on each request
const { searchParams } = request.nextUrl;
const search = searchParams.get("search") || "";
const page = parseInt(searchParams.get("page") || "1", 10);
const perPage = parseInt(searchParams.get("perPage") || "20", 10);
const includeDrafts = searchParams.get("includeDrafts") !== "false";
const result = listPosts({ search, page, perPage, includeDrafts });
const totalCount = getPostCount();
return NextResponse.json({ ...result, totalCount });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { frontMatter, content } = body;
if (!frontMatter?.title || !frontMatter?.slug) {
return NextResponse.json(
{ error: "Title and slug are required" },
{ status: 400 }
);
}
const dirPath = createPost(frontMatter, content || "");
return NextResponse.json({ success: true, dirPath }, { status: 201 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { getFeeds, addFeed, updateFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const health = searchParams.get("health");
if (health) {
const result = await checkLinkViaProxy(health);
return NextResponse.json(result);
}
const feeds = await getFeeds();
return NextResponse.json({ feeds });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, feedTitle } = body;
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await addFeed(url, feedTitle || "");
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { oldUrl, url: newUrl, feedTitle } = body;
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
await updateFeed(oldUrl, newUrl || oldUrl, feedTitle || "");
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const url = request.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await deleteFeed(url);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { getLinks, addLink, updateLink, deleteLink, checkLinkViaProxy } from "@/lib/rssapi";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const all = searchParams.get("all") === "1";
const health = searchParams.get("health");
if (health) {
const result = await checkLinkViaProxy(health);
return NextResponse.json(result);
}
const links = await getLinks(all);
return NextResponse.json({ links });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, url, description, hidden } = body;
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await addLink({ name: name || "", url, description, hidden });
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { oldUrl, url: newUrl, name, description, hidden } = body;
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
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(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const url = request.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await deleteLink(url);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { getWechatStatus, syncWechat } from "@/lib/rssapi";
import { marked } from "marked";
function resolveUrl(src: string, slug: string): string {
if (src.startsWith("http://") || src.startsWith("https://")) return src;
if (src.startsWith("/")) return `https://usj.cc${src}`;
if (slug) return `https://usj.cc/${slug}/${src}`;
return `https://usj.cc/${src}`;
}
function mdToWechatHtml(md: string, slug: string): { html: string; imageUrls: string[] } {
const imageUrls: string[] = [];
// Step 1: Collect reference definitions [ref]: path
const refMap = new Map<string, string>();
for (const m of md.matchAll(/^\s*\[([^\]]+)\]:\s*(\S+)\s*$/gm)) {
refMap.set(m[1].toLowerCase(), resolveUrl(m[2], slug));
}
// Step 2: Convert reference-style images ![alt][ref] to inline ![](url)
let processed = md.replace(
/!\[([^\]]*)\]\[([^\]]+)\]/g,
(_match: string, alt: string, ref: string) => {
const src = refMap.get(ref.toLowerCase());
if (src) {
imageUrls.push(src);
return `![${alt}](${src})`;
}
return _match;
}
);
// Step 3: Convert inline images ![](url)
processed = processed.replace(
/!\[([^\]]*)\]\(([^)\s"]+)\)/g,
(_match: string, alt: string, src: string) => {
const absoluteUrl = resolveUrl(src, slug);
if (!imageUrls.includes(absoluteUrl)) imageUrls.push(absoluteUrl);
return `![${alt}](${absoluteUrl})`;
}
);
// Step 4: Strip reference definition lines + clean blank lines
processed = processed.replace(/^\s*\[[^\]]+\]:\s*\S+\s*$/gm, "");
processed = processed.replace(/\n{3,}/g, "\n\n");
// Convert Markdown to HTML
let html = marked.parse(processed, { async: false }) as string;
// WeChat inline style constants
const S: Record<string, string> = {
h2: "font-size:20px;font-weight:700;color:#1a1a1a;margin:28px 0 12px;padding-bottom:6px;border-bottom:2px solid #1d3d6c;line-height:1.4;",
h3: "font-size:17px;font-weight:600;color:#2c3e50;margin:22px 0 8px;line-height:1.5;",
p: "margin:0 0 12px;line-height:1.9;letter-spacing:0.2px;",
strong: "font-weight:700;color:#1a1a1a;",
em: "font-style:italic;color:#6f7f96;",
code: "background:#f1f1f1;color:#d6336c;padding:2px 6px;border-radius:3px;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;letter-spacing:0;",
pre: "background:#282a36;border:1px solid #44475a;border-radius:10px;padding:16px 18px;margin:16px 0;font-size:13px;line-height:1.7;overflow-x:auto;color:#f8f8f2;letter-spacing:0;",
bq: "border-left:4px solid #1d3d6c;padding:10px 18px;margin:16px 0;background:rgba(29,61,108,0.04);color:#5a6a7a;border-radius:0 6px 6px 0;",
img: "max-width:100%;height:auto;border-radius:4px;display:block;margin:16px auto;",
hr: "border:none;border-top:1px solid #e8e8e8;margin:24px 0;",
ul: "margin:8px 0 14px;padding-left:24px;",
li: "margin:5px 0;line-height:1.9;",
a: "color:#1d3d6c;text-decoration:none;border-bottom:1px solid rgba(29,61,108,0.2);word-break:break-all;",
table: "width:100%;border-collapse:collapse;margin:16px 0;font-size:13px;",
th: "border:1px solid #e0e0e0;padding:10px 14px;background:#f6f8fa;font-weight:600;",
td: "border:1px solid #e0e0e0;padding:10px 14px;",
};
html = html
.replace(/<h2>/g, `<h2 style="${S.h2}">`)
.replace(/<h3>/g, `<h3 style="${S.h3}">`)
.replace(/<p>/g, `<p style="${S.p}">`)
.replace(/<strong>/g, `<strong style="${S.strong}">`)
.replace(/<em>/g, `<em style="${S.em}">`)
.replace(/<code>/g, `<code style="${S.code}">`)
.replace(/<pre><code>/g, `<pre style="${S.pre}"><code>`)
.replace(/<pre>/g, `<pre style="${S.pre}">`)
.replace(/<blockquote>/g, `<blockquote style="${S.bq}">`)
.replace(/<img /g, `<img style="${S.img}" `)
.replace(/<hr>/g, `<hr style="${S.hr}" />`)
.replace(/<hr \/>/g, `<hr style="${S.hr}" />`)
.replace(/<ul>/g, `<ul style="${S.ul}">`)
.replace(/<ol>/g, `<ol style="${S.ul}">`)
.replace(/<li>/g, `<li style="${S.li}">`)
.replace(/<a /g, `<a style="${S.a}" `)
.replace(/<table>/g, `<table style="${S.table}">`)
.replace(/<th>/g, `<th style="${S.th}">`)
.replace(/<td>/g, `<td style="${S.td}">`);
html = `<section style="max-width:100%;word-wrap:break-word;font-size:15px;color:#333;line-height:1.9;text-align:left;">${html}</section>
<div style="display:block;margin-top:28px;padding-top:16px;border-top:1px solid #eee;text-align:center;font-size:12px;color:#bbb;">— END —</div>`;
return { html, imageUrls };
}
export async function GET() {
try {
const status = await getWechatStatus();
return NextResponse.json(status);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { articles } = body;
if (!articles || !Array.isArray(articles) || articles.length === 0) {
return NextResponse.json({ error: "articles is required" }, { status: 400 });
}
const processedArticles = articles.map((a: Record<string, unknown>) => {
const slug = (a.slug as string) || "";
const rawContent = (a.content as string) || "";
const { html, imageUrls } = mdToWechatHtml(rawContent, slug);
const result: Record<string, unknown> = {
title: a.title,
author: a.author,
content: html,
content_source_url: a.content_source_url,
thumb_media_url: a.thumb_media_url,
show_cover_pic: a.show_cover_pic ?? 1,
need_open_comment: a.need_open_comment ?? 0,
};
if (imageUrls.length > 0) {
result.content_image_urls = imageUrls.map((u) => ({ original_url: u }));
}
return result;
});
const result = await syncWechat(processedArticles);
return NextResponse.json(result);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getPostCount } from "@/lib/posts";
import { getLinks } from "@/lib/rssapi";
export async function GET() {
try {
const postCount = getPostCount();
// Try getting RSS stats (non-blocking)
let feedCount = 0;
let linkCount = 0;
try {
const links = await getLinks(true);
linkCount = links.length;
} catch {}
try {
const { getFeeds } = await import("@/lib/rssapi");
const feeds = await getFeeds();
feedCount = feeds.length;
} catch {}
return NextResponse.json({
posts: postCount,
feeds: feedCount,
links: linkCount,
});
} catch {
return NextResponse.json({ posts: 0, feeds: 0, links: 0 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const dirPath = formData.get("dirPath") as string | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only images allowed" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Determine upload directory
let uploadDir: string;
let isArticleFolder = false;
if (dirPath && fs.existsSync(dirPath)) {
uploadDir = dirPath;
isArticleFolder = true;
} else {
// Fallback to static/image
const year = new Date().getFullYear().toString();
uploadDir = path.join(process.cwd(), "..", "static", "image", year);
}
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Generate unique filename
const ext = file.name.split(".").pop() || "png";
const baseName = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const fileName = `${baseName}.${ext}`;
const filePath = path.join(uploadDir, fileName);
fs.writeFileSync(filePath, buffer);
// Article folder: bare filename. Static fallback: absolute from Hugo root
const url = isArticleFolder ? fileName : `/image/${new Date().getFullYear()}/${fileName}`;
return NextResponse.json({ url, fileName });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Upload failed" },
{ status: 500 }
);
}
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import { Bot, RefreshCw, Server } from "lucide-react";
interface BotStatus {
bot: { enabled: boolean; polling: boolean };
hugo: { running: boolean; starting: boolean };
}
export default function BotPage() {
const [status, setStatus] = useState<BotStatus | null>(null);
const [restarting, setRestarting] = useState(false);
async function fetchStatus() {
try {
const res = await fetch("/api/bot/status");
const data = await res.json();
setStatus(data);
} catch {
setStatus(null);
}
}
async function restartHugo() {
setRestarting(true);
try {
await fetch("/api/bot/restart", { method: "POST" });
} catch { /* ignore */ }
setTimeout(async () => {
await fetchStatus();
setRestarting(false);
}, 3000);
}
useEffect(() => {
fetchStatus();
const timer = setInterval(fetchStatus, 10000);
return () => clearInterval(timer);
}, []);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Bot 管理</h1>
{/* Bot Status */}
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Bot size={20} /> Telegram Bot
</h2>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
status?.bot.enabled
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span className="text-sm">
{status?.bot.enabled
? "已配置,随服务自动启动"
: "未配置 TG_BOT_TOKEN"}
</span>
</div>
</div>
{/* Hugo Status */}
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Server size={20} /> Hugo Server
</h2>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
status?.hugo.running
? "bg-green-500"
: status?.hugo.starting
? "bg-yellow-500"
: "bg-gray-400"
}`}
/>
<span className="text-sm">
{status?.hugo.running
? "运行中 — localhost:1313"
: status?.hugo.starting
? "启动中..."
: "已停止"}
</span>
</div>
<button
onClick={restartHugo}
disabled={restarting}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
<RefreshCw size={14} className={restarting ? "animate-spin" : ""} />
{restarting ? "重启中..." : "重启 Hugo"}
</button>
</div>
</div>
);
}
+708
View File
@@ -0,0 +1,708 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key,
CornerDownRight, Send, Edit, X, Check
} 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 [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);
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));
// 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); }
})();
}, [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 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);
// --- Login ---
if (!loggedIn) {
return (
<div className="min-h-full flex items-center justify-center px-4 md:px-8">
<div className="w-full max-w-[340px] mx-auto bg-card border border-line rounded-lg p-6 md: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-4 py-4 md:px-8 md:py-6">
{/* Top bar */}
<div className="flex items-center justify-between mb-5 shrink-0 gap-3 flex-wrap">
<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} /> 退出
</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>
)}
{/* 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-1 sm:grid-cols-3 gap-3 sm:gap-4">
{[
{ label: "文章总数", value: stats.totalArticles },
{ label: "评论总数", value: stats.totalComments },
{ label: "评论用户", value: stats.uniqueUsers },
].map((s) => (
<div key={s.label} className="bg-card 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 flex-col sm:flex-row gap-4 sm:gap-5 min-h-0">
{/* Top articles */}
<div className="flex-1 bg-card 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-card 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 flex-col sm:flex-row gap-4 sm:gap-5 flex-1 min-h-0">
{/* Left: Articles */}
<div className="w-full sm:w-[240px] shrink-0 flex flex-col min-h-0 sm:max-h-full">
<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-card border border-line rounded-md">
{pages.map((p) => (
<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" : ""
}`}>
<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>
)}
</div>
{/* Right: Comments */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{selectedPage ? (
<>
<div className="flex items-center gap-2 mb-2.5 shrink-0">
{editingTitle ? (
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<input
type="text"
value={editTitleText}
onChange={(e) => setEditTitleText(e.target.value)}
className="flex-1 bg-card 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 ? (
<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-card 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-card 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-card border border-line rounded-md text-[13px] text-muted">
<MessageSquare size={18} className="mr-2 text-muted/30" />
选择左侧文章查看评论
</div>
)}
</div>
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react";
import PostEditor from "@/components/PostEditor";
import { HUGO_PREVIEW } from "@/lib/config";
import type { Post } from "@/lib/posts";
export default function EditPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const router = useRouter();
const { slug } = use(params);
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/posts/${slug}`);
if (!res.ok) throw new Error("文章未找到");
setPost(await res.json());
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
load();
}, [slug]);
if (loading) {
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center gap-3 mb-8">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<div className="text-center py-20 text-muted text-sm">加载中...</div>
</div>
);
}
if (error || !post) {
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center gap-3 mb-8">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<div className="text-center py-20 text-danger text-sm">{error || "文章未找到"}</div>
</div>
);
}
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<button
type="button"
onClick={async () => {
await fetch("/api/hugo");
window.open(`${HUGO_PREVIEW}/${post.frontMatter.slug}.html`, "_blank");
}}
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/30 transition-colors"
title="预览"
>
<Eye size={13} /> 预览
</button>
</div>
<p className="text-[13px] text-muted mb-8">{post.frontMatter.title}</p>
<PostEditor
initialData={{ frontMatter: post.frontMatter, content: post.content, dirPath: post.dirPath }}
isEditing
/>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import PostEditor from "@/components/PostEditor";
export default function NewPostPage() {
const router = useRouter();
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center gap-3 mb-8">
<button
type="button"
onClick={() => router.back()}
className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title="返回"
>
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">撰写文章</h2>
</div>
<PostEditor />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Loader2, Wifi, WifiOff, Edit, X } from "lucide-react";
interface Feed {
url: string;
feedTitle: string;
siteUrl?: string;
error?: string;
}
interface FeedHealth {
url: string;
status: number;
ok: boolean;
latency?: number;
error?: string;
}
export default function FeedsPage() {
const router = useRouter();
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
const [checkingAll, setCheckingAll] = useState(false);
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ url: "", feedTitle: "" });
const [adding, setAdding] = useState(false);
// Edit
const [editTarget, setEditTarget] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ url: "", feedTitle: "" });
const [editing, setEditing] = useState(false);
const fetchFeeds = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/rss/feeds");
const data = await res.json();
setFeeds(data.feeds || []);
} catch { setMessage({ type: "error", text: "加载失败" }); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchFeeds(); }, [fetchFeeds]);
const checkOne = async (url: string) => {
setCheckingMap((prev) => new Map(prev).set(url, true));
try {
const res = await fetch(`/api/rss/feeds?health=${encodeURIComponent(url)}`);
const data = await res.json();
setHealth((prev) => new Map(prev).set(url, data));
} catch {} finally {
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
}
};
const checkAll = async () => {
setCheckingAll(true);
for (let i = 0; i < feeds.length; i += 3) {
const batch = feeds.slice(i, i + 3);
await Promise.all(batch.map((f) => checkOne(f.url)));
}
setCheckingAll(false);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm.url) return;
setAdding(true);
try {
const res = await fetch("/api/rss/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) throw new Error("添加失败");
setShowAdd(false);
setAddForm({ url: "", feedTitle: "" });
fetchFeeds();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
} finally { setAdding(false); }
};
const startEdit = (f: Feed) => {
setEditTarget(f.url);
setEditForm({ url: f.url, feedTitle: f.feedTitle });
};
const handleEdit = async () => {
if (!editTarget) return;
setEditing(true);
try {
const res = await fetch("/api/rss/feeds", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oldUrl: editTarget, url: editForm.url, feedTitle: editForm.feedTitle }),
});
if (!res.ok) throw new Error("保存失败");
setEditTarget(null);
fetchFeeds();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setEditing(false); }
};
const handleDelete = async (url: string, title: string) => {
if (!confirm(`确定删除「${title}」?`)) return;
try {
await fetch(`/api/rss/feeds?url=${encodeURIComponent(url)}`, { method: "DELETE" });
setFeeds((prev) => prev.filter((f) => f.url !== url));
} catch { setMessage({ type: "error", text: "删除失败" }); }
};
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center justify-between mb-6 sm:mb-8 gap-3 flex-wrap">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">订阅源管理</h2>
<span className="text-[13px] text-muted">{feeds.length} 个</span>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={checkAll} disabled={checkingAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
{checkingAll ? "检测中..." : "检测存活"}
</button>
<button type="button" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={13} /> 添加
</button>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
{showAdd && (
<div className="mb-6 p-5 bg-card border border-line rounded-lg">
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">博客名称</label>
<input type="text" value={addForm.feedTitle} onChange={(e) => setAddForm({ ...addForm, feedTitle: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30"
placeholder="例如:刘郎阁" />
</div>
<div className="flex-[2]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">RSS/站点 URL</label>
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30"
placeholder="https://example.com" required />
</div>
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加订阅源">
{adding ? "..." : "添加"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
</form>
</div>
)}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : feeds.length === 0 ? (
<div className="text-center py-20 text-muted text-sm">暂无订阅源</div>
) : (
<div className="space-y-2">
{feeds.map((f) => {
const h = health.get(f.url);
const ck = checkingMap.get(f.url);
return (
<div key={f.url} className={`flex items-center gap-4 px-5 py-3.5 bg-card border rounded-md group transition-all ${
editTarget === f.url ? "ring-2 ring-accent/20 border-accent" : "border-line"
}`}>
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
: null}
{editTarget === f.url ? (
<div className="flex-1 flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1">
<input type="text" value={editForm.feedTitle} onChange={(e) => setEditForm({ ...editForm, feedTitle: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="博客名称" />
</div>
<div className="flex-[2]">
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
</div>
<button type="button" onClick={handleEdit} disabled={editing}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
<button type="button" onClick={() => setEditTarget(null)}
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
</div>
) : (
<div className="flex-1 min-w-0">
<div className="font-medium text-[14px] text-text truncate">{f.feedTitle || f.url}</div>
<div className="text-[11px] text-muted truncate mt-0.5">{f.url}</div>
</div>
)}
{editTarget !== f.url && (
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 md:group-hover:opacity-100 transition-all">
<button type="button" onClick={() => startEdit(f)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="编辑">
<Edit size={13} />
</button>
<button type="button" onClick={() => handleDelete(f.url, f.feedTitle)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="删除">
<Trash2 size={13} />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
@import "tailwindcss";
:root {
color-scheme: light;
--bg: #fcfaf5;
--surface: #f6f1e7;
--card: #fffdf8;
--text: #223d66;
--muted: #6f7f96;
--line: #e4dfd2;
--accent: #1d3d6c;
--accent-hover: #2a5180;
--danger: #d75a4a;
--danger-hover: #c45151;
--success: #39b86f;
--warning: #c09a46;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #111827;
--surface: #1b2638;
--card: #243247;
--text: #f3f7fd;
--muted: #c1ccdc;
--line: #51627a;
--accent: #9fc4f4;
--accent-hover: #bfdbfe;
--danger: #fca5a5;
--danger-hover: #fecaca;
--success: #86efac;
--warning: #f3d58a;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-card: var(--card);
--color-text: var(--text);
--color-muted: var(--muted);
--color-line: var(--line);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-danger: var(--danger);
--color-danger-hover: var(--danger-hover);
--color-success: var(--success);
--color-warning: var(--warning);
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="dark"] input,
:root[data-theme="dark"] textarea,
:root[data-theme="dark"] select {
color: var(--text);
}
:root[data-theme="dark"] input::placeholder,
:root[data-theme="dark"] textarea::placeholder {
color: #aebbd0;
}
:root[data-theme="dark"] .text-muted\/30,
:root[data-theme="dark"] .text-muted\/35,
:root[data-theme="dark"] .text-muted\/40,
:root[data-theme="dark"] .text-muted\/50 {
color: #aebbd0;
}
:root[data-theme="dark"] .text-text\/70,
:root[data-theme="dark"] .text-text\/80 {
color: #d8e2f0;
}
:root[data-theme="dark"] .border-line\/50 {
border-color: #43546d;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* Focus ring */
*:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Trash2, Copy, Check, Image as ImageIcon } from "lucide-react";
const HUGO_SITE = "https://usj.cc";
interface ImageFile {
name: string;
path: string;
folder: string;
size: number;
url: string;
localUrl: string;
}
export default function ImagesPage() {
const router = useRouter();
const [images, setImages] = useState<ImageFile[]>([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const fetchImages = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/images");
setImages((await res.json()).images || []);
} catch {} finally { setLoading(false); }
}, []);
useEffect(() => { fetchImages(); }, [fetchImages]);
const copyMarkdown = (img: ImageFile) => {
const md = `![](./${img.name})`;
navigator.clipboard.writeText(md).then(() => {
setCopied(img.name);
setTimeout(() => setCopied(null), 2000);
}).catch(() => {});
};
const handleDelete = async (img: ImageFile) => {
if (!confirm(`确定删除「${img.name}」?此操作不可撤销。`)) return;
try {
const res = await fetch(`/api/images?path=${encodeURIComponent(img.path)}`, { method: "DELETE" });
if (!res.ok) throw new Error("删除失败");
setImages((prev) => prev.filter((x) => x.path !== img.path));
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "删除失败" });
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">图片管理</h2>
<span className="text-[13px] text-muted">{images.length} 张</span>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] ${message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="ml-2 underline">关闭</button>
</div>
)}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : images.length === 0 ? (
<div className="text-center py-20">
<ImageIcon size={28} className="mx-auto text-muted/25 mb-3" />
<p className="text-sm text-muted">暂无图片</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{images.map((img) => (
<div key={img.path} className="bg-card 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={img.localUrl}
alt={img.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
<div className="p-2.5 space-y-1.5">
<div className="text-[11px] text-text truncate" title={img.name}>{img.name}</div>
<div className="flex items-center justify-between text-[10px] text-muted">
<span>{formatSize(img.size)}</span>
<span className="truncate ml-2">{img.folder || "根目录"}</span>
</div>
<div className="flex items-center gap-1 pt-1 border-t border-line/50">
<button type="button" onClick={() => copyMarkdown(img)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1 text-[10px] rounded hover:bg-surface text-muted hover:text-accent transition-colors">
{copied === img.name ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copied === img.name ? "已复制" : "MD"}
</button>
<button type="button" onClick={() => handleDelete(img)}
className="px-1.5 py-1 text-[10px] rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors">
<Trash2 size={11} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
export const metadata: Metadata = {
title: "写作后台",
description: "Blog admin for Hugo",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const themeScript = `
try {
if (localStorage.getItem("write-theme") === "dark") {
document.documentElement.dataset.theme = "dark";
}
} catch {}
`;
return (
<html lang="zh-CN" className="h-full" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="h-full md:flex bg-bg text-text">
<Sidebar />
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
{children}
</main>
</body>
</html>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Eye, EyeOff, Edit, Wifi, WifiOff, Loader2, X } from "lucide-react";
interface Link {
name: string;
url: string;
description?: string;
image?: string;
hidden?: boolean;
rss?: string;
addedAt?: string;
}
export default function LinksPage() {
const router = useRouter();
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ name: "", url: "", description: "" });
const [adding, setAdding] = useState(false);
// Edit form
const [editTarget, setEditTarget] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ name: "", url: "", description: "" });
const [editing, setEditing] = useState(false);
// Health
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
const [checkingAll, setCheckingAll] = useState(false);
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
const fetchLinks = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/rss/links?all=1`); // always get all for admin
const data = await res.json();
const all = data.links || [];
// Sort visible first, then hidden
all.sort((a: Link, b: Link) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
setLinks(all);
} catch { setMessage({ type: "error", text: "加载失败" }); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchLinks(); }, [fetchLinks]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm.name || !addForm.url) return;
setAdding(true);
try {
const res = await fetch("/api/rss/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) throw new Error("添加失败");
setShowAdd(false);
setAddForm({ name: "", url: "", description: "" });
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
} finally { setAdding(false); }
};
const startEdit = (l: Link) => {
setEditTarget(l.url);
setEditForm({ name: l.name, url: l.url, description: l.description || "" });
};
const handleEdit = async () => {
if (!editTarget) return;
setEditing(true);
try {
const body = {
oldUrl: editTarget,
name: editForm.name,
url: editForm.url,
description: editForm.description,
};
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("保存失败");
setEditTarget(null);
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setEditing(false); }
};
const toggleHidden = async (l: Link) => {
try {
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldUrl: l.url,
name: l.name,
description: l.description || "",
hidden: !l.hidden,
}),
});
if (!res.ok) throw new Error("操作失败");
setLinks((prev) => prev.map((x) => x.url === l.url ? { ...x, hidden: !x.hidden } : x));
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "操作失败" });
}
};
const handleDelete = async (url: string, name: string) => {
if (!confirm(`确定删除「${name}」?`)) return;
try {
await fetch(`/api/rss/links?url=${encodeURIComponent(url)}`, { method: "DELETE" });
setLinks((prev) => prev.filter((l) => l.url !== url));
} catch { setMessage({ type: "error", text: "删除失败" }); }
};
const checkOne = async (url: string) => {
setCheckingMap((prev) => new Map(prev).set(url, true));
try {
const res = await fetch(`/api/rss/links?health=${encodeURIComponent(url)}`);
const data = await res.json();
setHealth((prev) => new Map(prev).set(url, data));
} catch {} finally {
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
}
};
const checkAll = async () => {
setCheckingAll(true);
const visible = links.filter((l) => !l.hidden);
for (let i = 0; i < visible.length; i += 3) {
const batch = visible.slice(i, i + 3);
await Promise.all(batch.map((l) => checkOne(l.url)));
}
setCheckingAll(false);
};
// Counts for display
const visibleLinks = showAll ? links : links.filter((l) => !l.hidden);
const hiddenCount = links.filter((l) => l.hidden).length;
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center justify-between mb-6 sm:mb-8 gap-3 flex-wrap">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">友链管理</h2>
<span className="text-[13px] text-muted">{links.length} 个{hiddenCount > 0 && `(${hiddenCount} 隐藏)`}</span>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={checkAll} disabled={checkingAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
{checkingAll ? "检测中..." : "检测存活"}
</button>
<button type="button" onClick={() => setShowAll(!showAll)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
showAll ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}>
{showAll ? <Eye size={13} /> : <EyeOff size={13} />}
{showAll ? "全部" : "可见"}
</button>
<button type="button" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={13} /> 添加
</button>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
{/* Add form */}
{showAdd && (
<div className="mb-6 p-5 bg-card border border-line rounded-lg">
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">网站名称</label>
<input type="text" value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-[2]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">URL</label>
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">描述</label>
<input type="text" value={addForm.description} onChange={(e) => setAddForm({ ...addForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" />
</div>
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加">
{adding ? "..." : "添加"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
</form>
</div>
)}
{/* Loading */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : visibleLinks.length === 0 ? (
<div className="text-center py-20 text-muted text-sm">暂无友链</div>
) : (
<div className="space-y-2">
{visibleLinks.map((l) => {
const h = health.get(l.url);
const ck = checkingMap.get(l.url);
return (
<div key={l.url}
className={`flex items-center gap-4 px-5 py-3.5 border rounded-md group transition-all ${
l.hidden ? "bg-surface/50 border-line/50 opacity-60" : "bg-card border-line"
} ${editTarget === l.url ? "ring-2 ring-accent/20 border-accent" : ""}`}>
{/* Health indicator */}
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
: null}
{/* Favicon */}
{l.image && <img src={l.image} alt="" className="w-4 h-4 rounded shrink-0" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />}
{/* Info or Edit form */}
{editTarget === l.url ? (
<div className="flex-1 flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1">
<input type="text" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="名称" />
</div>
<div className="flex-[2]">
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
</div>
<div className="flex-1">
<input type="text" value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" placeholder="描述" title="描述" />
</div>
<button type="button" onClick={handleEdit} disabled={editing}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
<button type="button" onClick={() => setEditTarget(null)}
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
</div>
) : (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-[14px] text-text">{l.name}</span>
{l.hidden && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded font-medium">隐藏</span>}
{h && !h.ok && <span className="text-[10px] text-danger bg-danger/8 px-1.5 py-px rounded font-medium">失联</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted">
<span className="truncate">{l.url}</span>
{l.description && <span className="text-line">·</span>}
{l.description && <span className="truncate">{l.description}</span>}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
{editTarget !== l.url && (
<>
<button type="button" onClick={() => checkOne(l.url)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
title="检测">
{checkingMap.get(l.url) ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
</button>
<button type="button" onClick={() => startEdit(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title="编辑">
<Edit size={13} />
</button>
<button type="button" onClick={() => toggleHidden(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title={l.hidden ? "显示" : "隐藏"}>
{l.hidden ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button type="button" onClick={() => handleDelete(l.url, l.name)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors"
title="删除">
<Trash2 size={13} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Search, Edit, Trash2, Plus, FileText, Eye,
FileText as PostIcon, Rss, Link2, GripVertical, X, CheckSquare, Square,
} from "lucide-react";
import Pagination from "@/components/Pagination";
import { HUGO_PREVIEW } from "@/lib/config";
interface PostItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
author: string;
}
export default function HomePage() {
const router = useRouter();
const [posts, setPosts] = useState<PostItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// Stats
const [stats, setStats] = useState<{ posts: number; feeds: number; links: number } | null>(null);
// Batch
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchAction, setBatchAction] = useState<"categories" | "tags" | "draft" | null>(null);
const [batchValue, setBatchValue] = useState("");
const [batching, setBatching] = useState(false);
useEffect(() => {
fetch("/api/stats").then(r => r.json()).then(setStats).catch(() => {});
}, []);
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const p = Math.max(1, Math.floor(page));
const params = new URLSearchParams({ page: p.toString(), search: search || "", perPage: "20" });
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error("加载失败");
const data = await res.json();
setPosts(data.posts || []);
setTotal(data.total || 0);
setTotalPages(Math.max(1, data.totalPages || 1));
setTotalCount(data.totalCount || 0);
} catch {
setPosts([]); setTotal(0); setTotalPages(1);
} finally { setLoading(false); }
}, [page, search]);
useEffect(() => { fetchPosts(); }, [fetchPosts]);
const handleDelete = async (slug: string, title: string) => {
if (!confirm(`确定删除「${title}」吗?`)) return;
await fetch(`/api/posts/${slug}`, { method: "DELETE" });
fetchPosts();
};
const handlePreview = async (slug: string) => {
await fetch("/api/hugo");
window.open(`${HUGO_PREVIEW}/${slug}.html`, "_blank");
};
const toggleSelect = (slug: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const toggleAll = () => {
if (selected.size === posts.length) setSelected(new Set());
else setSelected(new Set(posts.map((p) => p.slug)));
};
const handleBatchApply = async () => {
if (selected.size === 0 || !batchAction) return;
setBatching(true);
try {
const body: Record<string, unknown> = {
slugs: Array.from(selected),
action: batchAction,
};
if (batchAction === "draft") {
body.draft = batchValue === "true";
} else if (batchAction === "categories") {
body.categories = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
} else if (batchAction === "tags") {
body.tags = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
}
const res = await fetch("/api/posts/batch", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("批量操作失败");
setSelected(new Set());
setBatchAction(null);
setBatchValue("");
setSelectMode(false);
fetchPosts();
} catch {} finally { setBatching(false); }
};
const formatDate = (d: string) => {
if (!d) return "-";
return new Date(d).toLocaleDateString("zh-CN");
};
return (
<div className="px-4 py-6 md:px-8 md:py-8">
{/* Dashboard stats: hidden on mobile, full cards on >=sm */}
{stats && (
<div className="hidden sm:grid sm:grid-cols-3 gap-4 mb-8">
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-accent/8 rounded-lg flex items-center justify-center">
<PostIcon size={17} className="text-accent" />
</div>
<div>
<div className="text-[11px] text-muted">文章总数</div>
<div className="text-lg font-bold text-text">{stats.posts}</div>
</div>
</div>
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-success/10 rounded-lg flex items-center justify-center">
<Rss size={17} className="text-success" />
</div>
<div>
<div className="text-[11px] text-muted">订阅源</div>
<div className="text-lg font-bold text-text">{stats.feeds}</div>
</div>
</div>
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-[#c09a46]/10 rounded-lg flex items-center justify-center">
<Link2 size={17} className="text-[#c09a46]" />
</div>
<div>
<div className="text-[11px] text-muted">友链</div>
<div className="text-lg font-bold text-text">{stats.links}</div>
</div>
</div>
</div>
)}
{/* Header */}
<div className="hidden md:flex items-center justify-between mb-6 gap-3 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-1">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">文章管理</h2>
</div>
<p className="text-[13px] text-muted mt-0.5">共 {totalCount} 篇文章</p>
</div>
<div className="flex items-center gap-2">
{/* Batch toggle */}
<button
type="button"
onClick={() => { setSelectMode(!selectMode); setSelected(new Set()); setBatchAction(null); }}
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] rounded-md border transition-colors ${
selectMode ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}
>
<CheckSquare size={14} />
{selectMode ? "取消" : "批量"}
</button>
<Link href="/edit" className="inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={15} /> 新建
</Link>
</div>
</div>
{/* Batch action bar */}
{selectMode && selected.size > 0 && (
<div className="mb-4 p-3 bg-accent/5 border border-accent/20 rounded-lg flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-text">已选 {selected.size} 篇</span>
<select
value={batchAction || ""}
onChange={(e) => { setBatchAction(e.target.value as typeof batchAction || null); setBatchValue(""); }}
className="bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
>
<option value="">选择操作...</option>
<option value="categories">修改分类</option>
<option value="tags">修改标签</option>
<option value="draft">设为草稿/发布</option>
</select>
{batchAction === "draft" ? (
<select value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none">
<option value="">选择状态...</option>
<option value="true">设为草稿</option>
<option value="false">设为发布</option>
</select>
) : batchAction ? (
<input type="text" value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="flex-1 bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
placeholder={batchAction === "tags" ? "用顿号分隔" : "用顿号分隔"} />
) : null}
{batchAction && batchValue && (
<button type="button" onClick={handleBatchApply} disabled={batching}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50">
{batching ? "处理中..." : `应用到 ${selected.size} 篇`}
</button>
)}
<button type="button" onClick={() => setSelected(new Set())} className="p-1 rounded text-muted hover:text-text"><X size={14} /></button>
</div>
)}
{/* Search + (mobile) quick actions */}
<div className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted/50" />
<input type="text" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索标题、分类..." className="w-full pl-9 pr-4 py-2.5 bg-card border border-line rounded-md text-[13px] text-text placeholder:text-muted/40 focus:outline-none focus:border-accent/30 transition-colors" />
</div>
<button
type="button"
onClick={() => { setSelectMode(!selectMode); setSelected(new Set()); setBatchAction(null); }}
aria-label="批量"
className={`md:hidden shrink-0 h-[38px] w-[38px] inline-flex items-center justify-center rounded-md border transition-colors ${
selectMode ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}
>
<CheckSquare size={16} />
</button>
<Link
href="/edit"
aria-label="新建"
className="md:hidden shrink-0 h-[38px] w-[38px] inline-flex items-center justify-center rounded-md bg-accent text-white hover:bg-accent-hover transition-colors"
>
<Plus size={17} />
</Link>
</div>
{/* Article list */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : posts.length === 0 ? (
<div className="text-center py-20">
<FileText size={28} className="mx-auto text-muted/25 mb-3" />
<p className="text-sm text-muted">{search ? "没有匹配的文章" : "暂无文章"}</p>
</div>
) : (
<div className="space-y-2">
{/* Select all checkbox */}
{selectMode && (
<div className="flex items-center gap-2 px-4 py-2">
<button type="button" onClick={toggleAll} className="text-[12px] text-muted hover:text-accent">
{selected.size === posts.length ? "取消全选" : "全选"}
</button>
</div>
)}
{posts.map((post, i) => (
<div key={`${post.slug}-${i}`}
className="group flex items-center gap-4 px-5 py-3.5 bg-card border border-line rounded-md hover:border-accent/15 transition-all">
{/* Select checkbox */}
{selectMode && (
<button type="button" onClick={() => toggleSelect(post.slug)} className="shrink-0">
{selected.has(post.slug)
? <CheckSquare size={16} className="text-accent" />
: <Square size={16} className="text-muted/40" />}
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="font-medium text-[14px] text-text truncate hover:text-accent transition-colors text-left">
{post.title}
</button>
{post.draft ? (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#fef3c7] text-[#92400e] font-medium">草稿</span>
) : (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#d1fae5] text-[#065f46] font-medium">已发布</span>
)}
</div>
<div className="flex items-center gap-2 text-[12px] text-muted">
<span>{formatDate(post.date)}</span>
{post.categories.length > 0 && <><span className="text-line">|</span><span>{post.categories.join("、")}</span></>}
</div>
</div>
{!selectMode && (
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => handlePreview(post.slug)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="预览"><Eye size={14} /></button>
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="编辑"><Edit size={14} /></button>
<button type="button" onClick={() => handleDelete(post.slug, post.title)}
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="删除"><Trash2 size={14} /></button>
</div>
)}
</div>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{total > 0 && <p className="text-center text-xs text-muted mt-2">第 {page} 页 / 共 {totalPages} 页({total} 篇)</p>}
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Send, Loader2, Check, FileText } from "lucide-react";
interface PostItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
author: string;
}
export default function WechatPage() {
const router = useRouter();
const [posts, setPosts] = useState<PostItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<PostItem | null>(null);
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [ipWhitelistError, setIpWhitelistError] = useState<{ ip: string; detail: string } | null>(null);
const [syncSuccess, setSyncSuccess] = useState<{ title: string } | null>(null);
const [status, setStatus] = useState<{ configured: boolean; appId?: string } | null>(null);
const [coverUrl, setCoverUrl] = useState("");
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/posts?perPage=999&includeDrafts=false");
const data = await res.json();
setPosts(data.posts || []);
} catch {} finally { setLoading(false); }
}, []);
useEffect(() => {
fetchPosts();
fetch("/api/rss/wechat").then(r => r.json()).then(setStatus).catch(() => {});
}, [fetchPosts]);
const filtered = search
? posts.filter((p) => p.title.includes(search) || p.slug.includes(search))
: posts;
const handleSync = async () => {
if (!selected) return;
setSyncing(true);
setMessage(null);
try {
// Fetch full post content
const res = await fetch(`/api/posts/${selected.slug}`);
if (!res.ok) throw new Error("文章加载失败");
const post = await res.json();
// Send to WeChat via RSS API proxy (60s timeout)
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60000);
const syncRes = await fetch("/api/rss/wechat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
articles: [{
title: post.frontMatter.title.slice(0, 32),
author: (post.frontMatter.author || "小赵").slice(0, 16),
content: post.content,
slug: post.frontMatter.slug,
content_source_url: `https://usj.cc/${post.frontMatter.slug}.html`,
thumb_media_url: coverUrl || "https://usj.cc/image/avatar/tx7.jpg",
show_cover_pic: 1,
need_open_comment: 0,
}],
}),
signal: controller.signal,
});
clearTimeout(timer);
const data = await syncRes.json();
if (!syncRes.ok || !data.ok) throw new Error(data.error || data.message || "同步失败");
setSyncSuccess({ title: selected.title });
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
setMessage({ type: "error", text: "请求超时,微信接口响应较慢,请重试" });
} else {
const text = err instanceof Error ? err.message : "网络错误,请检查连接后重试";
const ipMatch = text.match(/invalid ip\s+([^\s,]+)/i);
if (/not in whitelist|code=40164|invalid ip/i.test(text)) {
setIpWhitelistError({ ip: ipMatch?.[1] || "报错中显示的 IP", detail: text });
}
setMessage({ type: "error", text });
}
} finally { setSyncing(false); }
};
return (
<div className="h-full flex flex-col px-4 py-4 md:px-8 md:py-6">
<div className="flex items-center justify-between mb-6 shrink-0">
<div className="flex items-center gap-3">
<button type="button" aria-label="返回上一页" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">微信公众号</h2>
{status && (
<span className={`text-[11px] px-2 py-0.5 rounded ${status.configured ? "bg-[#d1fae5] text-[#065f46]" : "bg-[#fef3c7] text-[#92400e]"}`}>
{status.configured ? `已配置 · ${status.appId}` : "未配置"}
</span>
)}
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between shrink-0 ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 flex-1 min-h-0">
{/* Left: article list */}
<div className="w-full sm:w-[260px] sm:max-w-[300px] shrink-0 flex flex-col min-h-0 sm:max-h-full">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索文章..."
className="w-full bg-card border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30 mb-3 shrink-0"
/>
{loading ? (
<div className="flex-1 flex items-center justify-center text-muted text-sm"><Loader2 size={14} className="animate-spin mr-2" />加载中</div>
) : (
<div className="flex-1 overflow-y-auto bg-card border border-line rounded-md">
{filtered.map((p, i) => (
<button key={`${p.slug}-${i}`} type="button" onClick={() => setSelected(p)}
className={`w-full text-left px-3.5 py-2.5 border-b border-line/50 text-[13px] transition-colors hover:bg-surface ${
selected?.slug === p.slug ? "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">
{new Date(p.date).toLocaleDateString("zh-CN")} · {p.author}
</div>
</button>
))}
</div>
)}
</div>
{/* Right: preview & sync */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{selected ? (
<>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3 shrink-0">
<div className="text-[13px] font-medium text-text truncate min-w-0">{selected.title}</div>
<button
type="button"
onClick={handleSync}
disabled={syncing}
className="shrink-0 w-full sm:w-auto inline-flex items-center justify-center gap-1.5 px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors whitespace-nowrap"
>
{syncing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
<span>{syncing ? "同步中..." : "同步到微信草稿"}</span>
</button>
</div>
<div className="flex-1 bg-card border border-line rounded-md p-4 overflow-y-auto">
<div className="text-[11px] text-muted/60 mb-3 flex items-center gap-2">
<span>预览</span>
<span className="text-line">|</span>
<span>slug: {selected.slug}</span>
<span className="text-line">|</span>
<span>{new Date(selected.date).toLocaleDateString("zh-CN")}</span>
</div>
<div className="space-y-2 text-[12px] text-muted">
<div><span className="font-medium text-text/70">标题:</span>{(selected.title || "").slice(0, 32)}</div>
<div><span className="font-medium text-text/70">作者:</span>{(selected.author || "小赵").slice(0, 16)}</div>
<div><span className="font-medium text-text/70">原文链接:</span>https://usj.cc/{selected.slug}.html</div>
<div className="flex items-center gap-2">
<span className="font-medium text-text/70 shrink-0">封面图:</span>
<input
type="text"
value={coverUrl}
onChange={(e) => setCoverUrl(e.target.value)}
placeholder="https://... 留空则无封面"
className="flex-1 bg-bg border border-line rounded px-2 py-1 text-[12px] focus:outline-none focus:border-accent/30"
/>
</div>
<div><span className="font-medium text-text/70">分类:</span>{selected.categories?.join("、") || "-"}</div>
</div>
<div className="mt-4 p-3 bg-surface rounded-md border border-line/50">
<div className="text-[11px] font-medium text-muted mb-1">同步说明</div>
<ul className="text-[11px] text-muted/70 space-y-0.5">
<li>· 文章将同步到公众号草稿箱,不会自动群发</li>
<li>· 封面图使用文章内第一张图片(如有)</li>
<li>· Markdown 将由 RSS API 转为微信富文本</li>
<li>· 在公众号后台「草稿箱」可预览和修改</li>
</ul>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center bg-card border border-line rounded-md text-[13px] text-muted">
<FileText size={18} className="mr-2 text-muted/30" />
选择左侧文章同步到微信草稿箱
</div>
)}
</div>
</div>
{syncSuccess && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#223d66]/25 px-4 backdrop-blur-sm">
<div className="w-full max-w-[460px] rounded-2xl border border-line bg-bg p-6 shadow-[0_24px_80px_rgba(34,61,102,0.22)]">
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#d1fae5] text-[#065f46]">
<Check size={18} />
</div>
<div>
<h3 className="text-[17px] font-semibold text-text">已同步到微信草稿箱</h3>
<p className="mt-1 text-[13px] leading-6 text-muted">
文章已经进入公众号草稿箱,可以去微信公众平台预览、调整排版,然后再决定是否群发。
</p>
</div>
</div>
<div className="rounded-xl border border-line bg-surface/70 px-4 py-3 text-[12px] leading-5 text-muted">
当前文章:<span className="font-medium text-text">{syncSuccess.title}</span>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setSyncSuccess(null)}
className="rounded-md border border-line px-4 py-2 text-[13px] text-muted transition-colors hover:bg-surface hover:text-text"
>
我知道了
</button>
<a
href="https://mp.weixin.qq.com/"
target="_blank"
rel="noopener noreferrer"
className="rounded-md bg-accent px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-accent-hover"
>
打开微信公众平台
</a>
</div>
</div>
</div>
)}
{ipWhitelistError && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#223d66]/25 px-4 backdrop-blur-sm">
<div className="w-full max-w-[460px] rounded-2xl border border-line bg-bg p-6 shadow-[0_24px_80px_rgba(34,61,102,0.22)]">
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#fef3c7] text-[#92400e]">
!
</div>
<div>
<h3 className="text-[17px] font-semibold text-text">需要添加微信公众号 IP 白名单</h3>
<p className="mt-1 text-[13px] leading-6 text-muted">
微信拒绝了当前接口的出口 IP,请到微信公众号后台的「设置与开发 → 基本配置 → IP 白名单」添加下面这个 IP 后再重试。
</p>
</div>
</div>
<div className="rounded-xl border border-line bg-surface/70 px-4 py-3">
<div className="text-[11px] text-muted">需要添加的 IP</div>
<div className="mt-1 select-all font-mono text-[18px] font-semibold text-text">{ipWhitelistError.ip}</div>
</div>
<div className="mt-3 max-h-24 overflow-y-auto rounded-lg bg-card px-3 py-2 text-[11px] leading-5 text-muted/80">
{ipWhitelistError.detail}
</div>
<div className="mt-6 flex justify-end gap-3">
<a
href="https://mp.weixin.qq.com/"
target="_blank"
rel="noopener noreferrer"
className="rounded-md border border-line px-4 py-2 text-[13px] text-muted transition-colors hover:bg-surface hover:text-text"
>
打开微信公众平台
</a>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(ipWhitelistError.ip)}
className="rounded-md border border-line px-4 py-2 text-[13px] text-muted transition-colors hover:bg-surface hover:text-text"
>
复制 IP
</button>
<button
type="button"
onClick={() => setIpWhitelistError(null)}
className="rounded-md bg-accent px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-accent-hover"
>
我知道了
</button>
</div>
</div>
</div>
)}
</div>
);
}