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

@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<Ana
return res.json();
}
export async function apiTelemetryPageView(): Promise<void> {
export async function apiTelemetryPageView(path: string, ref: string): Promise<void> {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
headers: clientHeaders(),
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ path, ref }),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
// ===== 访问统计(管理端,仅超管/站长) =====
export interface VisitDayPoint {
date: string;
pv: number;
uv: number;
}
export interface VisitNamedCount {
key: string;
label: string;
count: number;
}
export interface VisitPageRow {
path: string;
pv: number;
uv: number;
share: number;
}
export interface VisitOverview {
range_days: number;
trend: VisitDayPoint[];
today_pv: number;
today_uv: number;
yesterday_pv: number;
yesterday_uv: number;
logged_in_pv: number;
devices: VisitNamedCount[];
sources: VisitNamedCount[];
top_pages: VisitPageRow[];
}
export interface VisitMinutePoint {
minute: string;
pv: number;
}
export interface VisitEventItem {
id: number;
created_at: string;
kind: string;
path: string;
ref_host: string;
ref_kind: string;
user_id: number;
username: string;
ip: string;
device: string;
os: string;
browser: string;
bot: string;
status: number;
}
export interface VisitRealtime {
minutes: VisitMinutePoint[];
pv_5min: number;
pv_30min: number;
online: number;
top_pages: VisitNamedCount[];
recent: VisitEventItem[];
}
export interface VisitSourcesKindDay {
date: string;
items: VisitNamedCount[];
}
export interface VisitSources {
range_days: number;
kinds: VisitNamedCount[];
search_hosts: VisitNamedCount[];
external: VisitNamedCount[];
trend: VisitSourcesKindDay[];
}
export interface VisitPages {
range_days: number;
total: number;
items: VisitPageRow[];
}
export interface VisitVisitors {
range_days: number;
devices: VisitNamedCount[];
oses: VisitNamedCount[];
browsers: VisitNamedCount[];
logged_in_pv: number;
guest_pv: number;
new_visitors: number;
returning: number;
hourly: VisitNamedCount[];
}
export interface VisitSecurity {
range_days: number;
bots: VisitNamedCount[];
bot_trend: VisitDayPoint[];
probe_status: VisitNamedCount[];
probe_ips: VisitNamedCount[];
login_failed: number;
failed_users: number;
}
export interface VisitEventsQuery {
kind?: string;
bot?: string;
path?: string;
ip?: string;
start?: string;
end?: string;
page?: number;
size?: number;
}
export interface VisitEventsResult {
total: number;
items: VisitEventItem[];
}
export interface VisitUsage {
rows: number;
bytes: number;
}
export interface VisitSettingsPayload {
enabled: boolean;
retention_days: number;
usage: VisitUsage;
dropped: number;
}
// SSR 首拉:透传 cookie
async function fetchVisitStats<T>(path: string, cookieHeader?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("failed");
return res.json();
}
// 客户端拉取:带 CSRF + 自动续期
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
const headers = init?.body
? clientHeaders({ "Content-Type": "application/json" })
: clientHeaders();
const res = await fetchWithRefresh(path, {
method: init?.method ?? "GET",
...init,
headers,
});
if (!res.ok) throw new Error("failed");
return res.json();
}
export function fetchStatsOverview(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
}
export function apiStatsOverview(range = "7d") {
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
}
export function fetchStatsRealtime(cookieHeader?: string) {
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
}
export function apiStatsRealtime() {
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
}
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
}
export function apiStatsSources(range = "7d") {
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
}
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
return fetchVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
cookieHeader
);
}
export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) {
return apiVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
);
}
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
}
export function apiStatsVisitors(range = "7d") {
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
}
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
}
export function apiStatsSecurity(range = "7d") {
return apiVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`);
}
export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return fetchVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`, cookieHeader);
}
export function apiStatsEvents(query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return apiVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
}
export function fetchStatsSettings(cookieHeader?: string) {
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
}
export function apiStatsSettings() {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
}
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", {
method: "PUT",
body: JSON.stringify({ enabled, retention_days: retentionDays }),
});
}
export function apiStatsPurge(days: number) {
return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", {
method: "POST",
body: JSON.stringify({ days }),
});
}
export type AdminChatRoomType = "group" | "direct";
export interface AdminChatParticipant {
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise<Sponsor
if (!res.ok) throw new Error("加载赞助商配置失败");
return res.json();
}
// ===== 侧栏管理(布局配置 + 自定义 HTML 工具) =====
import type {
SidebarConfig,
SidebarPublicPayload,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
export type {
SidebarConfig,
SidebarScope,
SidebarScopeConfig,
SidebarBlockRef,
SidebarPublicPayload,
SidebarWidgetPublic,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
/** SSR 公开侧栏载荷;失败由调用方回落默认布局 */
export async function fetchSidebar(cookieHeader?: string): Promise<SidebarPublicPayload> {
const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取侧栏配置失败");
return res.json();
}
/** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */
export async function fetchAdminSidebar(
cookie?: string
): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> {
const res = await fetch(`${API_BASE}/api/admin/sidebar`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载侧栏配置失败");
return res.json();
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];
}> {
return apiOperations("/api/admin/sidebar");
}
export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> {
return apiOperations("/api/admin/sidebar/config", "PUT", { config });
}
export async function apiAdminCreateSidebarWidget(
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js">
): Promise<{ widget: SidebarWidgetRecord }> {
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
}
export async function apiAdminUpdateSidebarWidget(
id: number,
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body);
}
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
}