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

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef } from "react";
import type { SidebarWidgetPublic, User } from "@/lib/api";
import { scopeWidgetCss } from "@/lib/widgetCode";
import { ensureWidgetApi } from "@/lib/widgetApi";
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
export default function CustomWidgetBlock({
widget,
viewer,
}: {
widget: SidebarWidgetPublic;
viewer?: User | null;
}) {
const hostRef = useRef<HTMLDivElement>(null);
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
useEffect(() => {
ensureWidgetApi(viewer);
}, [viewer]);
useEffect(() => {
const host = hostRef.current;
if (!host || !widget.js) return;
const script = document.createElement("script");
script.dataset.widgetScript = String(widget.id);
script.textContent = widget.js;
host.appendChild(script);
return () => {
script.remove();
};
}, [widget.id, widget.js]);
return (
<section
className={`j13-widget-body j13-widget-${widget.id}`}
data-widget-id={widget.id}
data-widget-name={widget.name}
>
{widget.css ? (
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
) : null}
{widget.html ? (
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
) : null}
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
<div ref={hostRef} />
</section>
);
}

View File

@@ -0,0 +1,40 @@
import { Fragment } from "react";
import type { SidebarBlockRef, SidebarWidgetPublic } from "@/lib/sidebarMeta";
import { renderSidebarBlock, type SidebarBlockProps } from "./registry";
// 页面从本文件导入该类型(registry 中定义)
export type { SidebarBlockProps };
import CustomWidgetBlock from "./CustomWidgetBlock";
// 按管理端配置渲染一列侧栏块:
// - 内置块经注册表解析,未知 key / 数据缺失静默跳过;
// - 自定义工具引用已删除或停用(公开载荷不含)时静默跳过;
// - className 缺省时输出 Fragment,布局容器(gap/space-y、sticky、限高)由页面自行决定。
export default function SidebarRenderer({
blocks,
widgets,
props,
className,
}: {
blocks: SidebarBlockRef[];
widgets: SidebarWidgetPublic[];
props: SidebarBlockProps;
className?: string;
}) {
const items = blocks
.map((ref, i) => {
if (ref.widget) {
const widget = widgets.find((x) => x.id === ref.widget);
if (!widget) return null;
return <CustomWidgetBlock key={`w-${widget.id}-${i}`} widget={widget} viewer={props.user} />;
}
if (!ref.key) return null;
const node = renderSidebarBlock(ref.key, props);
if (node === null) return null;
return <Fragment key={`k-${ref.key}-${i}`}>{node}</Fragment>;
})
.filter((x) => x !== null);
if (items.length === 0) return null;
return className ? <div className={className}>{items}</div> : <>{items}</>;
}

View File

@@ -0,0 +1,155 @@
import type {
ActiveUser,
AnnouncementSummary,
Board,
BoardModerator,
BoardSidebarStats,
BoardWithCount,
CheckinStatus,
NewUser,
OverviewStats,
PostListItem,
PublicAdItem,
PublicSponsorItem,
SidebarPageItem,
User,
UserProfile,
} from "@/lib/api";
import PostToc, { type TocItem } from "@/components/PostToc";
import { WorkspaceNavPanel } from "@/components/home/WorkspaceNav";
import {
PulseCard,
Announcements,
AdsPanel,
SponsorsPanel,
HotRanking,
NewUsers,
ActiveUsers,
FriendLinks,
AboutSite,
} from "@/components/home/CommunityPanels";
import {
Moderators,
BoardIntro,
BoardPulse,
BoardHot,
BoardActive,
} from "@/components/home/BoardPanels";
import {
BoardCard as PostBoardCard,
AuthorRailCard as PostAuthorCard,
AuthorPosts as PostAuthorPosts,
} from "@/components/post/PostRailBlocks";
// 侧栏块 props 汇总:页面把已有数据整包传入,块按需取用;
// 某块所需数据缺失(如板块态没传 stats)时对应块渲染为空,安全跳过。
export interface SidebarBlockProps {
allowComments: boolean;
user?: User | null;
checkin?: CheckinStatus | null;
// home 范围
boards?: BoardWithCount[];
stats?: OverviewStats;
announcements?: AnnouncementSummary[];
canManageAnnouncements?: boolean;
ads?: PublicAdItem[];
adsPanelTitle?: string;
adsEnabled?: boolean;
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
hot?: PostListItem[];
newUsers?: NewUser[];
activeUsers?: ActiveUser[];
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
// board 范围
board?: BoardWithCount;
moderators?: BoardModerator[];
boardStats?: BoardSidebarStats;
boardHot?: PostListItem[];
boardActiveUsers?: ActiveUser[];
// post 范围
postBoard?: Board;
authorProfile?: UserProfile;
authorPosts?: PostListItem[];
toc?: TocItem[];
}
// 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。
export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
switch (key) {
case "board_nav":
return p.boards ? <WorkspaceNavPanel boards={p.boards} user={p.user} /> : null;
case "pulse":
return p.stats ? (
<PulseCard stats={p.stats} checkin={p.checkin} allowComments={p.allowComments} />
) : null;
case "announcements":
return p.announcements ? (
<Announcements items={p.announcements} canManageAnnouncements={p.canManageAnnouncements} />
) : null;
case "ads":
return p.ads && p.adsEnabled !== false ? (
<AdsPanel items={p.ads} title={p.adsPanelTitle || "自助推广"} enabled />
) : null;
case "sponsors":
return p.sponsors && p.sponsorsEnabled !== false ? (
<SponsorsPanel
items={p.sponsors}
title={p.sponsorsPanelTitle || "赞助商"}
enabled
/>
) : null;
case "hot":
return p.hot ? <HotRanking posts={p.hot} allowComments={p.allowComments} /> : null;
case "new_users":
return p.newUsers ? <NewUsers users={p.newUsers} /> : null;
case "active_users":
return p.activeUsers ? (
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
) : null;
case "friend_links":
return <FriendLinks />;
case "about_site":
return p.sidebarPages || p.canManagePages ? (
<AboutSite
pages={p.sidebarPages ?? []}
siteDescription={p.siteDescription}
canManagePages={p.canManagePages}
/>
) : null;
case "board_moderators":
return p.moderators ? <Moderators items={p.moderators} /> : null;
case "board_intro":
return p.board ? <BoardIntro board={p.board} /> : null;
case "board_pulse":
return p.boardStats ? (
<BoardPulse stats={p.boardStats} checkin={p.checkin} allowComments={p.allowComments} />
) : null;
case "board_hot":
return p.boardHot ? (
<BoardHot posts={p.boardHot} allowComments={p.allowComments} />
) : null;
case "board_active":
return p.boardActiveUsers ? (
<BoardActive users={p.boardActiveUsers} allowComments={p.allowComments} />
) : null;
case "post_board":
return p.postBoard ? <PostBoardCard board={p.postBoard} /> : null;
case "post_author":
return p.authorProfile ? (
<PostAuthorCard profile={p.authorProfile} allowComments={p.allowComments} />
) : null;
case "post_author_posts":
return p.authorPosts ? <PostAuthorPosts posts={p.authorPosts} /> : null;
case "post_toc":
return p.toc && p.toc.length > 0 ? <PostToc items={p.toc} /> : null;
default:
return null;
}
}