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

@@ -15,8 +15,8 @@ import Modal from "@/components/Modal";
*
* 两类告知:
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
* - 登录已失效(reason=session_replaced):其他浏览器登录顶掉旧会话、
* 安全设置剔除本设备等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
* - 登录已失效(reason=session_replaced):安全设置剔除本设备、
* 会话数超上限被淘汰等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
*
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
@@ -152,7 +152,7 @@ export default function AccountGuard() {
id="session-ended-dialog-desc"
className="meta text-[13px] leading-relaxed text-center"
>
你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
请重新登录后继续使用。
</p>
</Modal>

View File

@@ -8,13 +8,9 @@ const SHOW_AFTER = 400;
/**
* 公开站长页回顶:滚过阈值后右下角浮现。
* 短页自然不出现;帖子移动端底栏时抬高,避免叠住操作条。
* 短页自然不出现;移动端位置已为底部公共菜单预留避让(见 globals.css)。
*/
export default function BackToTop({
liftForMobileBar = false,
}: {
liftForMobileBar?: boolean;
}) {
export default function BackToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -29,7 +25,7 @@ export default function BackToTop({
return (
<button
type="button"
className={`j13-back-top j13-toast-in${liftForMobileBar ? " is-lifted" : ""}`}
className="j13-back-top j13-toast-in"
onClick={() => window.scrollTo({ top: 0, behavior: scrollBehavior() })}
aria-label="回到顶部"
>

View File

@@ -1306,7 +1306,11 @@ export default function CommentSection({
}
return (
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
<section
id="comments"
className="panel p-4 sm:p-6 lg:p-8 mt-4 sm:mt-6 scroll-mt-24 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
aria-label="评论区"
>
<div className="flex items-center gap-2.5 mb-6">
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
评论

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>

View File

@@ -134,7 +134,9 @@ export default function ImageInsertModal({
<span className="j13-img-dropzone-title">
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
</span>
<span className="j13-img-dropzone-hint">支持 JPEG / PNG / WebP</span>
<span className="j13-img-dropzone-hint">
支持 JPEG / PNG / WebP / GIF,自动转存 WebP(GIF 除外)
</span>
</button>
{uploadError && !uploading ? (
<p className="j13-img-modal-error" role="alert">

View File

@@ -413,9 +413,9 @@ export default function MarkdownEditor({
if (!file || disabled) return;
retryFile.current = file;
setUploadError("");
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP");
toast("仅支持 JPEG / PNG / WebP", "error");
if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP / GIF");
toast("仅支持 JPEG / PNG / WebP / GIF", "error");
return;
}
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
@@ -1254,7 +1254,7 @@ export default function MarkdownEditor({
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)}
/>

View File

@@ -1,62 +0,0 @@
"use client";
import { MessageSquare } from "lucide-react";
import LikeButton from "./LikeButton";
import FavoriteButton from "./FavoriteButton";
import { scrollBehavior } from "@/lib/motion";
/**
* 帖子详情移动端浮动操作条(lg 以下显示):
* 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。
*/
export default function MobilePostBar({
postId,
liked,
likeCount,
favorited = false,
allowComments = true,
}: {
postId: string;
liked: boolean;
likeCount: number;
favorited?: boolean;
allowComments?: boolean;
}) {
const scrollToComments = () => {
document.getElementById("comment-input")?.scrollIntoView({
behavior: scrollBehavior(),
block: "center",
});
};
return (
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
<div
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
style={{
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
borderColor: "var(--line-2)",
boxShadow: "var(--shadow-lg)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}}
>
{allowComments ? (
<button
type="button"
onClick={scrollToComments}
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
aria-label="跳到评论框"
>
<MessageSquare size={18} />
</button>
) : null}
<div className="j13-bar-like">
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
</div>
<FavoriteButton postId={postId} favorited={favorited} variant="compact" />
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
Home,
Trophy,
PenSquare,
MessageSquare,
User as UserIcon,
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
/**
* 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
*/
export default function MobileTabBar({
user,
initialChatUnread = 0,
}: {
user: User | null;
initialChatUnread?: number;
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
useEffect(() => {
setChatUnread(initialChatUnread);
}, [initialChatUnread]);
// 实时未读:与 Header 同源(chat:unread 对全体成员推送;在当前会话页则忽略)
useEffect(() => {
if (!user || !allowMessages) return;
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
if (!data || typeof data.room_id !== "number") return;
if (pathname === `/chat/${data.room_id}`) return;
const delta = typeof data.delta === "number" ? data.delta : 1;
setChatUnread((c) => Math.max(0, c + delta));
});
return off;
}, [user, pathname, allowMessages]);
// 会话内已读 / 回前台:合并对账(短时间多次同步只打一次 summary)
useEffect(() => {
if (!user || !allowMessages) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pullSummary = () => {
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
const onSync = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(pullSummary, 250);
};
window.addEventListener("j13-chat-unread-sync", onSync);
const offVisible = onDocumentVisible(pullSummary);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
window.removeEventListener("j13-chat-unread-sync", onSync);
offVisible();
};
}, [user, allowMessages]);
const showMessages = !!user && allowMessages;
const isActive = (match: (p: string) => boolean) =>
!!pathname && match(pathname);
return (
<nav
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
aria-label="底部导航"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<div className="flex items-stretch">
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
<TabLink
href="/leaderboard"
icon={<Trophy size={20} />}
label="排行榜"
active={isActive((p) => p.startsWith("/leaderboard"))}
/>
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
{showMessages && (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p.startsWith("/chat"))}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
)}
<TabLink
href={user ? `/u/${user.id}` : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
}
/>
</div>
</nav>
);
}
function TabLink({
href,
icon,
label,
active,
prominent = false,
badge = 0,
}: {
href: string;
icon: React.ReactNode;
label: string;
active: boolean;
prominent?: boolean;
badge?: number | string;
}) {
const ink = active ? "var(--accent)" : "var(--ink-2)";
return (
<Link
href={href}
prefetch={false}
aria-label={label}
aria-current={active ? "page" : undefined}
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 transition-colors"
style={{ color: ink }}
>
{prominent ? (
<span
className="w-9 h-9 -mt-1 flex items-center justify-center rounded-full"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
aria-hidden="true"
>
{icon}
</span>
) : (
<span className="relative" aria-hidden="true">
{icon}
{badge ? (
<span
className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 text-[9.5px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--panel)",
}}
>
{badge}
</span>
) : null}
</span>
)}
<span
className={`text-[10px] leading-none ${
prominent ? "mt-0.5" : ""
} ${active ? "font-semibold" : "font-medium"}`}
>
{label}
</span>
</Link>
);
}

View File

@@ -201,31 +201,46 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</button>
{open && (
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<>
{/* 移动端遮罩:点击关闭(桌面不渲染) */}
<div
className="flex items-center justify-between px-5 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
className="lg:hidden fixed inset-0 z-40"
style={{ background: "rgba(0,0,0,0.35)" }}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
{unread > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
>
<CheckCheck size={13} /> 全部已读
</button>
)}
</div>
{/* 移动端底部面板抓手 */}
<div
className="lg:hidden mx-auto mt-2 mb-[-6px] w-10 h-1 rounded-full shrink-0"
style={{ background: "var(--line-2)" }}
aria-hidden="true"
/>
<div
className="flex items-center justify-between px-5 py-3.5 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
{unread > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
>
<CheckCheck size={13} /> 全部已读
</button>
)}
</div>
<div className="max-h-[24rem] overflow-y-auto">
<div className="flex-1 min-h-0 max-h-[24rem] max-lg:max-h-none overflow-y-auto">
{loading ? (
<p className="text-center py-10 meta">加载中...</p>
) : notifications.length === 0 ? (
@@ -303,7 +318,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</div>
<div
className="px-4 py-2.5 text-center"
className="px-4 py-2.5 text-center shrink-0"
style={{ borderTop: "1px solid var(--line)" }}
>
<Link
@@ -314,7 +329,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
查看全部通知 →
</Link>
</div>
</div>
</div>
</>
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { useSiteBrand } from "@/components/SiteBrandProvider";
@@ -23,10 +24,9 @@ function pathIsComposeStudio(pathname: string | null) {
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
}
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
function pathIsPostDetail(pathname: string | null) {
if (!pathname) return false;
return /^\/post\/[^/]+$/.test(pathname);
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
function pathHasNoTabBar(pathname: string | null) {
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
}
/**
@@ -85,6 +85,7 @@ export default function SiteChrome({
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
const showTabBar = showBackToTop;
return (
<>
@@ -95,7 +96,6 @@ export default function SiteChrome({
initialTheme={theme}
siteName={brand.site_name}
wordmark={brand.site_wordmark}
slogan={brand.site_slogan}
logoLight={brand.logo_light_url}
logoDark={brand.logo_dark_url}
brandMark={brand.brand_mark}
@@ -105,14 +105,15 @@ export default function SiteChrome({
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-[var(--main-pad-y)] outline-none"
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none"
>
{children}
</main>
<Footer siteName={brand.site_name} links={brand.footer_links} />
{showBackToTop ? (
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
{showTabBar ? (
<MobileTabBar user={user} initialChatUnread={chatUnread} />
) : null}
{showBackToTop ? <BackToTop /> : null}
</>
);
}

View File

@@ -28,6 +28,7 @@ import {
BarChart3,
Award,
Layers,
Images,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -62,7 +63,7 @@ interface NavGroup {
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
// 分组导航:概览 → 内容 → 成员与经济 → 外观展示 → 系统
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
@@ -74,6 +75,12 @@ const NAV_GROUPS: NavGroup[] = [
{
title: "内容",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/content",
label: "内容管理",
@@ -99,12 +106,6 @@ const NAV_GROUPS: NavGroup[] = [
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
@@ -134,23 +135,17 @@ const NAV_GROUPS: NavGroup[] = [
icon: Award,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "站点",
title: "外观展示",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
@@ -163,6 +158,23 @@ const NAV_GROUPS: NavGroup[] = [
icon: PanelLeft,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/media",
label: "媒体库",
icon: Images,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
{
title: "系统",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];

View File

@@ -20,6 +20,8 @@ import type {
CheckinStatus,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { PulseCheckin } from "./CheckinCard";
import { formatCompact } from "@/lib/format";
@@ -34,16 +36,21 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
{items.length === 0 ? (
<p className="meta px-0.5 py-1.5">暂无板块管理员</p>
) : (
<ul className="space-y-0.5">
<ul className="space-y-1.5">
{items.map((u) => {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{name}
</span>
<LevelBadge level={u.level} className="shrink-0" />
{u.badges?.slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
))}
<span className="flex-1" />
</Link>
</li>
);

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

View File

@@ -1,43 +0,0 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export type Crumb = { href?: string; label: string };
/** 位置指引:嵌在正文面板顶部,不单独成块 */
export default function SiteDocBreadcrumb({
items,
className,
}: {
items: Crumb[];
className?: string;
}) {
return (
<nav
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
aria-label="面包屑"
>
{items.map((item, i) => {
const last = i === items.length - 1;
const cls = last ? "truncate" : "shrink-0";
return (
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1 min-w-0">
{i > 0 ? <ChevronRight size={12} className="shrink-0 opacity-70" aria-hidden /> : null}
{item.href ? (
<Link
href={item.href}
className={`hover:text-[var(--accent)] transition-colors ${cls}`}
style={last ? { color: "var(--accent)" } : undefined}
>
{item.label}
</Link>
) : (
<span className={cls} style={last ? { color: "var(--accent)" } : undefined}>
{item.label}
</span>
)}
</span>
);
})}
</nav>
);
}

View File

@@ -8,14 +8,11 @@ export default function SiteDocShell({
left,
right,
children,
breadcrumb,
mobileExtras,
}: {
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
breadcrumb?: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
}) {
@@ -30,7 +27,6 @@ export default function SiteDocShell({
</aside>
<div className="min-w-0">
{breadcrumb}
{children}
</div>

View File

@@ -4,6 +4,4 @@ export { default as AboutSiteHeader } from "./AboutSiteHeader";
export { default as SiteDocPageNav } from "./SiteDocPageNav";
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
export { default as SiteDocMasthead } from "./SiteDocMasthead";
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
export type { Crumb } from "./SiteDocBreadcrumb";
export type { SiteDocIdentityKind } from "./SiteDocIdentity";