"use client"; import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Check, ChevronDown, ChevronUp, FolderOpen, Globe, GripVertical, Loader2, Pin, } from "lucide-react"; import { apiAdminPinnedPosts, apiAdminSavePinOrder, type AdminPinnedPost, } from "@/lib/api"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import { AdminEmpty, AdminIconButton, AdminPageHeader, AdminSegmented, } from "@/components/admin"; 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(canGlobal ? 2 : 1); const [boardId, setBoardId] = useState(boards[0]?.id ?? 0); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); /** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */ const [dragIndex, setDragIndex] = useState(null); const [dropIndex, setDropIndex] = useState(null); 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([]); setError(""); }; /** 统一保存:乐观更新新顺序,失败回滚并提示 */ const saveOrder = useCallback( async (next: AdminPinnedPost[], prev: AdminPinnedPost[]) => { setPosts(next); setSaving(true); try { await apiAdminSavePinOrder( level, level === 1 ? boardId : 0, next.map((p) => p.id) ); toast("置顶顺序已更新", "ok"); } catch (e) { setPosts(prev); toast(e instanceof Error ? e.message : "保存顺序失败"); } finally { setSaving(false); } }, [level, boardId] ); // 上移/下移按钮与键盘共用 const move = (idx: number, dir: -1 | 1) => { const target = idx + dir; if (target < 0 || target >= posts.length || saving) return; const prev = posts; const next = [...posts]; const tmp = next[idx]; next[idx] = next[target]; next[target] = tmp; void saveOrder(next, prev); }; const resetDrag = () => { setDragIndex(null); setDropIndex(null); }; const onRowDragOver = (e: DragEvent, i: number) => { if (dragIndex === null) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; // 指针过行中线则插到行后,否则插到行前 const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; setDropIndex(i + (after ? 1 : 0)); }; const onRowDrop = (e: DragEvent) => { e.preventDefault(); if (dragIndex === null || dropIndex === null || saving) { resetDrag(); return; } // 落在原位置前后:无变化 if (dropIndex === dragIndex || dropIndex === dragIndex + 1) { resetDrag(); return; } const prev = posts; const next = [...posts]; const [moved] = next.splice(dragIndex, 1); next.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved); resetDrag(); void saveOrder(next, prev); }; /** 插入指示线:仅当落点确实改变位置时显示 */ const showLineAt = (pos: number) => dragIndex !== null && dropIndex === pos && pos !== dragIndex && pos !== dragIndex + 1; const segOptions = [ ...(canGlobal ? [{ key: "2", label: "全站置顶", icon: }] : []), { key: "1", label: "板块置顶", icon: }, ]; return (
{error && (
{error}
)} {/* 层级切换 + 板块选择 */}
switchLevel(Number(k) as Level)} ariaLabel="置顶层级" /> {level === 1 && ( )}
{loading ? ( ) : posts.length === 0 ? ( } title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"} description="在帖子页的「管理」菜单中可将帖子设为置顶。" /> ) : ( <> {/* 列表状态条:数量 + 保存状态/操作提示 */}
共 {posts.length} 篇置顶 {saving ? ( <> 保存中… ) : ( "拖拽手柄或上下按钮调整,自动保存" )}
    { // 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁 if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDropIndex(null); } }} > {posts.map((p, i) => (
  • onRowDragOver(e, i)} onDrop={onRowDrop} > {/* 拖拽插入位置指示线 */} {showLineAt(i) && ( )} {i === posts.length - 1 && showLineAt(posts.length) && ( )} {/* 拖拽手柄:键盘上下方向键同样可排序 */} { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(i)); setDragIndex(i); }} onDragEnd={resetDrag} onKeyDown={(e) => { if (e.key === "ArrowUp") { e.preventDefault(); move(i, -1); } else if (e.key === "ArrowDown") { e.preventDefault(); move(i, 1); } }} > {/* 名次徽章:前三名高亮 */} {i + 1}
    {p.title}

    {level === 2 && ( {p.board_name} )} {formatRelative(p.created_at)}

    move(i, -1)} > move(i, 1)} >
  • ))}
)}
); } /** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 select */ function BoardSelect({ boards, value, onChange, disabled, }: { boards: BoardOpt[]; value: number; onChange: (id: number) => void; disabled?: boolean; }) { const [open, setOpen] = useState(false); /** 键盘/悬停高亮下标 */ const [hi, setHi] = useState(0); const ref = useRef(null); const triggerRef = useRef(null); const listRef = useRef(null); const current = boards.find((b) => b.id === value); useEffect(() => { if (!open) return; setHi(Math.max(0, boards.findIndex((b) => b.id === value))); const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [open, boards, value]); // 高亮项保持在可视区内 useEffect(() => { if (!open) return; const el = listRef.current?.children[hi]; el?.scrollIntoView({ block: "nearest" }); }, [hi, open]); const close = (refocus = false) => { setOpen(false); if (refocus) triggerRef.current?.focus(); }; const select = (id: number) => { onChange(id); close(true); }; const onKeyDown = (e: ReactKeyboardEvent) => { if (!open) { if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { e.preventDefault(); setOpen(true); } return; } if (e.key === "Escape") { e.preventDefault(); close(true); } else if (e.key === "ArrowDown") { e.preventDefault(); setHi((v) => Math.min(boards.length - 1, v + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi((v) => Math.max(0, v - 1)); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (boards[hi]) select(boards[hi].id); } else if (e.key === "Tab") { close(); } }; if (boards.length === 0) { return 无可用板块; } return (
{open && (
    {boards.map((b, i) => { const active = b.id === value; return (
  • ); })}
)}
); } /** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */ function PinListSkeleton() { return (
    {[0, 1, 2, 3].map((i) => (
  • ))}
); }