325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
"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";
|
|
|
|
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;
|
|
try {
|
|
const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" });
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
alert("删除失败:" + (data.error || "未知错误"));
|
|
return;
|
|
}
|
|
fetchPosts();
|
|
} catch (e) {
|
|
alert("删除失败:" + (e instanceof Error ? e.message : String(e)));
|
|
}
|
|
};
|
|
|
|
const handlePreview = async (slug: string) => {
|
|
await fetch("/api/hugo");
|
|
window.open(`/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>
|
|
);
|
|
}
|