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]>
This commit is contained in:
1 parent
81bf010e3e
commit
f8362a3f4c
21 files changed
+3083
-1126
No files matched your search
@@ -0,0 +1,199 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user