feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
166
frontend/app/admin/analytics/ui.tsx
Normal file
166
frontend/app/admin/analytics/ui.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
|
||||
/** 事件类型中文标签 */
|
||||
export function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "pageview":
|
||||
return "浏览";
|
||||
case "bot":
|
||||
return "爬虫";
|
||||
case "probe":
|
||||
return "探测";
|
||||
default:
|
||||
return kind || "-";
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源类别中文标签 */
|
||||
export function refKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "direct":
|
||||
return "直接访问";
|
||||
case "internal":
|
||||
return "站内";
|
||||
case "search":
|
||||
return "搜索引擎";
|
||||
case "social":
|
||||
return "社交";
|
||||
case "external":
|
||||
return "外部链接";
|
||||
default:
|
||||
return kind || "-";
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceLabel(d: string): string {
|
||||
switch (d) {
|
||||
case "computer":
|
||||
return "电脑";
|
||||
case "mobile":
|
||||
return "手机";
|
||||
case "tablet":
|
||||
return "平板";
|
||||
default:
|
||||
return d || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatInt(n: number): string {
|
||||
return new Intl.NumberFormat("zh-CN").format(n);
|
||||
}
|
||||
|
||||
/** 简单加载/错误占位(居中卡片内文本) */
|
||||
export function StatsState({ loading, error }: { loading?: boolean; error?: string }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
正在加载…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--danger)" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TableEmpty({ text = "暂无数据" }: { text?: string }) {
|
||||
return (
|
||||
<p className="py-8 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 15 秒轮询 + 回前台即拉;返回手动 reload */
|
||||
export function usePolling(load: () => void, intervalMs = 15_000) {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, intervalMs);
|
||||
const off = onDocumentVisible(load);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
off();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intervalMs]);
|
||||
return () => setTick((t) => t + 1);
|
||||
}
|
||||
|
||||
/** 7/30 日区间切换段控件(各 Tab 复用) */
|
||||
export function RangeSegmented({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "7d" | "30d";
|
||||
onChange: (v: "7d" | "30d") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" role="tablist" aria-label="统计区间">
|
||||
{(
|
||||
[
|
||||
{ key: "7d", label: "近 7 日" },
|
||||
{ key: "30d", label: "近 30 日" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === o.key}
|
||||
onClick={() => value !== o.key && onChange(o.key)}
|
||||
className="admin-seg"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardShell({
|
||||
title,
|
||||
hint,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-4 sm:p-5">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{title}
|
||||
</h3>
|
||||
{hint ? <p className="meta text-[11.5px] mt-0.5">{hint}</p> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user