225 lines
7.0 KiB
TypeScript
225 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { ChevronDown, ChevronUp, Pin } from "lucide-react";
|
|
import {
|
|
apiAdminPinnedPosts,
|
|
apiAdminSavePinOrder,
|
|
type AdminPinnedPost,
|
|
} from "@/lib/api";
|
|
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
|
import AdminEmpty from "@/components/admin/AdminEmpty";
|
|
import { toast } from "@/lib/toast";
|
|
|
|
interface BoardOpt {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface PinnedAdminProps {
|
|
boards: BoardOpt[];
|
|
/** 管理员及以上:可排全站置顶 */
|
|
canGlobal: boolean;
|
|
}
|
|
|
|
type Level = 1 | 2;
|
|
|
|
export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|
const [level, setLevel] = useState<Level>(canGlobal ? 2 : 1);
|
|
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
|
|
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const load = useCallback(async (lv: Level, bid: number) => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const r = await apiAdminPinnedPosts(lv, lv === 1 ? bid : undefined);
|
|
setPosts(r.posts ?? []);
|
|
} catch (e) {
|
|
setPosts([]);
|
|
setError(e instanceof Error ? e.message : "获取置顶列表失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load(level, boardId);
|
|
}, [level, boardId, load]);
|
|
|
|
const switchLevel = (lv: Level) => {
|
|
if (lv === level) return;
|
|
setLevel(lv);
|
|
setPosts([]);
|
|
};
|
|
|
|
// 上移/下移:乐观交换后整体保存顺序,失败回滚
|
|
const move = async (idx: number, dir: -1 | 1) => {
|
|
const target = idx + dir;
|
|
if (target < 0 || target >= posts.length || busy) return;
|
|
const prev = posts;
|
|
const next = [...posts];
|
|
const tmp = next[idx];
|
|
next[idx] = next[target];
|
|
next[target] = tmp;
|
|
setPosts(next);
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await apiAdminSavePinOrder(level, level === 1 ? boardId : 0, next.map((p) => p.id));
|
|
} catch (e) {
|
|
setPosts(prev);
|
|
setError(e instanceof Error ? e.message : "保存顺序失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const tabCls = (active: boolean) =>
|
|
`px-3.5 py-2 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors ${
|
|
active ? "" : "hover:bg-[var(--panel-2)]"
|
|
}`;
|
|
|
|
return (
|
|
<div>
|
|
<AdminPageHeader
|
|
icon={Pin}
|
|
title="置顶管理"
|
|
description="调整置顶帖在列表中的显示顺序,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
|
|
/>
|
|
|
|
{error && (
|
|
<div
|
|
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
|
|
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 层级切换 + 板块选择 */}
|
|
<div className="mb-4 flex items-center gap-3 flex-wrap">
|
|
<div
|
|
className="inline-flex items-center gap-1 p-1 rounded-[var(--r-sm)]"
|
|
style={{ background: "var(--panel-2)" }}
|
|
role="tablist"
|
|
>
|
|
{canGlobal && (
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={level === 2}
|
|
className={tabCls(level === 2)}
|
|
style={
|
|
level === 2
|
|
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
|
|
: { color: "var(--ink-3)" }
|
|
}
|
|
onClick={() => switchLevel(2)}
|
|
>
|
|
全站置顶
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={level === 1}
|
|
className={tabCls(level === 1)}
|
|
style={
|
|
level === 1
|
|
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
|
|
: { color: "var(--ink-3)" }
|
|
}
|
|
onClick={() => switchLevel(1)}
|
|
>
|
|
板块置顶
|
|
</button>
|
|
</div>
|
|
|
|
{level === 1 && (
|
|
<select
|
|
className="input text-[13px] py-2"
|
|
style={{ minWidth: 160 }}
|
|
value={boardId}
|
|
onChange={(e) => setBoardId(Number(e.target.value))}
|
|
aria-label="选择板块"
|
|
>
|
|
{boards.length === 0 && <option value={0}>无可用板块</option>}
|
|
{boards.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="panel overflow-hidden">
|
|
{loading ? (
|
|
<p className="meta text-center py-14 text-[13px]">加载中…</p>
|
|
) : posts.length === 0 ? (
|
|
<AdminEmpty
|
|
icon={<Pin size={20} />}
|
|
title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"}
|
|
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
|
|
/>
|
|
) : (
|
|
<ul>
|
|
{posts.map((p, i) => (
|
|
<li
|
|
key={p.id}
|
|
className="flex items-center gap-3 px-4 py-3"
|
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
|
>
|
|
<span
|
|
className="w-6 text-center text-[12.5px] font-bold tabular-nums shrink-0"
|
|
style={{ color: "var(--ink-3)" }}
|
|
>
|
|
{i + 1}
|
|
</span>
|
|
<div className="flex flex-col gap-0.5 shrink-0">
|
|
<button
|
|
type="button"
|
|
className="admin-icon-btn"
|
|
onClick={() => void move(i, -1)}
|
|
disabled={i === 0 || busy}
|
|
aria-label="上移"
|
|
>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-icon-btn"
|
|
onClick={() => void move(i, 1)}
|
|
disabled={i === posts.length - 1 || busy}
|
|
aria-label="下移"
|
|
>
|
|
<ChevronDown size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<a
|
|
href={`/post/${p.id}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[13.5px] font-semibold truncate block hover:underline"
|
|
style={{ color: "var(--ink)" }}
|
|
>
|
|
{p.title}
|
|
</a>
|
|
<p className="meta text-[12px] truncate">
|
|
{p.board_name} · {new Date(p.created_at).toLocaleDateString("zh-CN")}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|