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

@@ -4,8 +4,6 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import {
Menu,
X,
LogIn,
UserPlus,
LogOut,
@@ -19,6 +17,7 @@ import {
ChevronDown,
ShieldCheck,
MessageSquare,
X,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -43,7 +42,6 @@ export default function Header({
initialTheme = "light",
siteName = "姜十三",
wordmark = "",
slogan = "",
logoLight = "",
logoDark = "",
brandMark = "image_text",
@@ -56,7 +54,6 @@ export default function Header({
initialTheme?: "light" | "dark";
siteName?: string;
wordmark?: string;
slogan?: string;
logoLight?: string;
logoDark?: string;
brandMark?: BrandMark;
@@ -70,13 +67,14 @@ export default function Header({
pathname === "/compose" ||
(pathname?.startsWith("/compose/") ?? false) ||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
const [menuOpen, setMenuOpen] = useState(false);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
const [searchQuery, setSearchQuery] = useState("");
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
const [searchOpen, setSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const mobileSearchRef = useRef<HTMLInputElement>(null);
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
useEffect(() => {
@@ -101,7 +99,6 @@ export default function Header({
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
useEffect(() => onForceLogout(() => {
setMenuOpen(false);
apiLogout()
.catch(() => {})
.finally(() => {
@@ -164,8 +161,9 @@ export default function Header({
e.preventDefault();
const q = searchQuery.trim();
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
setMenuOpen(false);
setSearchOpen(false);
searchInputRef.current?.blur();
mobileSearchRef.current?.blur();
};
useEffect(() => {
@@ -203,7 +201,6 @@ export default function Header({
/* ignore */
}
setUser(null);
setMenuOpen(false);
router.refresh();
};
@@ -233,7 +230,6 @@ export default function Header({
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
slogan={slogan}
logoLight={logoLight}
logoDark={logoDark}
/>
@@ -255,9 +251,19 @@ export default function Header({
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
<div className="flex items-center gap-2 lg:gap-3">
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
{/* 移动端搜索开关 */}
<button
onClick={() => setSearchOpen((v) => !v)}
aria-label="搜索"
aria-expanded={searchOpen}
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
>
{searchOpen ? <X size={17} /> : <Search size={17} />}
</button>
<form onSubmit={handleSearch} className="hidden lg:block">
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
<Search
@@ -283,18 +289,14 @@ export default function Header({
</kbd>
</div>
</form>
<ThemeToggle
initialTheme={initialTheme}
className={`${iconBtn} hidden lg:inline-flex`}
style={iconBtnStyle}
/>
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
{user && allowMessages && (
<Link
href="/chat"
prefetch={false}
aria-label="消息"
data-tip="消息"
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
style={{
color: pathname?.startsWith("/chat")
? "var(--accent)"
@@ -333,95 +335,43 @@ export default function Header({
<UserMenu user={user} onLogout={handleLogout} />
</>
) : (
<div className="hidden lg:flex items-center gap-2">
<div className="flex items-center gap-2">
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
<Link href="/register" className="btn btn-primary btn-sm">
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
<UserPlus size={14} /> 注册
</Link>
</div>
)}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
aria-label="菜单"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={19} /> : <Menu size={19} />}
</button>
</div>
</div>
{/* 移动端抽屉 */}
{menuOpen && (
{/* 移动端展开式搜索行 */}
{searchOpen && (
<div
className="lg:hidden border-t px-4 py-4"
className="lg:hidden border-t px-4 py-2.5"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<form onSubmit={handleSearch} className="relative mb-3">
<form onSubmit={handleSearch} className="relative">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
<input
ref={mobileSearchRef}
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchOpen(false);
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
}
}}
aria-label="搜索帖子"
placeholder="搜索帖子...(Esc 清除)"
placeholder="搜索帖子..."
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
/>
</form>
<div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
<MobileLink href="/leaderboard" active={pathname?.startsWith("/leaderboard") ?? false}>
<span className="inline-flex items-center gap-2">
<Trophy size={15} /> 排行榜
</span>
</MobileLink>
{user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
{user && allowMessages && (
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
<span className="inline-flex items-center gap-2">
<MessageSquare size={15} /> 消息
{chatUnread > 0 && (
<span
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full"
style={{ background: "var(--clay)", color: "#fff" }}
>
{chatUnread > 99 ? "99+" : chatUnread}
</span>
)}
</span>
</MobileLink>
)}
{user && (
<MobileLink href="/notifications">
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<ThemeToggle
initialTheme={initialTheme}
labelMode="labeled"
iconSize={14}
className="btn btn-line btn-sm"
/>
{user && (
<button onClick={handleLogout} className="btn btn-danger btn-sm">
<LogOut size={14} /> 退出
</button>
)}
</div>
</div>
)}
</header>
@@ -455,32 +405,9 @@ function NavPill({
);
}
function MobileLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: "transparent",
}}
>
{children}
</Link>
);
}
/**
* 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
*/
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
@@ -506,12 +433,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative hidden lg:block" ref={menuRef}>
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
aria-label="用户菜单"
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
style={{
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
@@ -519,14 +447,14 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span
className="text-[13px] max-w-[90px] truncate"
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
<ChevronDown
size={14}
className="transition-transform duration-200"
className="hidden lg:block transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
@@ -562,6 +490,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
</div>
</div>
<div className="p-1.5">
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
</Link>
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>