Files
UHD_Media_Tool/apps/web/app/components/sites/user-data-chart.tsx
T
rootandClaude Opus 5.5 f8362a3f4c feat(web): site center page, add-site wizard, credentials, health and user-data chart (FE-2)
- 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理,
  按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、
  等级 / VIP、做种、健康检测结果与时间、快照、限流
- 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、
  删除(确认框列出影响)
- 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一
  (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie
  并链接待处理
- 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 /
  快照与 Feed / 账号(凭证管理、Passkey、登录历史)
- 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数)
- 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:08:29 +08:00

200 lines
7.5 KiB
TypeScript

import type { SiteUserDataPoint } from "@umt/contracts";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Button } from "~/components/ui/button.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ratioText } from "./site-fields.ts";
// 用户数据 30 天折线。主色无彩(interface-materials §1):系列靠明暗 + 实线 / 虚线区分,不用色相;
// 字节与分享率量纲不同,分两张图,不做双轴。
const INK = "var(--foreground)";
const INK_2 = "var(--muted-foreground)";
const GRID = "var(--border)";
type Row = { day: string; upload: number; download: number; ratio: number | null };
const dayLabel = (d: string) => d.slice(5).replace("-", "/");
function Legend({ items }: { items: { label: string; dashed?: boolean; muted?: boolean }[] }) {
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{items.map((i) => (
<li key={i.label} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden className="shrink-0">
<line
x1="1"
y1="3"
x2="17"
y2="3"
stroke={i.muted ? INK_2 : INK}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={i.dashed ? "4 3" : undefined}
/>
</svg>
{i.label}
</li>
))}
</ul>
);
}
function TipBox({
active,
payload,
label,
kind,
}: {
active?: boolean;
payload?: { payload: Row }[];
label?: string;
kind: "bytes" | "ratio";
}) {
const row = payload?.[0]?.payload;
if (!active || !row) return null;
return (
<div className="pg-float rounded-lg px-3 py-2 text-meta">
<p className="mb-1 font-medium text-foreground">{label}</p>
{kind === "bytes" ? (
<>
<p className="text-muted-foreground">
上传 <span className="text-foreground tabular-nums">{formatBytes(row.upload)}</span>
</p>
<p className="text-muted-foreground">
下载 <span className="text-foreground tabular-nums">{formatBytes(row.download)}</span>
</p>
</>
) : (
<p className="text-muted-foreground">
分享率 <span className="text-foreground tabular-nums">{ratioText(row)}</span>
</p>
)}
</div>
);
}
const axisProps = {
stroke: GRID,
tick: { fill: "var(--muted-foreground)", fontSize: 11 },
tickLine: false,
axisLine: false,
} as const;
export function UserDataChart({ points }: { points: SiteUserDataPoint[] }) {
const [table, setTable] = useState(false);
const rows: Row[] = [...points]
.sort((a, b) => a.day.localeCompare(b.day))
.map((p) => ({ day: dayLabel(p.day), upload: p.upload, download: p.download, ratio: p.ratio }));
if (rows.length < 2)
return (
<p className="py-6 text-center text-caption text-muted-foreground">
{rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"}
</p>
);
return (
<div className="space-y-5">
<figure className="min-w-0">
<figcaption className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-caption font-medium text-foreground">上传 / 下载</span>
<Legend items={[{ label: "上传" }, { label: "下载", dashed: true, muted: true }]} />
</figcaption>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="0" vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} tickFormatter={(v: number) => formatBytes(v, 0)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="bytes" />}
/>
<Line
type="monotone"
dataKey="upload"
name="上传"
stroke={INK}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="download"
name="下载"
stroke={INK_2}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK_2 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<figure className="min-w-0">
<figcaption className="mb-2 text-caption font-medium text-foreground">分享率</figcaption>
<div className="h-32 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} domain={["auto", "auto"]} tickFormatter={(v: number) => v.toFixed(1)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="ratio" />}
/>
<Line
type="monotone"
dataKey="ratio"
name="分享率"
stroke={INK}
strokeWidth={2}
dot={false}
connectNulls
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<div>
<Button variant="ghost" size="xs" onClick={() => setTable((t) => !t)} aria-expanded={table}>
{table ? "收起数据表" : "查看数据表"}
</Button>
{table ? (
<div className="mt-2 max-h-64 overflow-auto rounded-lg border border-border/60">
<table className="w-full text-meta tabular-nums">
<thead className="sticky top-0 bg-background text-muted-foreground">
<tr>
<th className="px-3 py-1.5 text-left font-medium">日期</th>
<th className="px-3 py-1.5 text-right font-medium">上传</th>
<th className="px-3 py-1.5 text-right font-medium">下载</th>
<th className="px-3 py-1.5 text-right font-medium">分享率</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 text-foreground">
{[...rows].reverse().map((r) => (
<tr key={r.day}>
<td className="px-3 py-1.5">{r.day}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.upload)}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.download)}</td>
<td className="px-3 py-1.5 text-right">{ratioText(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
);
}