feat: 移动端底部导航可配置与工作区导航、置顶后台等前端调整
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "@/lib/toast";
|
||||
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
||||
home: { label: "首页", hint: "站点首页" },
|
||||
library: { label: "书库", hint: "书库列表页入口" },
|
||||
snippets: { label: "片段", hint: "代码片段列表页入口(默认关闭,开启后显示在底栏)" },
|
||||
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
||||
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
||||
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
||||
@@ -22,6 +23,7 @@ function switchesOf(tabs: MobileTabKey[]): TabSwitches {
|
||||
return {
|
||||
home: tabs.includes("home"),
|
||||
library: tabs.includes("library"),
|
||||
snippets: tabs.includes("snippets"),
|
||||
compose: tabs.includes("compose"),
|
||||
messages: tabs.includes("messages"),
|
||||
me: tabs.includes("me"),
|
||||
@@ -166,7 +168,7 @@ export default function MobileTabBarPanel({
|
||||
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
||||
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
||||
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
|
||||
<li>片段、排行榜未放进底栏时,移动端仍可从首页 ☰ 导航抽屉进入(片段默认关闭)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
@@ -5280,6 +5280,36 @@ a.j13-mention-token:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 置顶管理:行悬停、拖拽手柄、板块下拉触发器焦点 */
|
||||
.pin-row {
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.pin-row:hover {
|
||||
background: var(--item-hover-bg);
|
||||
}
|
||||
.pin-row.is-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.pin-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--ink-3);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.pin-row:hover .pin-drag-handle {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.pin-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pin-drag-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pin-board-trigger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
|
||||
.admin-doc-page {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user