feat: 移动端底部导航可配置与工作区导航、置顶后台等前端调整

This commit is contained in:
2026-10-16 16:26:08 +08:00
parent 47f87a011c
commit 55ded2695c
10 changed files with 573 additions and 154 deletions

View File

@@ -1,15 +1,36 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronDown, ChevronUp, Pin } from "lucide-react";
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 AdminPageHeader from "@/components/admin/AdminPageHeader";
import AdminEmpty from "@/components/admin/AdminEmpty";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
} from "@/components/admin";
interface BoardOpt {
id: number;
@@ -29,8 +50,11 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
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 [saving, setSaving] = useState(false);
const [error, setError] = useState("");
/** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const load = useCallback(async (lv: Level, bid: number) => {
setLoading(true);
@@ -54,41 +78,94 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
if (lv === level) return;
setLevel(lv);
setPosts([]);
setError("");
};
// 上移/下移:乐观交换后整体保存顺序,失败回滚
const move = async (idx: number, dir: -1 | 1) => {
/** 统一保存:乐观更新新顺序,失败回滚并提示 */
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 || busy) return;
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;
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);
}
void saveOrder(next, prev);
};
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)]"
}`;
const resetDrag = () => {
setDragIndex(null);
setDropIndex(null);
};
const onRowDragOver = (e: DragEvent<HTMLLIElement>, 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<HTMLLIElement>) => {
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: <Globe size={13} aria-hidden /> }]
: []),
{ key: "1", label: "板块置顶", icon: <FolderOpen size={13} aria-hidden /> },
];
return (
<div>
<AdminPageHeader
icon={Pin}
title="置顶管理"
description="调整置顶帖在列表中的显示顺序,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
description="拖拽手柄或上下箭头调整置顶帖顺序,改动自动保存,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
/>
{error && (
@@ -102,64 +179,25 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
{/* 层级切换 + 板块选择 */}
<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>
<AdminSegmented
options={segOptions}
value={String(level)}
onChange={(k) => switchLevel(Number(k) as Level)}
ariaLabel="置顶层级"
/>
{level === 1 && (
<select
className="input text-[13px] py-2"
style={{ minWidth: 160 }}
<BoardSelect
boards={boards}
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>
onChange={setBoardId}
disabled={loading || saving}
/>
)}
</div>
<div className="panel overflow-hidden">
{loading ? (
<p className="meta text-center py-14 text-[13px]">加载中…</p>
<PinListSkeleton />
) : posts.length === 0 ? (
<AdminEmpty
icon={<Pin size={20} />}
@@ -167,58 +205,349 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
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)" }}
<>
{/* 列表状态条:数量 + 保存状态/操作提示 */}
<div
className="flex items-center justify-between gap-3 px-4 py-2.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="text-[12.5px] font-semibold inline-flex items-center gap-1"
style={{ color: "var(--ink-2)" }}
>
<span
className="w-6 text-center text-[12.5px] font-bold tabular-nums shrink-0"
style={{ color: "var(--ink-3)" }}
共
<b className="tabular-nums" style={{ color: "var(--accent)" }}>
{posts.length}
</b>
篇置顶
</span>
<span className="meta text-[12px] inline-flex items-center gap-1.5">
{saving ? (
<>
<Loader2 size={12} className="animate-spin" aria-hidden />
保存中…
</>
) : (
"拖拽手柄或上下按钮调整,自动保存"
)}
</span>
</div>
<ul
onDragLeave={(e) => {
// 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropIndex(null);
}
}}
>
{posts.map((p, i) => (
<li
key={p.id}
className={`pin-row relative flex items-center gap-3 px-4 py-3${
dragIndex === i ? " is-dragging" : ""
}`}
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
onDragOver={(e) => onRowDragOver(e, i)}
onDrop={onRowDrop}
>
{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="上移"
{/* 拖拽插入位置指示线 */}
{showLineAt(i) && (
<span
className="absolute left-3 right-3 -top-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{i === posts.length - 1 && showLineAt(posts.length) && (
<span
className="absolute left-3 right-3 -bottom-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{/* 拖拽手柄:键盘上下方向键同样可排序 */}
<span
role="button"
tabIndex={0}
aria-label={`拖拽调整「${p.title}」的顺序,也可使用上下方向键`}
className="pin-drag-handle w-5 h-8 -ml-1 inline-flex items-center justify-center rounded-md shrink-0"
draggable={!saving}
onDragStart={(e) => {
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);
}
}}
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
onClick={() => void move(i, 1)}
disabled={i === posts.length - 1 || busy}
aria-label="下移"
<GripVertical size={14} aria-hidden />
</span>
{/* 名次徽章:前三名高亮 */}
<span
className="w-7 h-7 rounded-lg inline-flex items-center justify-center text-[12.5px] font-bold tabular-nums shrink-0"
style={
i < 3
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { background: "var(--panel-2)", color: "var(--ink-3)" }
}
>
<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>
{i + 1}
</span>
<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] mt-0.5 flex items-center gap-1.5 truncate">
{level === 2 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-px rounded-full shrink-0"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<FolderOpen size={10} aria-hidden />
{p.board_name}
</span>
)}
<span className="shrink-0">{formatRelative(p.created_at)}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<AdminIconButton
ariaLabel={`上移「${p.title}」`}
tip="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ChevronUp size={15} />
</AdminIconButton>
<AdminIconButton
ariaLabel={`下移「${p.title}」`}
tip="下移"
disabled={i === posts.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ChevronDown size={15} />
</AdminIconButton>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(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 <span className="meta text-[13px] py-2">无可用板块</span>;
}
return (
<div ref={ref} className="relative" onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="选择板块"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="pin-board-trigger h-9 pl-3 pr-2.5 rounded-lg inline-flex items-center gap-2 text-[13px] font-medium transition-[border-color,box-shadow] disabled:opacity-60 disabled:cursor-not-allowed"
style={{
minWidth: 176,
background: "var(--panel)",
border: `1px solid ${open ? "var(--accent)" : "var(--line-2)"}`,
color: "var(--ink)",
boxShadow: open ? "0 0 0 3px var(--accent-soft)" : "var(--shadow-sm)",
}}
>
<FolderOpen
size={14}
className="shrink-0 transition-colors"
style={{ color: open ? "var(--accent)" : "var(--ink-3)" }}
aria-hidden
/>
<span className="truncate max-w-44">{current?.name ?? "选择板块"}</span>
<ChevronDown
size={13}
className={`ml-auto shrink-0 transition-transform duration-150${
open ? " rotate-180" : ""
}`}
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
</button>
{open && (
<div
className="absolute z-30 mt-1.5 left-0 w-full min-w-52 rounded-xl overflow-hidden"
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul
ref={listRef}
role="listbox"
aria-label="板块列表"
className="max-h-60 overflow-y-auto p-1.5"
>
{boards.map((b, i) => {
const active = b.id === value;
return (
<li key={b.id}>
<button
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
onClick={() => select(b.id)}
onMouseEnter={() => setHi(i)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-[13px] transition-colors"
style={{
background: active
? "var(--nav-active-bg)"
: i === hi
? "var(--panel-2)"
: undefined,
color: active ? "var(--nav-active-ink)" : "var(--ink-2)",
}}
>
<span className="flex-1 truncate font-medium">{b.name}</span>
{active && <Check size={13} className="shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */
function PinListSkeleton() {
return (
<ul aria-hidden>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex items-center gap-3 px-4 py-3"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<span
className="w-5 h-7 -ml-1 rounded-md animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<span
className="w-7 h-7 rounded-lg animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<span
className="h-3.5 rounded animate-pulse"
style={{ background: "var(--panel-2)", width: `${52 - i * 9}%` }}
/>
<span
className="h-3 w-28 rounded animate-pulse"
style={{ background: "var(--panel-2)" }}
/>
</div>
</li>
))}
</ul>
);
}