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

91
frontend/lib/widgetApi.ts Normal file
View File

@@ -0,0 +1,91 @@
// 自定义侧栏工具的站点开放接口:
// - CSS:工具内直接使用站点主题变量(var(--accent) 等),随明暗主题与
// 后台自定义主题色自动变化(CSS 变量沿 DOM 继承,无需任何 JS);
// - JS:window.J13 提供当前用户、主题读取/监听、站点概览懒加载。
// 本模块只在浏览器端(CustomWidgetBlock 的 effect 内)执行。
import type { OverviewResponse, User } from "@/lib/api";
export interface WidgetViewer {
id: number;
username: string;
nickname: string;
role: string;
avatar: string | null;
}
export interface WidgetApi {
version: 1;
/** 当前登录用户(未登录为 null);每次工具挂载时刷新 */
user: WidgetViewer | null;
theme: {
get: () => "light" | "dark";
/** 监听明暗切换,返回取消监听函数 */
onChange: (cb: (theme: "light" | "dark") => void) => () => void;
};
/** 站点公开概览统计(结果模块级缓存,多个工具共享一次请求) */
getOverview: () => Promise<OverviewResponse | null>;
}
declare global {
interface Window {
J13?: WidgetApi;
}
}
function toViewer(user: User | null | undefined): WidgetViewer | null {
if (!user) return null;
return {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
avatar: user.avatar || null,
};
}
// 概览请求全站只发一次(无论多少个工具、多少次挂载)
let overviewPromise: Promise<OverviewResponse | null> | null = null;
function loadOverview(): Promise<OverviewResponse | null> {
if (!overviewPromise) {
overviewPromise = fetch("/api/overview", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.catch(() => null);
}
return overviewPromise;
}
/**
* 幂等初始化 window.J13 并刷新当前用户信息。
* 必须在注入站长 JS 之前调用;多个工具共享同一个实例。
*/
export function ensureWidgetApi(viewer: User | null | undefined): WidgetApi {
const root = document.documentElement;
const readTheme = (): "light" | "dark" =>
root.classList.contains("dark") ? "dark" : "light";
if (!window.J13) {
const listeners = new Set<(theme: "light" | "dark") => void>();
// 主题切换 = html 元素 .dark 类的增删,观察其变化广播给工具
const observer = new MutationObserver(() => {
const theme = readTheme();
listeners.forEach((cb) => cb(theme));
});
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
window.J13 = {
version: 1,
user: null,
theme: {
get: readTheme,
onChange(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
},
getOverview: loadOverview,
};
}
window.J13.user = toViewer(viewer);
return window.J13;
}