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

@@ -124,15 +124,18 @@ const (
DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5
MaxMobileTabs = 6
)
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是管理面板排序基准,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "snippets", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置
// DefaultMobileTabKeys 底栏缺省启用集合(白名单子集:snippets 默认关闭,需管理员手动开启)
var DefaultMobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认启用配置
func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...)
return append([]string(nil), DefaultMobileTabKeys...)
}
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -645,7 +648,7 @@ func (s *SettingService) SetMobileTabs(tabs []string) error {
if !ok || len(out) == 0 {
return ErrInvalidSiteSetting
}
if mobileTabsEqual(out, MobileTabKeys) {
if mobileTabsEqual(out, DefaultMobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs)
}
b, err := json.Marshal(out)

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>
);
}

View File

@@ -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>

View File

@@ -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%;

View File

@@ -6,6 +6,7 @@ import { Fragment, useEffect, useState } from "react";
import {
Home,
BookOpen,
CodeXml,
PenSquare,
MessageSquare,
User as UserIcon,
@@ -14,7 +15,7 @@ import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { userPath, libraryPath } from "@/lib/urlStyle";
import { userPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import type { MobileTabKey } from "@/lib/mobileTabs";
@@ -36,6 +37,7 @@ export default function MobileTabBar({
const allowMessages = useAllowMessages();
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle);
const snipHref = snippetsPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -100,6 +102,16 @@ export default function MobileTabBar({
active={isActive((p) => p === libHref)}
/>
),
snippets: (
<TabLink
href={snipHref}
icon={<CodeXml size={20} />}
label="片段"
active={isActive(
(p) => p === snipHref || p.startsWith("/snippets") || p.startsWith("/snippet-"),
)}
/>
),
compose: (
<TabLink
href={user ? "/compose" : "/login"}

View File

@@ -7,7 +7,7 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { libraryPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) {
@@ -88,6 +88,7 @@ export default function SiteChrome({
const tabBarRoots: string[] = [];
if (enabledTabs.has("home")) tabBarRoots.push("/");
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
if (enabledTabs.has("snippets")) tabBarRoots.push(snippetsPath(brand.url_style));
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =

View File

@@ -11,7 +11,6 @@ import {
GitFork,
Plus,
Lock,
Loader2,
ArrowLeft,
ArrowRight,
RotateCcw,
@@ -454,24 +453,19 @@ export default function SnippetExplorer({
)}
</div>
{/* 结果统计 */}
{/* 结果统计:纯文本行,切换范围时数据原地更新,无任何加载指示 */}
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<span className="tabular-nums" style={{ fontFamily: MONO }}>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
</span>
)}
<span className="tabular-nums" style={{ fontFamily: MONO }}>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
</span>
<span>
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
{loading ? ",加载中…" : ""}
</span>
</div>
{/* 列表 / 空态 */}
{items.length === 0 && loading ? null : items.length === 0 && !loading ? (
{/* 列表 / 空态:请求期间保留现有内容,新数据返回后原地替换,不闪空态 */}
{items.length === 0 ? (
filtering ? (
<div
className="mt-4 rounded-xl px-6 py-12 text-center"

View File

@@ -1,14 +1,14 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
import { Bookmark, CodeXml, Home, LayoutGrid, Menu, ShieldCheck, Trophy } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { leaderboardPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -26,9 +26,16 @@ export function WorkspaceNavPanel({
user?: User | null;
}) {
const params = useSearchParams();
const pathname = usePathname();
const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
const snipHref = snippetsPath(urlStyle);
const lbHref = leaderboardPath(urlStyle);
// 与 Header 高亮规则一致:覆盖标准路径与伪静态(/snippet-*.html、/rank.html 等)
const snippetsActive =
!!pathname && (pathname.startsWith("/snippets") || pathname.startsWith("/snippet-"));
const leaderboardActive = !!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`));
return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
@@ -49,6 +56,37 @@ export function WorkspaceNavPanel({
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径),
桌面端由顶部导航承担,左栏不重复 */}
<Link
href={snipHref}
className="j13-bcard lg:hidden"
aria-current={snippetsActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<CodeXml size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">代码片段</span>
</Link>
<Link
href={lbHref}
className="j13-bcard lg:hidden"
aria-current={leaderboardActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Trophy size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">排行榜</span>
</Link>
{user ? (
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
<span

View File

@@ -7,6 +7,10 @@ describe("normalizeMobileTabs", () => {
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
});
it("snippets 为白名单合法项,显式启用时保留", () => {
assert.deepEqual(normalizeMobileTabs(["snippets"]), ["snippets"]);
assert.deepEqual(normalizeMobileTabs(["home", "snippets", "me"]), ["home", "snippets", "me"]);
});
it("去重与去空白", () => {
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
});
@@ -16,15 +20,18 @@ describe("normalizeMobileTabs", () => {
it("非法元素被剔除", () => {
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
});
it("空数组/非法输入回落默认全量", () => {
it("空数组/非法输入回落默认集合", () => {
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
});
it("默认全量与固定顺序一致", () => {
it("白名单与默认集合:snippets 在白名单内但默认不启用", () => {
assert.equal(MOBILE_TABS.length, 6);
assert.ok(MOBILE_TABS.includes("snippets"));
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
assert.ok(!DEFAULT_MOBILE_TABS.includes("snippets"));
assert.deepEqual(DEFAULT_MOBILE_TABS, MOBILE_TABS.filter((k) => k !== "snippets"));
});
});

View File

@@ -1,17 +1,20 @@
/**
* 移动端底栏配置的单一事实源:
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认集合。
*
* MOBILE_TABS = 白名单(同时是管理面板排序基准),含全部可选 key;
* DEFAULT_MOBILE_TABS = 缺省启用集合,是白名单的子集(snippets 默认关闭,需管理员手动开启)。
*/
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
export const MOBILE_TABS = ["home", "library", "snippets", "compose", "messages", "me"] as const;
export type MobileTabKey = (typeof MOBILE_TABS)[number];
/** 底栏默认全量配置 */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
/** 底栏默认启用配置(白名单子集:snippets 默认不启用,移动端入口由首页导航抽屉承担) */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = ["home", "library", "compose", "messages", "me"];
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认集合 */
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
const seen = new Set<string>();