feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user