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

179
frontend/lib/sidebarMeta.ts Normal file
View File

@@ -0,0 +1,179 @@
// 侧栏管理:内置块元数据与默认布局(管理页与前台注册表共用,勿在此引入组件)
export type SidebarScope = "home" | "board" | "post";
export interface SidebarBlockRef {
key?: string;
widget?: number;
}
export interface SidebarScopeConfig {
left?: SidebarBlockRef[] | null;
right?: SidebarBlockRef[] | null;
}
export interface SidebarConfig {
version: number;
scopes: Partial<Record<SidebarScope, SidebarScopeConfig>>;
}
export interface SidebarWidgetPublic {
id: number;
name: string;
html: string;
css: string;
js: string;
}
export interface SidebarPublicPayload {
config: SidebarConfig;
widgets: SidebarWidgetPublic[];
}
export interface SidebarWidgetRecord {
id: number;
name: string;
html: string;
css: string;
js: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
// 每侧最多放置块数(与后端 SidebarMaxBlocksPerSide 一致)
export const SIDEBAR_MAX_BLOCKS_PER_SIDE = 24;
export const SIDEBAR_SCOPE_LABELS: Record<SidebarScope, string> = {
home: "首页",
board: "板块页",
post: "帖子详情",
};
export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
left: "左栏",
right: "右栏",
};
// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
export const SIDEBAR_WIDGET_META: Record<SidebarScope, Record<string, string>> = {
home: {
board_nav: "板块目录",
pulse: "站点数据",
announcements: "站点公告",
ads: "自助推广",
sponsors: "赞助商",
hot: "热门讨论",
new_users: "最新注册",
active_users: "活跃成员",
friend_links: "友情链接",
about_site: "关于本站",
},
board: {
board_moderators: "板块管理员",
board_intro: "板块概况",
board_pulse: "板块数据",
board_hot: "本板热门",
board_active: "活跃成员",
},
post: {
post_board: "所属板块",
post_author: "作者名片",
post_author_posts: "作者其他帖",
post_toc: "文章目录",
},
};
// 内置块 icon(字符串引用,渲染方 import lucide-react 时映射)
// 保持轻量:不在 sidebarMeta 里引入组件
export const SIDEBAR_BLOCK_ICON: Record<string, string> = {
board_nav: "layout-grid",
pulse: "bar-chart-3",
announcements: "megaphone",
ads: "circle-dollar-sign",
sponsors: "handshake",
hot: "flame",
new_users: "user-plus",
active_users: "users",
friend_links: "link2",
about_site: "info",
board_moderators: "shield-check",
board_intro: "file-text",
board_pulse: "bar-chart-3",
board_hot: "flame",
board_active: "users",
post_board: "layers",
post_author: "user",
post_author_posts: "pen-line",
post_toc: "list",
};
// 默认布局(= 历史硬编码渲染;后端 DefaultSidebarConfig 的前端镜像)
export function defaultSidebarConfig(): SidebarConfig {
const refs = (keys: string[]): SidebarBlockRef[] => keys.map((key) => ({ key }));
return {
version: 1,
scopes: {
home: {
left: refs(["board_nav"]),
right: refs([
"pulse",
"announcements",
"ads",
"sponsors",
"hot",
"new_users",
"active_users",
"friend_links",
"about_site",
]),
},
board: {
left: [],
right: refs(["board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"]),
},
post: {
left: refs(["post_board", "post_author", "post_author_posts"]),
right: refs(["post_toc"]),
},
},
};
}
// 按页面范围解析左右两列的块列表:
// - 未配置(nil)的侧回落默认布局,显式空数组 = 站长清空该侧;
// - board 范围左栏跟随 home.left(板块态仅管理右栏)。
export function sidebarScopeLists(
config: SidebarConfig,
scope: SidebarScope
): { left: SidebarBlockRef[]; right: SidebarBlockRef[] } {
const def = defaultSidebarConfig().scopes;
if (scope === "board") {
return {
left: config.scopes.home?.left ?? def.home?.left ?? [],
right: config.scopes.board?.right ?? def.board?.right ?? [],
};
}
return {
left: config.scopes[scope]?.left ?? def[scope]?.left ?? [],
right: config.scopes[scope]?.right ?? def[scope]?.right ?? [],
};
}
// 块的展示名:内置取 META,自定义取备注名;未知返回 null(渲染时跳过)
export function sidebarBlockLabel(
ref: SidebarBlockRef,
widgets: { id: number; name: string }[]
): { label: string; custom: boolean } | null {
if (ref.widget) {
const w = widgets.find((x) => x.id === ref.widget);
return w ? { label: w.name, custom: true } : null;
}
if (ref.key) {
for (const scope of Object.keys(SIDEBAR_WIDGET_META) as SidebarScope[]) {
const label = SIDEBAR_WIDGET_META[scope][ref.key];
if (label) return { label, custom: false };
}
}
return null;
}