Files
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

167 lines
3.8 KiB
TypeScript

"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>
);
}