added:增加Linux-server
This commit is contained in:
1 parent
6fe4e9d23f
commit
d78f3f811b
90 files changed
+16784
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user