diff --git a/write/src/app/api/comments/cleanup/route.ts b/write/src/app/api/comments/cleanup/route.ts new file mode 100644 index 00000000..ce33fcff --- /dev/null +++ b/write/src/app/api/comments/cleanup/route.ts @@ -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).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(); + 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 } + ); + } +} diff --git a/write/src/app/api/comments/pages/[id]/route.ts b/write/src/app/api/comments/pages/[id]/route.ts new file mode 100644 index 00000000..8a608321 --- /dev/null +++ b/write/src/app/api/comments/pages/[id]/route.ts @@ -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 }); + } +} diff --git a/write/src/app/api/comments/stats/route.ts b/write/src/app/api/comments/stats/route.ts new file mode 100644 index 00000000..d344a711 --- /dev/null +++ b/write/src/app/api/comments/stats/route.ts @@ -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(); + + // 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 } + ); + } +} diff --git a/write/src/app/api/images/route.ts b/write/src/app/api/images/route.ts index f5f79886..2a8508b3 100644 --- a/write/src/app/api/images/route.ts +++ b/write/src/app/api/images/route.ts @@ -11,10 +11,39 @@ interface ImageFile { size: number; url: string; slug: string; + localUrl: string; } -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = request.nextUrl; + const serveFile = searchParams.get("file"); + + // Mode 2: Serve a specific image file (relative path from CONTENT_DIR) + if (serveFile) { + const fullPath = path.join(CONTENT_DIR, serveFile); + if (!fullPath.startsWith(CONTENT_DIR)) { + return NextResponse.json({ error: "Invalid path" }, { status: 403 }); + } + if (!fs.existsSync(fullPath)) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + const ext = path.extname(fullPath).toLowerCase(); + const mimeMap: Record = { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", + }; + const buffer = fs.readFileSync(fullPath); + return new NextResponse(buffer, { + headers: { + "Content-Type": mimeMap[ext] || "application/octet-stream", + "Cache-Control": "public, max-age=3600", + "Content-Length": String(buffer.length), + }, + }); + } + + // Mode 1: List all images const images: ImageFile[] = []; const exts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"]); @@ -38,9 +67,9 @@ export async function GET() { scan(full, folder ? `${folder}/${entry}` : entry, childSlug); } else if (exts.has(path.extname(entry).toLowerCase())) { const rel = path.relative(CONTENT_DIR, full).replace(/\\/g, "/"); - // Hugo serves page resources at // const url = slug ? `/${slug}/${entry}` : `/${rel}`; - images.push({ name: entry, path: full, folder, size: stat.size, url, slug }); + const localUrl = `/api/images?file=${encodeURIComponent(rel)}`; + images.push({ name: entry, path: rel, folder, size: stat.size, url, slug, localUrl }); } } } @@ -58,15 +87,16 @@ export async function GET() { export async function DELETE(request: NextRequest) { try { const { searchParams } = request.nextUrl; - const filePath = searchParams.get("path"); - if (!filePath) return NextResponse.json({ error: "path required" }, { status: 400 }); + const relPath = searchParams.get("path"); + if (!relPath) return NextResponse.json({ error: "path required" }, { status: 400 }); - if (!filePath.startsWith(CONTENT_DIR)) { + const fullPath = path.join(CONTENT_DIR, relPath); + if (!fullPath.startsWith(CONTENT_DIR)) { return NextResponse.json({ error: "Invalid path" }, { status: 403 }); } - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); return NextResponse.json({ success: true }); } return NextResponse.json({ error: "File not found" }, { status: 404 }); diff --git a/write/src/app/api/rss/feeds/route.ts b/write/src/app/api/rss/feeds/route.ts index ebe49c59..246c38fb 100644 --- a/write/src/app/api/rss/feeds/route.ts +++ b/write/src/app/api/rss/feeds/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getFeeds, addFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi"; +import { getFeeds, addFeed, updateFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi"; export async function GET(request: NextRequest) { try { @@ -43,9 +43,7 @@ export async function PUT(request: NextRequest) { if (!oldUrl) { return NextResponse.json({ error: "oldUrl is required" }, { status: 400 }); } - // Delete old, add new (RSS API has no update endpoint for feeds) - await deleteFeed(oldUrl); - await addFeed(newUrl || oldUrl, feedTitle || ""); + await updateFeed(oldUrl, newUrl || oldUrl, feedTitle || ""); return NextResponse.json({ success: true }); } catch (error: unknown) { return NextResponse.json( diff --git a/write/src/app/api/rss/links/route.ts b/write/src/app/api/rss/links/route.ts index a7c47cff..63bc21f2 100644 --- a/write/src/app/api/rss/links/route.ts +++ b/write/src/app/api/rss/links/route.ts @@ -44,17 +44,12 @@ export async function PUT(request: NextRequest) { if (!oldUrl) { return NextResponse.json({ error: "oldUrl is required" }, { status: 400 }); } - // If URL changed, delete old and create new - if (newUrl && newUrl !== oldUrl) { - await deleteLink(oldUrl); - await addLink({ name: name || "", url: newUrl, description, hidden }); - } else { - const data: Record = {}; - if (name !== undefined) data.name = name; - if (description !== undefined) data.description = description; - if (hidden !== undefined) data.hidden = hidden; - await updateLink(oldUrl, data); - } + const data: Record = {}; + 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( diff --git a/write/src/app/comments/page.tsx b/write/src/app/comments/page.tsx index 5fd4bc06..6f3d15c4 100644 --- a/write/src/app/comments/page.tsx +++ b/write/src/app/comments/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key, - CornerDownRight, Send + CornerDownRight, Send, Edit, X, Check } from "lucide-react"; interface ArtalkPage { @@ -39,6 +39,19 @@ export default function CommentsPage() { const [loadingComments, setLoadingComments] = useState(false); const [pagesTotal, setPagesTotal] = useState(0); const [error, setError] = useState(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(() => lsGet(TOKEN_KEY)); const [user, setUser] = useState(() => { const raw = lsGet(USER_KEY); @@ -188,6 +201,29 @@ export default function CommentsPage() { setComments(unique); // Update comment count for this page setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: unique.length } : p)); + + // Auto-sync title from blog post + try { + const slug = selectedPage.key.replace(/^\//, "").replace(/\.html$/, ""); + if (slug) { + const postRes = await fetch(`/api/posts/${slug}`); + if (postRes.ok) { + const post = await postRes.json(); + const realTitle = post.frontMatter?.title || ""; + if (realTitle && realTitle.trim() !== (selectedPage.title || "").trim()) { + // Title differs — auto-update Artalk page + await fetch(`/api/comments/pages/${selectedPage.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: selectedPage.key, title: realTitle, admin_only: false }), + }); + // Update local state + setSelectedPage((prev) => prev ? { ...prev, title: realTitle } : prev); + setPages((prev) => prev.map((p) => p.id === selectedPage.id ? { ...p, title: realTitle } : p)); + } + } + } + } catch { /* silent */ } } catch (err: unknown) { setError(err instanceof Error ? err.message : "加载失败"); } finally { setLoadingComments(false); } @@ -257,6 +293,96 @@ export default function CommentsPage() { setTimeout(() => replyRef.current?.focus(), 50); }; + const handleSaveTitle = async () => { + if (!selectedPage || !editTitleText.trim()) return; + setSavingTitle(true); + try { + const res = await fetch(`/api/comments/pages/${selectedPage.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: selectedPage.key, + title: editTitleText.trim(), + admin_only: false, + }), + }); + if (!res.ok) throw new Error("更新失败"); + setSelectedPage({ ...selectedPage, title: editTitleText.trim() }); + setPages((prev) => + prev.map((p) => (p.id === selectedPage.id ? { ...p, title: editTitleText.trim() } : p)) + ); + setEditingTitle(false); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "更新标题失败"); + } finally { setSavingTitle(false); } + }; + + const startEditTitle = () => { + if (!selectedPage) return; + setEditTitleText(selectedPage.title || ""); + setEditingTitle(true); + }; + + const handleCleanup = async () => { + if (!confirm("将删除重复且评论数为0的页面,确定继续?")) return; + if (!token) return; + setCleaning(true); + setError(null); + try { + const res = await fetch("/api/comments/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "清理失败"); + setError(null); + alert(`清理完成:共 ${data.total} 个页面,${data.duplicateGroups} 组重复,删除了 ${data.deleted} 个。`); + fetchPages(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "清理失败"); + } finally { setCleaning(false); } + }; + + const handleDeletePage = async (page: ArtalkPage) => { + if (!confirm(`确定删除页面「${page.title || "(无标题)"}」吗?\n此操作不可撤销,该页面的所有评论也将被删除。`)) return; + if (!token) return; + try { + const res = await fetch(`/api/comments/pages/${page.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: page.key, title: page.title, admin_only: false }), + }); + // Delete via Artalk directly + const delRes = await fetch(`https://artalk.usj.cc/api/v2/pages/${page.id}`, { + method: "DELETE", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ site_name: "优世界" }), + }); + if (!delRes.ok) throw new Error("删除失败"); + setPages((prev) => prev.filter((p) => p.id !== page.id)); + if (selectedPage?.id === page.id) { + setSelectedPage(null); + setComments([]); + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "删除页面失败"); + } + }; + + const fetchStats = async () => { + if (!token) return; + setLoadingStats(true); + setViewMode("stats"); + try { + const res = await fetch(`/api/comments/stats?token=${encodeURIComponent(token)}`); + if (!res.ok) throw new Error("加载失败"); + setStats(await res.json()); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "加载统计失败"); + } finally { setLoadingStats(false); } + }; + const fmtDate = (d: string) => d ? new Date(d).toLocaleString("zh-CN") : "-"; const stripHtml = (h: string) => h.replace(/<[^>]*>/g, "").slice(0, 300); @@ -308,8 +434,22 @@ export default function CommentsPage() { {/* Top bar */}

评论管理

+
+ + +
{user && {user.name}} +
)} - {/* Two columns */} + {/* Stats view */} + {viewMode === "stats" && ( +
+ {loadingStats ? ( +
+ 加载统计数据... +
+ ) : stats ? ( +
+ {/* Summary cards */} +
+ {[ + { label: "文章总数", value: stats.totalArticles }, + { label: "评论总数", value: stats.totalComments }, + { label: "评论用户", value: stats.uniqueUsers }, + ].map((s) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ + {/* Top articles & top users side by side */} +
+ {/* Top articles */} +
+

评论最多的文章 Top 20

+
+ {stats.topArticles.map((a, i) => ( +
+ {i + 1} + {a.title} + {a.comment_count} 条 +
+ ))} +
+
+ + {/* Top commenters */} +
+

最活跃的用户 Top 30

+
+ {stats.topUsers.map((u, i) => ( +
+ {i + 1} + {u.nick} + {u.count} 条 +
+ ))} +
+
+
+
+ ) : null} +
+ )} + + {/* Two columns — only in comments mode */} + {viewMode === "comments" && (
{/* Left: Articles */}
@@ -339,15 +542,26 @@ export default function CommentsPage() { ) : (
{pages.map((p) => ( - + + +
))}
)} @@ -357,8 +571,36 @@ export default function CommentsPage() {
{selectedPage ? ( <> -
- {selectedPage.title} · {comments.length} 条 +
+ {editingTitle ? ( +
+ setEditTitleText(e.target.value)} + className="flex-1 bg-white border border-line rounded-md px-2 py-1 text-[13px] text-text focus:outline-none focus:border-accent/30" + onKeyDown={(e) => { if (e.key === "Enter") handleSaveTitle(); if (e.key === "Escape") setEditingTitle(false); }} + autoFocus + /> + + +
+ ) : ( + <> + {selectedPage.title} + + + )} + {comments.length} 条
{loadingComments ? ( @@ -460,6 +702,7 @@ export default function CommentsPage() { )}
+ )}
); } diff --git a/write/src/app/images/page.tsx b/write/src/app/images/page.tsx index 1ff16fc4..f7942611 100644 --- a/write/src/app/images/page.tsx +++ b/write/src/app/images/page.tsx @@ -12,6 +12,7 @@ interface ImageFile { folder: string; size: number; url: string; + localUrl: string; } export default function ImagesPage() { @@ -88,7 +89,7 @@ export default function ImagesPage() {
{img.name} { await artalkMutate(`/api/v2/comments/${id}`, "DELETE", { site_name: ARTALK_SITE }); } + +export async function updatePage( + id: number, + data: { key: string; title: string; admin_only?: boolean } +): Promise { + return artalkMutate(`/api/v2/pages/${id}`, "PUT", { + key: data.key, + title: data.title, + admin_only: data.admin_only ?? false, + site_name: ARTALK_SITE, + }); +} diff --git a/write/src/lib/config.ts b/write/src/lib/config.ts index 13f43ee5..b78978dd 100644 --- a/write/src/lib/config.ts +++ b/write/src/lib/config.ts @@ -10,7 +10,7 @@ export const ARTALK_SITE = "优世界"; export const ITEMS_PER_PAGE = 20; export const RECYCLE_DAYS = 30; export const HUGO_PREVIEW = "http://localhost:1313"; -export const RSS_API_BASE = process.env.RSS_API_BASE || "https://rssapi.usj.cc"; +export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc"; export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || ""; export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || ""; export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || ""; diff --git a/write/src/lib/rssapi.ts b/write/src/lib/rssapi.ts index 119a243c..ee5b1c7e 100644 --- a/write/src/lib/rssapi.ts +++ b/write/src/lib/rssapi.ts @@ -64,6 +64,17 @@ export async function addFeed(url: string, feedTitle: string): Promise { }); } +export async function updateFeed(oldUrl: string, newUrl: string, feedTitle: string): Promise { + const qs = withToken(); + const body: Record = { url: newUrl, feedTitle }; + if (oldUrl && oldUrl !== newUrl) body.oldUrl = oldUrl; + await rssFetch(`/api/feeds?${qs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + export async function deleteFeed(url: string): Promise { const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN })); await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" }); @@ -94,13 +105,14 @@ export async function addLink(link: { name: string; url: string; description?: s }); } -export async function updateLink(url: string, data: { name?: string; description?: string; hidden?: boolean }): Promise { - // RSS API uses POST as upsert +export async function updateLink(oldUrl: string, data: { name?: string; url?: string; description?: string; hidden?: boolean }): Promise { const qs = withToken(); + const body: Record = { ...data }; + if (data.url && data.url !== oldUrl) body.oldUrl = oldUrl; await rssFetch(`/api/links?${qs}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, ...data }), + body: JSON.stringify(body), }); } diff --git a/write/start.bat b/write/start.bat new file mode 100644 index 00000000..639c4ec4 --- /dev/null +++ b/write/start.bat @@ -0,0 +1,13 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +echo. +echo ╔═══════════════════════════╗ +echo ║ 写作后台 · 优世界 ║ +echo ║ http://localhost:8016 ║ +echo ╚═══════════════════════════╝ +echo. +echo 正在启动... +echo. +npm run dev -- -p 8016 +pause