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