feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -7,10 +7,6 @@ import {
Heart,
MessageCircle,
Flame,
Layers,
User,
PenLine,
ArrowRight,
ChevronRight,
} from "lucide-react";
import {
@@ -18,19 +14,20 @@ import {
fetchComments,
fetchPosts,
fetchUserProfile,
fetchSidebar,
postDeleteTypeLabel,
type UserProfile,
type PostListItem,
type Board,
type CommentsResponse,
type SidebarPublicPayload,
} from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { postViewerCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
import PostToc from "@/components/PostToc";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
@@ -88,13 +85,6 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
}
}
function formatYMD(dateStr: string) {
const d = new Date(dateStr);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
d.getDate()
).padStart(2, "0")}`;
}
function formatDateTime(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
@@ -105,162 +95,6 @@ function formatDateTime(dateStr: string) {
});
}
/* ============================================================
左栏模块(仅 xl 三栏时展示)
============================================================ */
function BoardCard({ board }: { board: Board }) {
const colorIdx = boardColor(board);
return (
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
<p className="j13-module-title mb-3" id="board-card-title">
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
</p>
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
<span
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
>
<BoardIcon icon={board.icon} name={board.name} size={20} />
</span>
<span
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{board.name}
</span>
</Link>
{board.description && (
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{board.description}
</p>
)}
<Link
href={`/?board=${board.id}`}
className="btn btn-line btn-sm w-full justify-center mt-3.5"
>
查看该板讨论 <ArrowRight size={13} />
</Link>
</section>
);
}
function AuthorRailCard({
profile,
allowComments,
}: {
profile: UserProfile;
allowComments: boolean;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
const stats = [
{ label: "发帖", value: post_count },
...(allowComments ? [{ label: "评论", value: comment_count }] : []),
{ label: "积分", value: points },
];
return (
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
<p className="j13-module-title mb-3" id="author-card-title">
<User size={15} style={{ color: "var(--accent)" }} /> 作者
</p>
<div className="flex items-center gap-3">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
</Link>
<div className="min-w-0">
<Link
href={`/u/${u.id}`}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<p className="meta mt-0.5 truncate">@{u.username}</p>
{u.role !== "user" && (
<div className="mt-1">
<RoleBadge role={u.role} boardIds={u.board_ids} />
</div>
)}
</div>
</div>
{/* 有签名则展示;无则留空,不放占位文案 */}
{signature ? (
<p
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
style={{ color: "var(--ink-2)" }}
title={signature}
>
{signature}
</p>
) : null}
<div
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
style={{ borderColor: "var(--line)" }}
>
{stats.map((s, i) => (
<div
key={s.label}
className="px-1.5 py-2.5 text-center"
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
>
<p
className="text-[17px] font-semibold leading-none tabular-nums"
style={{ color: "var(--ink)" }}
>
{s.value}
</p>
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
{s.label}
</p>
</div>
))}
</div>
{streak > 0 && (
<p
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--gold)" }}
>
<Flame size={11.5} /> 连签 {streak} 天
</p>
)}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
访问主页
</Link>
</section>
);
}
function AuthorPosts({ posts }: { posts: PostListItem[] }) {
if (posts.length === 0) return null;
return (
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
</p>
<ul className="j13-rail-scroll">
{posts.map((p) => (
<li key={p.id}>
<Link
href={`/post/${p.id}`}
data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
>
<span
className="block text-[13px] leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{p.title}
</span>
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
</Link>
</li>
))}
</ul>
</section>
);
}
/* ============================================================
右栏模块(lg 起展示;移动端下沉到评论之后)
============================================================ */
@@ -466,11 +300,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
};
const meFirst = await getMeCached(cookie || undefined);
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
const [commentsData, profile, relatedData] = await Promise.all([
const [commentsData, profile, relatedData, sidebarData] = 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),
]);
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const me = meFirst;
const comments = commentsData.comments;
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
@@ -483,6 +320,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 侧栏布局:post 范围左右两列(toc 需在下方已就绪)
const sidebarLists = sidebarScopeLists(sidebar.config, "post");
const hasLeftRail = sidebarLists.left.length > 0;
const hasRightRail = sidebarLists.right.length > 0;
const sidebarProps: SidebarBlockProps = {
allowComments,
postBoard: post.board,
authorProfile: profile ?? undefined,
authorPosts,
toc,
};
// 三档网格退化:双栏 / 仅左 / 仅右 / 单栏
const railGridClass = !hasLeftRail && !hasRightRail
? ""
: hasLeftRail && hasRightRail
? "lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px]"
: hasLeftRail
? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
: "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const postType = normalizePostType(post.post_type);
@@ -602,19 +459,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
allowComments={allowComments}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
{/* 左栏:板块 / 作者 / 作者其他帖 */}
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div
className={`grid grid-cols-1 items-start gap-x-6 xl:gap-x-7 ${railGridClass}`}
>
{/* 左栏:按配置渲染(缺省 = 板块 / 作者 / 作者其他帖) */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
<aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航"
>
<BoardCard board={post.board} />
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
{profile && <AuthorPosts posts={authorPosts} />}
</aside>
{hasLeftRail && (
<aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航"
>
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
</aside>
)}
{/* 中栏:正文主体 */}
<div className="min-w-0">
@@ -845,14 +705,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</article>
</div>
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
<aside
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助"
>
{toc.length > 0 && <PostToc items={toc} />}
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
</aside>
{/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */}
{hasRightRail && (
<aside
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助"
>
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
</aside>
)}
</div>
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}