feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -2,7 +2,8 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Home, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
@@ -27,17 +28,8 @@ export function WorkspaceNavPanel({
return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<div className="j13-rail-scroll j13-board-list min-h-0 flex-1">
<p className="j13-rail-group">浏览</p>
<Link
href="/"
className="j13-bcard"
@@ -53,25 +45,41 @@ export function WorkspaceNavPanel({
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
{user ? (
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Bookmark size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">我的收藏</span>
</Link>
))}
) : null}
{boards.length > 0 ? (
<>
<p className="j13-rail-group">板块</p>
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</>
) : null}
</div>
{showAdmin ? (
@@ -105,46 +113,77 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
);
}
// 移动端:发帖入口 + 横向滑动板块条
export function WorkspaceNavMobile({
boards,
user,
}: {
boards: BoardWithCount[];
user: User | null;
}) {
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const composePath =
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
const composeHref = user
? composePath
: `/login?redirect=${encodeURIComponent(composePath)}`;
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(max > 4 && el.scrollLeft < max - 4);
}, []);
useEffect(() => {
updateScrollState();
const el = scrollRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(updateScrollState);
ro.observe(el);
return () => ro.disconnect();
}, [boards, updateScrollState]);
// 进页 / 切板块时把激活 chip 滚到可视区中央
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({
block: "nearest",
inline: "center",
});
}, [activeBoard, boards]);
return (
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
<div className="j13-chips">
<Link href={composeHref} className="btn btn-primary btn-sm shrink-0">
<PenSquare size={14} /> 发帖
</Link>
<Link
href="/"
className="j13-chip"
aria-current={activeBoard === 0 ? "page" : undefined}
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
<div className="relative">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Home size={13} /> 全部
</Link>
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
href="/"
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-current={activeBoard === 0 ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
<Home size={13} /> 全部
</Link>
))}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
</Link>
))}
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
</div>
</div>
);