();
+ boards.forEach((b) => m.set(b.id, boardColor(b)));
+ return m;
+ }, [boards]);
+
useEffect(() => {
apiListBoards()
.then((r) => setBoards(r.boards ?? []))
@@ -755,7 +762,7 @@ export default function UsersAdmin({
case "role":
return (
-
+
);
case "status":
@@ -945,7 +952,7 @@ export default function UsersAdmin({
{u.nickname}
{u.role !== ROLES.USER && (
-
+
)}
{isSelf && (
帖子 #{c.post_id}
) : (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 430f5f5..f76fc77 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -364,7 +364,7 @@ html[data-lv-fx="off"] .lv-text {
line-height: 1.4;
letter-spacing: 0.01em;
pointer-events: none;
- white-space: normal;
+ white-space: pre-line;
overflow-wrap: anywhere;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
@@ -7252,6 +7252,27 @@ a.j13-mention-token:hover {
transform: none;
}
+ /* 自助推广:购买广告位胶囊按钮 */
+ .j13-ad-buy-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 11px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.5;
+ white-space: nowrap;
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent-soft) 70%, var(--panel));
+ border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+ }
+ .j13-ad-buy-btn:hover {
+ background: var(--accent);
+ color: var(--accent-on);
+ border-color: var(--accent);
+ }
+
/* 自助推广侧栏占位:空槽可点去购买 */
.j13-ad-slot {
display: flex;
@@ -9112,6 +9133,16 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
}
.j13-fade-up { animation: j13-fade-up 0.2s ease-out both; }
+/* 深链/目录跳转落点高亮:先铺主题色浅底再渐隐(lib/flash.ts 挂载,超时自动摘除) */
+@keyframes j13-flash-fade {
+ from { background-color: var(--accent-soft); }
+ to { background-color: transparent; }
+}
+.j13-flash-target {
+ animation: j13-flash-fade 1.8s ease-out both;
+ border-radius: 10px;
+}
+
/* 跳到主内容 */
.skip-link {
position: fixed;
diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx
index 6903b43..b2c924c 100644
--- a/frontend/app/notifications/NotificationsClient.tsx
+++ b/frontend/app/notifications/NotificationsClient.tsx
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
+import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
/**
@@ -20,13 +20,18 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
}
- // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
+ // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
+ } else if (n.type === "follow") {
+ router.push(`/u/${n.actor_id}`);
+ } else if (n.comment_id > 0) {
+ // 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
+ router.push(`/post/${n.post_id}#comment-${n.comment_id}`);
} else {
router.push(`/post/${n.post_id}`);
}
@@ -92,7 +97,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
?
: n.type === "badge"
?
- : ;
+ : n.type === "follow"
+ ?
+ : ;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
@@ -162,7 +169,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
- ? "向你颁布了徽章"
+ ? "向你颁布了徽章"
+ : n.type === "follow"
+ ? "关注了你"
: "点赞了你的帖子"}
>
diff --git a/frontend/app/points/PointsClient.tsx b/frontend/app/points/PointsClient.tsx
index e4f3b02..213df3e 100644
--- a/frontend/app/points/PointsClient.tsx
+++ b/frontend/app/points/PointsClient.tsx
@@ -1,8 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
-import Link from "next/link";
-import { ChevronRight } from "lucide-react";
import {
apiGetPoints,
apiGetPointsLedger,
@@ -42,14 +40,6 @@ export default function PointsClient() {
return (
-
-
可用余额
;
+ boardColors?: Record;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
@@ -192,7 +199,9 @@ function AuthorCardStacked({
{(u.badges ?? []).slice(0, 2).map((ub) => (
))}
- {u.role !== "user" && }
+ {u.role !== "user" && (
+
+ )}
@{u.username}
@@ -307,13 +316,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
};
const meFirst = await getMeCached(cookie || undefined);
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
- const [commentsData, profile, relatedData, sidebarData] = await Promise.all([
+ const [commentsData, profile, relatedData, sidebarData, boardsData] = await Promise.all([
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
// 侧栏布局配置失败不阻塞页面,回落默认布局
fetchSidebar(cookie).catch(() => null),
+ // 板块名/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色
+ getBoardsCached().catch(() => null),
]);
+ // 板块 id → 名称 / 配色下标(与板块列表、头像底色同一套 av-N 色板)
+ const boardNames: Record = {};
+ const boardColors: Record = {};
+ for (const b of boardsData?.boards ?? []) {
+ boardNames[b.id] = b.name;
+ boardColors[b.id] = boardColor(b);
+ }
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const me = meFirst;
const comments = commentsData.comments;
@@ -336,6 +354,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
authorProfile: profile ?? undefined,
authorPosts,
toc,
+ boardNames,
+ boardColors,
};
// 三档网格退化:双栏 / 仅左 / 仅右 / 单栏
const railGridClass = !hasLeftRail && !hasRightRail
@@ -462,6 +482,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
postId={id}
liked={post.liked}
likeCount={post.like_count}
+ favorited={post.favorited === true}
allowComments={allowComments}
/>
@@ -550,6 +571,23 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
>
{post.user.nickname}
+ {/* 等级 + 管理员颁布徽章 + 站点角色徽章收拢为一组(详情接口已 Preload,随 post.user 返回) */}
+
+
+ {(post.user.badges ?? []).slice(0, 2).map((ub) => (
+
+ ))}
+ {post.user.role !== "user" && (
+
+ )}
+
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
) : null}
- {/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
+ {/* 文末点赞/收藏:分隔线后居中,唯一的互动入口(作者行不再放置) */}
+
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
)}
diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx
index 3c6fed0..98b15fe 100644
--- a/frontend/app/u/[id]/UserProfileContent.tsx
+++ b/frontend/app/u/[id]/UserProfileContent.tsx
@@ -1,9 +1,17 @@
import Link from "next/link";
-import { FileText, MessageCircle, AlertCircle } from "lucide-react";
+import {
+ FileText, MessageCircle, AlertCircle, Bookmark, UserPlus,
+} from "lucide-react";
import Pagination from "@/components/Pagination";
+import PostRow from "@/components/PostRow";
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
+import Avatar from "@/components/Avatar";
+import LevelBadge from "@/components/LevelBadge";
+import FollowButton from "@/components/FollowButton";
import { formatRelative } from "@/lib/format";
-import type { UserProfile, UserCommentsResponse } from "@/lib/api";
+import type {
+ UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
+} from "@/lib/api";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
@@ -11,10 +19,125 @@ const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
const ITEM_BASE =
"group block rounded-xl px-6 py-5 border transition-[background-color,box-shadow,border-color] duration-150";
+const SORTS: { key: string; label: string }[] = [
+ { key: "new", label: "最新" },
+ { key: "hot", label: "热门" },
+ { key: "essence", label: "精华" },
+];
+
+/** 帖子卡(发帖列表与收藏列表共用) */
+function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
+ const pinned = post.pinned > 0;
+ return (
+
+
+ {pinned && (
+
+ 置顶
+
+ )}
+
+ {post.title}
+
+ {post.recommended && 推荐}
+
+
+
+ {post.board.name}
+
+ {formatRelative(post.created_at)}
+ {allowComments ? (
+
+
+
+ {post.comment_count}
+ 评论
+
+
+ ) : null}
+
+
+
+ {post.view_count}
+ 浏览
+
+
+
+
+
+ {post.like_count}
+ 点赞
+
+
+
+
+ );
+}
+
+/** 列表区块统一空态 */
+function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
+ return (
+
+ );
+}
+
+/** 列表加载失败统一态 */
+function ErrorState({ retryHref }: { retryHref: string }) {
+ return (
+
+
+
加载失败
+
网络异常,请稍后重试
+
重新加载
+
+ );
+}
+
/**
* 用户主页内容区(纯服务端渲染):
- * tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。
- * Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责 posts / comments 列表。
+ * tab 与分页全部走 URL(?tab=xxx&page=N&sort=xxx),刷新/后退/分享链接均保持一致。
+ * Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责列表类 tab。
*/
export default function UserProfileContent({
userId,
@@ -22,153 +145,187 @@ export default function UserProfileContent({
tab,
commentData,
commentError,
+ favoriteData,
+ favoriteError,
+ followingData,
+ followingError,
page,
+ sort = "new",
+ isOwner,
allowComments = true,
}: {
userId: string;
profile: UserProfile;
- tab: "posts" | "comments";
+ tab: "posts" | "comments" | "favorites" | "following";
commentData: UserCommentsResponse | null;
commentError: boolean;
+ favoriteData: UserFavoritesResponse | null;
+ favoriteError: boolean;
+ followingData: UserFollowingResponse | null;
+ followingError: boolean;
page: number;
+ sort?: string;
+ isOwner: boolean;
allowComments?: boolean;
}) {
+ const displayName = profile.user.nickname || profile.user.username;
+ const paginationQuery: Record =
+ tab === "comments" ? { tab: "comments" }
+ : tab === "favorites" ? { tab: "favorites" }
+ : tab === "following" ? { tab: "following" }
+ : sort !== "new" ? { sort }
+ : {};
+
return (
{tab === "posts" ? (
+ {/* 标题 + 排序 chips(最新/热门/精华),与参考图一致 */}
+
+
+ {isOwner ? "我的帖子" : `${displayName} 的帖子`}
+
+
+ {SORTS.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
{Array.isArray(profile.posts) && profile.posts.length > 0 ? (
-
- {profile.posts.map((post) => {
- const pinned = post.pinned > 0;
- return (
-
-
- {pinned && (
-
- 置顶
-
- )}
-
- {post.title}
-
- {post.recommended && 推荐}
-
-
-
- {post.board.name}
-
- {formatRelative(post.created_at)}
- {allowComments ? (
-
-
-
- {post.comment_count}
- 评论
-
-
- ) : null}
-
-
-
- {post.view_count}
- 浏览
-
-
-
-
-
- {post.like_count}
- 点赞
-
-
-
-
- );
- })}
+ /* 与首页信息流一致的行式列表 */
+
+ {profile.posts.map((post, i) => (
+
+ ))}
) : (
-
+
}
+ text="暂无发帖"
+ />
)}
+ ) : tab === "favorites" ? (
+ favoriteError ? (
+
+ ) : favoriteData && favoriteData.posts.length > 0 ? (
+
+
+
+ {isOwner ? "我的收藏" : `${displayName} 的收藏`}
+
+
+
+ {favoriteData.posts.map((post) => (
+
+ ))}
+
+
+
+ ) : (
+
}
+ text="暂无收藏"
+ />
+ )
+ ) : tab === "following" ? (
+ followingError ? (
+
+ ) : followingData && followingData.users.length > 0 ? (
+
+
+
+ {isOwner ? "我的关注" : `${displayName} 的关注`}
+
+
+
+ {followingData.users.map((fu) => (
+
+
+
+
+
+
+ {fu.nickname || fu.username}
+
+
+
+
+ @{fu.username}
+ {fu.signature ? ` · ${fu.signature}` : ""}
+
+
+
+ {isOwner &&
}
+
+ ))}
+
+
+
+ ) : (
+
}
+ text="暂无关注"
+ />
+ )
) : commentError ? (
-
-
-
评论加载失败
-
网络异常,请稍后重试
-
重新加载
-
+
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
-
+
{(commentData.comments ?? []).map((c) => (
-
- 评论于《
{c.post_title}》
+
+
+
+ 评论于《{c.post_title}》
+
{c.content}
-
+
{formatRelative(c.created_at)}
@@ -179,21 +336,14 @@ export default function UserProfileContent({
page={commentData.page}
size={20}
basePath={`/u/${userId}`}
- query={{ tab: "comments" }}
+ query={paginationQuery}
/>
) : (
-
+
}
+ text="暂无评论"
+ />
)}
);
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index 766033a..c998e13 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -3,14 +3,16 @@ import { cookies, headers } from "next/headers";
import { Metadata } from "next";
import {
FileText, MessageCircle, Flame, Calendar, Hash, Shield,
- Settings, KeyRound, Info, PenLine,
+ Settings, KeyRound, Info, PenLine, Bookmark, UserPlus, Mail,
} from "lucide-react";
import {
fetchUserProfile, fetchUserComments, fetchLoginDevices,
+ fetchUserFavorites, fetchUserFollowing,
type UserProfile, type UserCommentsResponse, type LoginDevice,
+ type UserFavoritesResponse, type UserFollowingResponse,
} from "@/lib/api";
import { authCookieHeader, sessionCookieHeader } from "@/lib/cookies";
-import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import { getMeCached, getPublicSettingsCached, getBoardsCached } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
@@ -19,8 +21,10 @@ import ProfileHeroAvatar from "./ProfileHeroAvatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
+import { boardColor } from "@/components/BoardIcon";
import { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton";
+import FollowButton from "@/components/FollowButton";
import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
@@ -29,19 +33,23 @@ import JsonLd from "@/components/JsonLd";
interface UserPageProps {
params: Promise<{ id: string }>;
- searchParams: Promise<{ page?: string; tab?: string }>;
+ searchParams: Promise<{ page?: string; tab?: string; sort?: string }>;
}
-export type UserTab = "posts" | "comments" | "settings" | "security";
+export type UserTab = "posts" | "comments" | "favorites" | "following" | "settings" | "security";
+
+function parseTab(rawTab?: string): UserTab {
+ return rawTab === "comments" ? "comments" :
+ rawTab === "favorites" ? "favorites" :
+ rawTab === "following" ? "following" :
+ rawTab === "settings" ? "settings" :
+ rawTab === "security" ? "security" : "posts";
+}
export async function generateMetadata({ params, searchParams }: UserPageProps): Promise
{
const { id } = await params;
const sp = await searchParams;
- const rawTab = sp.tab;
- const tab: UserTab =
- rawTab === "comments" ? "comments" :
- rawTab === "settings" ? "settings" :
- rawTab === "security" ? "security" : "posts";
+ const tab = parseTab(sp.tab);
try {
const data = await fetchUserProfile(id, 1);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
@@ -55,6 +63,9 @@ export async function generateMetadata({ params, searchParams }: UserPageProps):
}
const query: Record = {};
if (allowComments && tab === "comments") query.tab = "comments";
+ if (tab === "favorites") query.tab = "favorites";
+ if (tab === "following") query.tab = "following";
+ if (tab === "posts" && (sp.sort === "hot" || sp.sort === "essence")) query.sort = sp.sort;
const page = canonicalPage(sp.page);
if (page) query.page = page;
return {
@@ -75,11 +86,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const { id } = await params;
const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
- const rawTab = sp.tab;
- const tab: UserTab =
- rawTab === "comments" ? "comments" :
- rawTab === "settings" ? "settings" :
- rawTab === "security" ? "security" : "posts";
+ const tab = parseTab(sp.tab);
+ const sort = sp.sort === "hot" || sp.sort === "essence" ? sp.sort : "new";
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -92,7 +100,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
let data: UserProfile;
try {
// 带 cookie:超管/站长才能拿到 banned 字段
- data = await fetchUserProfile(id, page, cookie || undefined);
+ data = await fetchUserProfile(id, page, cookie || undefined, sort);
} catch {
return (
@@ -107,6 +115,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
);
}
+ // 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色
+ const boardNames: Record = {};
+ const boardColors: Record = {};
+ try {
+ for (const b of (await getBoardsCached()).boards) {
+ boardNames[b.id] = b.name;
+ boardColors[b.id] = boardColor(b);
+ }
+ } catch {
+ /* 板块列表失败时回落通用角色徽章 */
+ }
+
// 判断是否本人(决定能否看到 settings / security tab)
const isOwner = me.user?.id === data.user.id;
const currentUser = me.user;
@@ -119,12 +139,34 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
let commentError = false;
if (canReadComments && effectiveTab === "comments") {
try {
- commentData = await fetchUserComments(id, page);
+ commentData = await fetchUserComments(id, page, cookie || undefined);
} catch {
commentError = true;
}
}
+ // 收藏 tab:SSR 直出对应分页(公开可见)
+ let favoriteData: UserFavoritesResponse | null = null;
+ let favoriteError = false;
+ if (effectiveTab === "favorites") {
+ try {
+ favoriteData = await fetchUserFavorites(id, page);
+ } catch {
+ favoriteError = true;
+ }
+ }
+
+ // 关注 tab:SSR 直出对应分页(公开可见)
+ let followingData: UserFollowingResponse | null = null;
+ let followingError = false;
+ if (effectiveTab === "following") {
+ try {
+ followingData = await fetchUserFollowing(id, page);
+ } catch {
+ followingError = true;
+ }
+ }
+
// 安全设置:仍有效的登录会话(仅本人)
// SSR 直连后端时必须转发 refresh cookie + 浏览器 UA/IP,才能标「当前在线」并避免把 Node fetch 当成一台设备
let loginDevices: LoginDevice[] = [];
@@ -148,20 +190,29 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const showPoints = data.stats.points > 0 || isOwner;
const hasHeroActions = isOwner || !!currentUser;
- // 导航项(桌面左栏 / 移动 chips 共用定义)
- const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
+ // 导航项(桌面左栏 / 移动 chips 共用定义):「个人主页」+「设置」两组,与参考图一致
+ const mainNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = [
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: },
...(canReadComments
- ? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: }]
+ ? [{ key: "comments" as const, label: "回帖", count: data.stats.comment_count, icon: }]
: []),
- { key: "settings", label: "资料设置", icon: , ownerOnly: true },
- { key: "security", label: "安全设置", icon: , ownerOnly: true },
+ { key: "favorites", label: "收藏", count: data.stats.favorite_count, icon: },
+ { key: "following", label: "关注", count: data.stats.following_count, icon: },
];
- const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner);
+ const settingsNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = isOwner
+ ? [
+ { key: "settings", label: "资料设置", icon: },
+ { key: "security", label: "安全设置", icon: },
+ ]
+ : [];
+ const visibleNav = [...mainNavItems, ...settingsNavItems];
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
const profileQuery: Record = {};
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
+ if (effectiveTab === "favorites") profileQuery.tab = "favorites";
+ if (effectiveTab === "following") profileQuery.tab = "following";
+ if (effectiveTab === "posts" && sort !== "new") profileQuery.sort = sort;
if (page > 1) profileQuery.page = page;
const profileLd =
effectiveTab === "settings" || effectiveTab === "security"
@@ -220,6 +271,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
) : currentUser ? (
+
{u.role !== "user" && (
-
+
)}
{u.banned === true && (
- {visibleNav.map((t) => (
+ {mainNavItems.map((t) => (
))}
+ {settingsNavItems.length > 0 && (
+ <>
+
+ 设置
+
+
+ {settingsNavItems.map((t) => (
+
+ {t.icon}
+ {t.label}
+
+ ))}
+
+ >
+ )}
{/* 中栏:Tab 内容 */}
- {effectiveTab === "posts" || effectiveTab === "comments" ? (
+ {effectiveTab === "posts" ||
+ effectiveTab === "comments" ||
+ effectiveTab === "favorites" ||
+ effectiveTab === "following" ? (
) : effectiveTab === "settings" && isOwner && currentUser ? (
@@ -421,35 +510,45 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
账号信息
-
-
-
用户名
-
+
+
-
-
+
-
-
+
+
角色
-
+
{roleMeta(u.role).label}
-
-
用户 ID
-
+
+ {u.email && (
+
+
+ 邮箱
+
+
+ {u.email}
+
+
+ )}
diff --git a/frontend/components/AdText.tsx b/frontend/components/AdText.tsx
index f9bb82e..a9210ff 100644
--- a/frontend/components/AdText.tsx
+++ b/frontend/components/AdText.tsx
@@ -1,12 +1,15 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { AD_TIP_DELAY_MS, adHoverTip } from "@/lib/ads";
type AdTextProps = {
title: string;
textColor?: string;
bgColor?: string;
href?: string;
+ /** 广告到期时间(ISO 字符串):有值时气泡首行展示,无值时仅显示免责声明 */
+ endsAt?: string | null;
className?: string;
};
@@ -14,7 +17,14 @@ type AdTextProps = {
* 文字广告:仅当文字宽度超出容器时才启用跑马灯滚动,
* 能完整显示时居中静止。结构与样式复用侧栏 .j13-ad-text。
*/
-export function AdText({ title, textColor, bgColor, href, className = "" }: AdTextProps) {
+export function AdText({
+ title,
+ textColor,
+ bgColor,
+ href,
+ endsAt,
+ className = "",
+}: AdTextProps) {
const trackRef = useRef
(null);
const [overflow, setOverflow] = useState(false);
@@ -35,6 +45,9 @@ export function AdText({ title, textColor, bgColor, href, className = "" }: AdTe
const cls = `j13-ad-text${overflow ? " is-overflow" : ""}${className ? ` ${className}` : ""}`;
const style: CSSProperties = { color: textColor, background: bgColor };
+ // 气泡首行:到期时间;第二行:免责声明
+ const tip = adHoverTip(endsAt);
+
const inner = (
{inner}
);
}
return (
-
+
{inner}
);
diff --git a/frontend/components/BadgeChip.tsx b/frontend/components/BadgeChip.tsx
index 775c9f4..5060b97 100644
--- a/frontend/components/BadgeChip.tsx
+++ b/frontend/components/BadgeChip.tsx
@@ -22,11 +22,11 @@ export default function BadgeChip({
return (
-
+
{badge.name}
);
diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx
index 14c9474..e19b312 100644
--- a/frontend/components/CommentSection.tsx
+++ b/frontend/components/CommentSection.tsx
@@ -39,6 +39,8 @@ import MarkdownEditor from "@/components/MarkdownEditor";
import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import EditHistoryModal from "@/components/EditHistoryModal";
+import { scrollFlashTo } from "@/lib/flash";
+import { fetchCommentLocation } from "@/lib/api";
interface CommentSectionProps {
postId: string;
@@ -288,6 +290,59 @@ export default function CommentSection({
return () => window.removeEventListener("hashchange", focusComposer);
}, [user, commentsOpen]);
+ // 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。
+ // 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。
+ // 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
+ const locatedRef = useRef(null); // 已跨页尝试过的评论 ID,防循环
+ useEffect(() => {
+ if (!commentsOpen) return;
+ let cancelled = false;
+ const locate = async () => {
+ const m = window.location.hash.match(/^#comment-(\d+)$/);
+ if (!m) return;
+ const id = Number(m[1]);
+ const el = document.getElementById(`comment-${id}`);
+ if (el) {
+ scrollFlashTo(el);
+ return;
+ }
+ // 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位
+ const node = findComment(comments, id);
+ if (node) {
+ const rootId = node.root_id ?? node.parent_id ?? node.id;
+ setExpandedFloors((prev) => {
+ if (prev.has(rootId)) return prev;
+ const next = new Set(prev);
+ next.add(rootId);
+ return next;
+ });
+ window.setTimeout(() => {
+ if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`));
+ }, 160);
+ return;
+ }
+ // 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位)
+ if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃
+ locatedRef.current = id;
+ try {
+ const floor = await fetchCommentLocation(postId, id);
+ if (cancelled || floor <= 0) return;
+ const destPage = Math.max(1, Math.ceil(floor / size));
+ if (destPage === page) return; // 同页却无节点(异常数据),放弃
+ router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false });
+ } catch {
+ /* 定位失败静默:停留在当前页 */
+ }
+ };
+ locate();
+ window.addEventListener("hashchange", locate);
+ return () => {
+ cancelled = true;
+ window.removeEventListener("hashchange", locate);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [comments, commentsOpen, page, postId, size]);
+
const submitFloor = async () => {
if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return;
submitLock.current = true;
diff --git a/frontend/components/FavoriteButton.tsx b/frontend/components/FavoriteButton.tsx
new file mode 100644
index 0000000..5f38fa5
--- /dev/null
+++ b/frontend/components/FavoriteButton.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { Bookmark } from "lucide-react";
+import { apiToggleFavorite } from "@/lib/api";
+import { toast } from "@/lib/toast";
+
+interface FavoriteButtonProps {
+ postId: string;
+ favorited: boolean;
+ variant?: "default" | "compact" | "article";
+}
+
+export default function FavoriteButton({
+ postId,
+ favorited: initialFavorited,
+ variant = "default",
+}: FavoriteButtonProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [favorited, setFavorited] = useState(initialFavorited);
+ const [loading, setLoading] = useState(false);
+
+ // router.refresh()(登录/登出后)服务端会重新下发 favorited 态,同步到本地
+ useEffect(() => {
+ setFavorited(initialFavorited);
+ }, [initialFavorited]);
+
+ const handleClick = async () => {
+ if (loading) return;
+ setLoading(true);
+ const wasFavorited = favorited;
+ setFavorited(!wasFavorited);
+ try {
+ const res = await apiToggleFavorite(postId);
+ if (res.error === "未登录") {
+ router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
+ return;
+ }
+ if (typeof res.favorited === "boolean") {
+ setFavorited(res.favorited);
+ } else {
+ throw new Error(res.error || "FAIL");
+ }
+ } catch {
+ setFavorited(wasFavorited);
+ toast("操作失败,请稍后重试");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const sizeCls =
+ variant === "compact"
+ ? "px-2.5 py-1.5 gap-1 text-[12.5px]"
+ : variant === "article"
+ ? "px-7 py-3 gap-2 text-[15px] min-w-[132px]"
+ : "px-4 py-2 gap-1.5 text-sm";
+ const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
+
+ return (
+
+ );
+}
diff --git a/frontend/components/FollowButton.tsx b/frontend/components/FollowButton.tsx
new file mode 100644
index 0000000..9c99ea4
--- /dev/null
+++ b/frontend/components/FollowButton.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { UserPlus, UserCheck } from "lucide-react";
+import { apiToggleFollow } from "@/lib/api";
+import { toast } from "@/lib/toast";
+
+interface FollowButtonProps {
+ userId: string | number;
+ following: boolean;
+ size?: "default" | "sm";
+}
+
+export default function FollowButton({
+ userId,
+ following: initialFollowing,
+ size = "default",
+}: FollowButtonProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [following, setFollowing] = useState(initialFollowing);
+ const [loading, setLoading] = useState(false);
+
+ // router.refresh()(登录/登出后)服务端会重新下发关注态,同步到本地
+ useEffect(() => {
+ setFollowing(initialFollowing);
+ }, [initialFollowing]);
+
+ const handleClick = async () => {
+ if (loading) return;
+ setLoading(true);
+ const wasFollowing = following;
+ setFollowing(!wasFollowing);
+ try {
+ const res = await apiToggleFollow(userId);
+ if (res.error === "未登录") {
+ router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
+ return;
+ }
+ if (typeof res.following === "boolean") {
+ setFollowing(res.following);
+ // 关注数变化后刷新页面统计(导航计数、Hero 统计)
+ router.refresh();
+ } else {
+ throw new Error(res.error || "FAIL");
+ }
+ } catch {
+ setFollowing(wasFollowing);
+ toast("操作失败,请稍后重试");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const sizeCls = size === "sm" ? "px-2.5 py-1 gap-1 text-[12px]" : "btn-sm";
+ const iconSize = size === "sm" ? 12 : 14;
+
+ return (
+
+ );
+}
diff --git a/frontend/components/MobilePostBar.tsx b/frontend/components/MobilePostBar.tsx
index 2ff10cd..826ecdf 100644
--- a/frontend/components/MobilePostBar.tsx
+++ b/frontend/components/MobilePostBar.tsx
@@ -2,21 +2,24 @@
import { MessageSquare } from "lucide-react";
import LikeButton from "./LikeButton";
+import FavoriteButton from "./FavoriteButton";
import { scrollBehavior } from "@/lib/motion";
/**
* 帖子详情移动端浮动操作条(lg 以下显示):
- * 长文读到中途也能点赞 / 跳评论框;回顶由全局 BackToTop 承担。
+ * 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。
*/
export default function MobilePostBar({
postId,
liked,
likeCount,
+ favorited = false,
allowComments = true,
}: {
postId: string;
liked: boolean;
likeCount: number;
+ favorited?: boolean;
allowComments?: boolean;
}) {
const scrollToComments = () => {
@@ -52,6 +55,7 @@ export default function MobilePostBar({
+
);
diff --git a/frontend/components/NotificationBell.tsx b/frontend/components/NotificationBell.tsx
index a63182c..0af4ef8 100644
--- a/frontend/components/NotificationBell.tsx
+++ b/frontend/components/NotificationBell.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
+import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
@@ -99,13 +99,18 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
);
}
setOpen(false);
- // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
+ // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
+ } else if (n.type === "follow") {
+ router.push(`/u/${n.actor_id}`);
+ } else if (n.comment_id > 0) {
+ // 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
+ router.push(`/post/${n.post_id}#comment-${n.comment_id}`);
} else {
router.push(`/post/${n.post_id}`);
}
@@ -138,6 +143,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "deleted") return "删除了你的帖子";
if (n.type === "badge") return "向你颁布了徽章";
if (n.type === "necro_reply") return "旧帖回复提醒";
+ if (n.type === "follow") return "关注了你";
return "点赞了你的帖子";
};
@@ -161,6 +167,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
return { color: "var(--accent)", bg: "var(--accent-soft)", node:
};
if (n.type === "badge")
return { color: "var(--gold)", bg: "var(--gold-soft)", node:
};
+ if (n.type === "follow")
+ return { color: "var(--accent)", bg: "var(--accent-soft)", node:
};
return { color: "var(--accent)", bg: "var(--accent-soft)", node:
};
};
diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx
index d4f974d..e6cf028 100644
--- a/frontend/components/PostRow.tsx
+++ b/frontend/components/PostRow.tsx
@@ -6,7 +6,6 @@ import { getPublicSettingsCached } from "@/lib/serverData";
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import LikeButton from "./LikeButton";
import Avatar from "./Avatar";
-import BadgeChip from "./BadgeChip";
import { IconComment, IconEye } from "./Icons";
/**
@@ -104,9 +103,6 @@ export default async function PostRow({
>
{authorName}
- {(post.user.badges ?? []).slice(0, 2).map((ub) => (
-
- ))}
·
{formatRelative(post.created_at)}
{replied && (
diff --git a/frontend/components/PostToc.tsx b/frontend/components/PostToc.tsx
index 432dd66..acd14cc 100644
--- a/frontend/components/PostToc.tsx
+++ b/frontend/components/PostToc.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { scrollBehavior } from "@/lib/motion";
+import { flashElement } from "@/lib/flash";
export interface TocItem {
level: 2 | 3;
@@ -53,6 +54,7 @@ export default function PostToc({ items }: { items: TocItem[] }) {
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
+ flashElement(target); // 主题色浅底高亮渐隐,标记落点
setActive(id);
};
diff --git a/frontend/components/RoleBadge.tsx b/frontend/components/RoleBadge.tsx
index d4ab638..ee11475 100644
--- a/frontend/components/RoleBadge.tsx
+++ b/frontend/components/RoleBadge.tsx
@@ -2,6 +2,7 @@
import { Crown, BadgeCheck, ShieldCheck, Shield, Users, type LucideIcon } from "lucide-react";
import { roleMeta, type RoleIconKey } from "@/lib/roles";
+import { BOARD_COLOR_COUNT } from "@/components/BoardIcon";
// 角色图标集中映射(图标库不含 ShieldStar,超管使用 BadgeCheck 表达"全权限认证")
const ICONS: Record
= {
@@ -12,59 +13,92 @@ const ICONS: Record = {
user: Users,
};
+/** 已知板块信息:名称 + 配色下标(0..15) */
+type BoardRef = { name: string; colorIdx: number | null };
+
/**
- * 角色徽章:图标 + 文案,配色走角色元数据中的 CSS token。
- * - board_admin 额外展示授权板块数(或板块名)
- * - variant=plain 时仅图标+文字不着底色(用于表头/文本行)
+ * 角色徽章:图标 + 文案。
+ * - 普通角色:配色走角色元数据中的 CSS token
+ * - board_admin 提供板块名时逐板块渲染「{板块名} · 板块管理员」,配色对齐板块色
+ * (av-N,深浅色自适应);truncate 窄列场景折叠为单枚「{首板块}等 N 个板块 · 板块管理员」
+ * - board_admin 未提供板块名时退回「板块管理员 · N 个板块」
*/
export default function RoleBadge({
role,
boardIds,
boardNames,
- size = 11,
+ boardColors,
+ size = 12,
className = "",
title,
truncate = false,
}: {
role?: string | null;
boardIds?: number[] | null;
- /** id -> 名称映射;提供时板块管理员展示板块名(多选时折叠为 N 个板块) */
+ /** id -> 名称映射;提供时板块管理员按板块逐枚展示 */
boardNames?: Map | Record;
+ /** id -> 配色下标(boardColor 计算);提供时徽章配色对齐板块色 */
+ boardColors?: Map | Record;
size?: number;
className?: string;
title?: string;
- /** 窄列场景:限制最大宽度并省略过长文案 */
+ /** 窄列场景:限制最大宽度并省略过长文案;多板块折叠为单枚 */
truncate?: boolean;
}) {
const meta = roleMeta(role);
const Icon = ICONS[meta.icon];
- let label = meta.label;
- if (role === "board_admin") {
- if (boardNames) {
- const names = (boardIds ?? [])
- .map((id) => (boardNames instanceof Map ? boardNames.get(id) : boardNames[id]))
- .filter(Boolean) as string[];
- if (names.length === 1) {
- label = `${meta.label} · ${names[0]}`;
- } else if (names.length > 1) {
- label = `${meta.label} · ${names.length} 个板块`;
- }
- } else if (boardIds && boardIds.length > 0) {
- label = `${meta.label} · ${boardIds.length} 个板块`;
- }
- }
-
- return (
+ // 单枚渲染:有板块配色下标时用 av-N 类(自带深浅色两套值),否则角色配色 token
+ const renderBadge = (label: string, boardColorIdx: number | null, key?: number) => (
{truncate ? {label} : label}
);
+
+ if (role === "board_admin" && boardNames) {
+ const boards = (boardIds ?? [])
+ .map((id): BoardRef | null => {
+ const name = boardNames instanceof Map ? boardNames.get(id) : boardNames[id];
+ if (!name) return null;
+ const c = boardColors
+ ? boardColors instanceof Map
+ ? boardColors.get(id)
+ : boardColors[id]
+ : undefined;
+ return { name, colorIdx: typeof c === "number" ? c % BOARD_COLOR_COUNT : null };
+ })
+ .filter((b): b is BoardRef => b !== null);
+
+ if (boards.length > 0) {
+ if (truncate) {
+ // 窄列:折叠为单枚,避免多枚徽章撑破布局
+ const first = boards[0];
+ const label =
+ boards.length === 1
+ ? `${first.name} · ${meta.label}`
+ : `${first.name} 等 ${boards.length} 个板块 · ${meta.label}`;
+ return renderBadge(label, first.colorIdx);
+ }
+ return <>{boards.map((b, i) => renderBadge(`${b.name} · ${meta.label}`, b.colorIdx, i))}>;
+ }
+ // 板块名缺失(板块被删等):回落通用渲染
+ }
+
+ let label = meta.label;
+ if (role === "board_admin" && boardIds && boardIds.length > 0) {
+ label = `${meta.label} · ${boardIds.length} 个板块`;
+ }
+ return renderBadge(label, null);
}
diff --git a/frontend/components/TipHost.tsx b/frontend/components/TipHost.tsx
index dbe186a..48e4776 100644
--- a/frontend/components/TipHost.tsx
+++ b/frontend/components/TipHost.tsx
@@ -136,7 +136,17 @@ export default function TipHost() {
const activeEl = useRef(null);
useEffect(() => {
+ let pendingTimer: number | null = null;
+
+ const clearPending = () => {
+ if (pendingTimer !== null) {
+ window.clearTimeout(pendingTimer);
+ pendingTimer = null;
+ }
+ };
+
const hide = () => {
+ clearPending();
activeEl.current = null;
setTip(null);
};
@@ -160,8 +170,27 @@ export default function TipHost() {
hide();
return;
}
- activeEl.current = host;
- placeTip(host, text);
+ // 同一目标已在显示:直接复位位置,不重新计时
+ if (activeEl.current === host && pendingTimer === null) {
+ placeTip(host, text);
+ return;
+ }
+ // data-tip-delay="毫秒":停留一会再显示,期间移出即取消
+ const delay = Number(host.getAttribute("data-tip-delay") || 0);
+ if (!Number.isFinite(delay) || delay <= 0) {
+ clearPending();
+ activeEl.current = host;
+ placeTip(host, text);
+ return;
+ }
+ clearPending();
+ pendingTimer = window.setTimeout(() => {
+ pendingTimer = null;
+ const focused = host.contains(document.activeElement);
+ if (!host.matches(":hover") && !focused) return;
+ activeEl.current = host;
+ placeTip(host, text);
+ }, delay);
};
const onOver = (e: Event) => {
@@ -202,6 +231,7 @@ export default function TipHost() {
window.addEventListener("resize", onScrollOrResize);
return () => {
+ clearPending();
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("focusin", onOver);
diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx
index 237fda7..b8c1fc8 100644
--- a/frontend/components/home/CommunityPanels.tsx
+++ b/frontend/components/home/CommunityPanels.tsx
@@ -34,7 +34,13 @@ import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
-import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads";
+import {
+ AD_IMAGE_HEIGHT,
+ AD_IMAGE_SIZE_LABEL,
+ AD_IMAGE_WIDTH,
+ AD_TIP_DELAY_MS,
+ adHoverTip,
+} from "@/lib/ads";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -464,10 +470,7 @@ export function AdsPanel({
{title || "自助推广"}
-
+
购买广告位
@@ -482,6 +485,8 @@ export function AdsPanel({
target="_blank"
rel="noopener noreferrer sponsored"
className="j13-ad-image-link"
+ data-tip={adHoverTip(ad.ends_at)}
+ data-tip-delay={AD_TIP_DELAY_MS}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
) : (
diff --git a/frontend/components/post/PostRailBlocks.tsx b/frontend/components/post/PostRailBlocks.tsx
index f8c6139..429f6a4 100644
--- a/frontend/components/post/PostRailBlocks.tsx
+++ b/frontend/components/post/PostRailBlocks.tsx
@@ -5,6 +5,7 @@ import BoardIcon, { boardColor } from "@/components/BoardIcon";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
+import BadgeChip from "@/components/BadgeChip";
// formatYMD 帖子详情页左栏「作者其他帖」日期
function formatYMD(dateStr: string) {
@@ -56,9 +57,14 @@ export function BoardCard({ board }: { board: Board }) {
export function AuthorRailCard({
profile,
allowComments,
+ boardNames,
+ boardColors,
}: {
profile: UserProfile;
allowComments: boolean;
+ /** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
+ boardNames?: Record;
+ boardColors?: Record;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
@@ -89,13 +95,23 @@ export function AuthorRailCard({
@{u.username}
- {u.role !== "user" && (
-
-
-
- )}
+ {/* 徽章行:站点角色 + 管理员颁布徽章统一收拢一处,悬浮显示颁布时间;窄栏最多 4 枚 */}
+ {(u.role !== "user" || (u.badges ?? []).length > 0) && (
+
+ {u.role !== "user" && (
+
+ )}
+ {(u.badges ?? []).slice(0, 4).map((ub) => (
+
+ ))}
+
+ )}
{/* 有签名则展示;无则留空,不放占位文案 */}
{signature ? (
) : null}
+ {/* 数据面板:圆角浅底 + 竖线分隔(参考作者卡布局,配色走站点令牌) */}
{stats.map((s, i) => (
0 ? { borderLeft: "1px solid var(--line)" } : undefined}
>
{s.value}
@@ -128,12 +147,17 @@ export function AuthorRailCard({
))}
+ {/* 连签胶囊:金色调软底,与站点 gold 令牌一致 */}
{streak > 0 && (
- 连签 {streak} 天
+ 连签 {streak} 天
)}
diff --git a/frontend/components/sidebar/registry.tsx b/frontend/components/sidebar/registry.tsx
index 813393f..17c7e23 100644
--- a/frontend/components/sidebar/registry.tsx
+++ b/frontend/components/sidebar/registry.tsx
@@ -78,6 +78,9 @@ export interface SidebarBlockProps {
authorProfile?: UserProfile;
authorPosts?: PostListItem[];
toc?: TocItem[];
+ // 板块 id → 名称/配色下标:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色
+ boardNames?: Record
;
+ boardColors?: Record;
}
// 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。
@@ -143,7 +146,12 @@ export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
return p.postBoard ? : null;
case "post_author":
return p.authorProfile ? (
-
+
) : null;
case "post_author_posts":
return p.authorPosts ? : null;
diff --git a/frontend/lib/ads.ts b/frontend/lib/ads.ts
index 0d2eeb6..93116c0 100644
--- a/frontend/lib/ads.ts
+++ b/frontend/lib/ads.ts
@@ -6,3 +6,22 @@ export const AD_TEXT_COL_WIDTH = 121;
/** 购买页建议文案 */
export const AD_IMAGE_SIZE_LABEL = `${AD_IMAGE_WIDTH}×${AD_IMAGE_HEIGHT}`;
export const AD_IMAGE_SIZE_LABEL_2X = `${AD_IMAGE_WIDTH * 2}×${AD_IMAGE_HEIGHT * 2}`;
+
+/** 广告悬停提示第二行:统一免责声明 */
+export const AD_TIP_DISCLAIMER = "斟酌选择,本站不做担保。";
+/** 广告悬停提示延迟弹出时长(毫秒):停留一会再显示 */
+export const AD_TIP_DELAY_MS = 600;
+
+/** 到期时间展示格式:2026-10-01 23:59 */
+export function formatAdEndsAt(value: string): string {
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return "";
+ const p = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+/** 广告悬停提示:首行到期时间(可选),第二行免责声明(\n 由气泡 pre-line 换行) */
+export function adHoverTip(endsAt?: string | null): string {
+ const t = endsAt ? formatAdEndsAt(endsAt) : "";
+ return t ? `到期时间:${t}\n${AD_TIP_DISCLAIMER}` : AD_TIP_DISCLAIMER;
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 49fda3b..a0f5da7 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -295,6 +295,7 @@ export interface PublicAdItem {
title?: string;
text_color?: string;
bg_color?: string;
+ ends_at?: string | null;
}
export interface PublicSponsorItem {
@@ -392,6 +393,8 @@ export interface Post {
view_count: number;
comment_count: number;
liked: boolean;
+ /** 详情接口返回:当前查看者是否已收藏 */
+ favorited?: boolean;
/** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */
edited?: boolean;
created_at: string;
@@ -584,12 +587,18 @@ export interface UserProfile {
total_points?: number;
// 徽章墙(全量,含授予时间)
badges?: UserBadgeView[];
+ /** 访客(登录态)与该用户的关注关系;本人访问恒 false */
+ is_following?: boolean;
+ /** 仅本人访问时下发的打码邮箱(j***@163.com) */
+ email?: string;
};
stats: {
post_count: number;
comment_count: number;
points: number;
streak: number;
+ favorite_count: number;
+ following_count: number;
};
posts: PostListItem[];
posts_total: number;
@@ -612,11 +621,34 @@ export interface UserCommentsResponse {
size: number;
}
+export interface UserFavoritesResponse {
+ posts: PostListItem[];
+ total: number;
+ page: number;
+ size: number;
+}
+
+export interface UserFollowingItem {
+ id: number;
+ username: string;
+ nickname: string;
+ avatar: string;
+ level?: number;
+ signature?: string;
+}
+
+export interface UserFollowingResponse {
+ users: UserFollowingItem[];
+ total: number;
+ page: number;
+ size: number;
+}
+
export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
- type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply";
+ type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply" | "follow";
post_id: number;
comment_id: number;
room_id?: number;
@@ -1286,8 +1318,25 @@ export async function fetchComments(
return res.json();
}
-export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise {
- const params = new URLSearchParams({ page: String(page), size: "20" });
+// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
+// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
+export async function fetchCommentLocation(
+ postId: string | number,
+ commentId: number
+): Promise {
+ const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
+ if (!res.ok) return 0;
+ const data = (await res.json()) as { floor?: number };
+ return data.floor || 0;
+}
+
+export async function fetchUserProfile(
+ id: string,
+ page = 1,
+ cookieHeader?: string,
+ sort: string = "new"
+): Promise {
+ const params = new URLSearchParams({ page: String(page), size: "20", sort });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败");
@@ -1307,9 +1356,11 @@ export async function fetchMe(cookieHeader?: string): Promise {
}
}
-export async function fetchUserComments(id: string, page = 1): Promise {
+// cookieHeader:SSR 时必须转发访问者 cookie——后端开启「评论需登录」时,
+// 匿名请求会拿到空列表(表现为所有人主页回帖都显示暂无评论)
+export async function fetchUserComments(id: string, page = 1, cookieHeader?: string): Promise {
const params = new URLSearchParams({ page: String(page), size: "20" });
- const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
+ const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取评论失败");
const data = (await res.json()) as UserCommentsResponse;
@@ -1317,6 +1368,24 @@ export async function fetchUserComments(id: string, page = 1): Promise {
+ const params = new URLSearchParams({ page: String(page), size: "20" });
+ const res = await fetch(`${API_BASE}/api/users/${id}/favorites?${params}`, ssrInit());
+ if (res.status === 404) throw new Error("用户不存在");
+ if (!res.ok) throw new Error("获取收藏失败");
+ const data = (await res.json()) as UserFavoritesResponse;
+ return { ...data, posts: data.posts ?? [] };
+}
+
+export async function fetchUserFollowing(id: string, page = 1): Promise {
+ const params = new URLSearchParams({ page: String(page), size: "20" });
+ const res = await fetch(`${API_BASE}/api/users/${id}/following?${params}`, ssrInit());
+ if (res.status === 404) throw new Error("用户不存在");
+ if (!res.ok) throw new Error("获取关注列表失败");
+ const data = (await res.json()) as UserFollowingResponse;
+ return { ...data, users: data.users ?? [] };
+}
+
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise {
const params = new URLSearchParams({ page: String(page), size: "20" });
@@ -2242,6 +2311,22 @@ export async function apiToggleLike(postId: string) {
return res.json();
}
+export async function apiToggleFavorite(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/favorite`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiToggleFollow(userId: string | number) {
+ const res = await fetchWithRefresh(`/api/users/${userId}/follow`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
export async function apiDeleteComment(
postId: string,
commentId: number,
diff --git a/frontend/lib/flash.ts b/frontend/lib/flash.ts
new file mode 100644
index 0000000..3ecdc93
--- /dev/null
+++ b/frontend/lib/flash.ts
@@ -0,0 +1,34 @@
+/**
+ * 跳转目标高亮:目标区域先铺主题色浅底(--accent-soft)再渐隐。
+ * 供评论深链定位与文章目录树跳转共用;样式见 globals.css 的 .j13-flash-target。
+ */
+import { scrollBehavior } from "@/lib/motion";
+
+const FLASH_MS = 1800;
+const CLEANUP_MS = FLASH_MS + 300;
+
+const timers = new WeakMap>();
+
+export function flashElement(el: HTMLElement | null | undefined) {
+ if (!el) return;
+ el.classList.remove("j13-flash-target");
+ // 强制重排,支持连续触发同一元素
+ void el.offsetWidth;
+ el.classList.add("j13-flash-target");
+ const prev = timers.get(el);
+ if (prev) clearTimeout(prev);
+ timers.set(
+ el,
+ setTimeout(() => {
+ el.classList.remove("j13-flash-target");
+ timers.delete(el);
+ }, CLEANUP_MS)
+ );
+}
+
+/** 滚动到目标(尊重平滑滚动开关与 scroll-margin)并高亮渐隐 */
+export function scrollFlashTo(el: HTMLElement | null | undefined, block: ScrollLogicalPosition = "start") {
+ if (!el) return;
+ el.scrollIntoView({ behavior: scrollBehavior(), block });
+ flashElement(el);
+}
diff --git a/frontend/lib/serverData.ts b/frontend/lib/serverData.ts
index 332b6e0..e8ddf8d 100644
--- a/frontend/lib/serverData.ts
+++ b/frontend/lib/serverData.ts
@@ -1,5 +1,5 @@
import { cache } from "react";
-import { fetchMe, fetchPublicSettings, fetchSiteState } from "./api";
+import { fetchBoards, fetchMe, fetchPublicSettings, fetchSiteState } from "./api";
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
@@ -10,3 +10,6 @@ export const getPublicSettingsCached = cache(() => fetchPublicSettings());
// 同请求内去重:注册/找回密码页读取开放状态与提示文案
export const getSiteStateCached = cache(() => fetchSiteState());
+
+// 同请求内去重:帖子详情/用户主页渲染板块管理员徽章需板块名与配色
+export const getBoardsCached = cache(() => fetchBoards());