feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal file
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal file
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal 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}</>;
|
||||
}
|
||||
155
frontend/components/sidebar/registry.tsx
Normal file
155
frontend/components/sidebar/registry.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user