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