Files
UHD_Media_Tool/apps/web/app/components/sites/site-add-dialog.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

396 lines
16 KiB
TypeScript

import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
useCreateSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import { LoginResultView } from "./site-dialogs.tsx";
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康)
const CUSTOM = "__custom_rss";
type Step = 1 | 2 | 3;
const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
}
export function SiteAddDialog({ onClose }: { onClose: () => void }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const [step, setStep] = useState<Step>(1);
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
const [cred, setCred] = useState<CredentialDraft>(emptyCredential("cookie"));
const [errors, setErrors] = useState<Record<string, string>>({});
const [created, setCreated] = useState<SiteListItem | null>(null);
const [login, setLogin] = useState<SiteLoginResult | null>(null);
const [health, setHealth] = useState<SiteHealthResult | null>(null);
const [phase, setPhase] = useState<string | null>(null);
const create = useCreateSite();
const update = useUpdateSite();
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const custom = defKey === CUSTOM;
const kinds: LoginKind[] = custom ? [] : supportedKinds(def);
const busy = phase !== null;
const groups = useMemo(() => {
const needle = q.trim().toLowerCase();
const hit = (d: SiteDefinitionView) =>
!needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle);
return GROUP_ORDER.map((a) => ({
adapter: a,
items: items.filter((d) => d.adapter === a && hit(d)).sort((x, y) => x.name.localeCompare(y.name, "zh-CN")),
})).filter((g) => g.items.length);
}, [items, q]);
const pick = (key: string) => {
const d = items.find((x) => x.key === key);
setDefKey(key);
setForm(d ? { name: d.name, baseUrl: d.baseUrl, key: d.key } : { name: "", baseUrl: "", key: "" });
setCred(emptyCredential(defaultKind(d ? supportedKinds(d) : [])));
setErrors({});
setStep(2);
};
const validate = () => {
const fe: Record<string, string> = {};
if (!form.name.trim()) fe.name = "填写名称";
try {
const u = new URL(form.baseUrl.trim());
if (!/^https?:$/.test(u.protocol)) throw new Error();
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
let body = null;
if (kinds.length) {
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default");
if (b.errors) Object.assign(fe, b.errors);
body = b.body;
}
setErrors(fe);
return Object.keys(fe).length ? null : { body };
};
/** 保存站点(已建则按表单更新)→ 写凭证 → 刷新登录 → 检测健康 */
const test = async () => {
const v = validate();
if (!v) return;
setLogin(null);
setHealth(null);
try {
setPhase("保存站点…");
let site = created;
const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() };
if (!site) {
site = await create.mutateAsync({
key: custom ? form.key.trim() : (def?.key ?? form.key),
adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"),
snapshotEnabled: !custom,
...base,
});
setCreated(site);
} else if (site.name !== base.name || site.baseUrl !== base.baseUrl) {
site = await update.mutateAsync({ id: site.id, body: base });
setCreated(site);
}
setStep(3);
if (v.body) {
setPhase("保存凭证…");
const saved = await put.mutateAsync({ siteId: site.id, body: v.body });
setPhase("登录中…");
const r = await refresh.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
setLogin(r.result);
}
setPhase("检测健康…");
setHealth(await check.mutateAsync(site.id).catch(() => null));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const finish = () => {
onClose();
if (created) navigate(consolePath("sites", created.id));
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>添加站点</DialogTitle>
<DialogDescription asChild>
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
{STEP_LABEL.map((l, i) => (
<li
key={l}
className={cn(
"flex items-center gap-1.5",
step === i + 1 ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"grid h-5 w-5 place-items-center rounded-full border text-micro",
step > i + 1
? "border-foreground bg-foreground text-background"
: step === i + 1
? "border-foreground"
: "border-border",
)}
>
{step > i + 1 ? <Check className="h-3 w-3" /> : i + 1}
</span>
{l}
</li>
))}
</ol>
</DialogDescription>
</DialogHeader>
{step === 1 ? (
<div className="min-w-0">
<div className="relative mb-3">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索站点名或域名"
className="pl-9"
aria-label="搜索站点定义"
/>
</div>
<div className="max-h-[55vh] space-y-4 overflow-y-auto pr-1">
{defs.isPending ? (
<LoadingState rows={5} />
) : defs.error ? (
<ErrorState plain error={defs.error} onRetry={() => void defs.refetch()} />
) : (
<>
{groups.map((g) => (
<section key={g.adapter}>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{ADAPTER_LABEL[g.adapter]} · {g.items.length}
</h3>
<ul className="grid gap-1.5 sm:grid-cols-2">
{g.items.map((d) => {
const taken = !!d.siteId;
return (
<li key={d.key}>
<button
type="button"
disabled={taken}
onClick={() => pick(d.key)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">
{d.name}
</span>
<span className="block truncate text-meta text-muted-foreground">
{hostOf(d.baseUrl)}
</span>
</span>
{taken ? (
<span className="shrink-0 text-meta text-muted-foreground">已添加</span>
) : !d.shippedEnabled ? (
<StatusBadge tone="warning" className="shrink-0">
未实测
</StatusBadge>
) : null}
</button>
</li>
);
})}
</ul>
</section>
))}
{!groups.length ? (
<p className="py-6 text-center text-caption text-muted-foreground">没有匹配的站点定义</p>
) : null}
<section>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
未适配站点
</h3>
<button
type="button"
onClick={() => pick(CUSTOM)}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
自定义 RSS 站点:只抓 Feed,不登录
</button>
</section>
</>
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
<form
id="site-add"
noValidate
onSubmit={(e) => {
e.preventDefault();
void test();
}}
className="max-h-[60vh] min-w-0 overflow-y-auto pr-1"
>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor="add-name" required error={errors.name}>
<Input
id="add-name"
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="地址"
htmlFor="add-url"
required
error={errors.baseUrl}
hint={custom ? undefined : "站点换了域名时改这里"}
>
<Input
id="add-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
{custom ? (
<FormField label="标识" htmlFor="add-key" required error={errors.key} hint="添加后不能改">
<Input
id="add-key"
className="font-mono"
value={form.key}
aria-invalid={!!errors.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
</div>
{kinds.length ? (
<>
<div className="mt-1 mb-3 border-t border-border/60" />
<CredentialFields
value={cred}
onChange={setCred}
kinds={kinds}
errors={errors}
disabled={busy}
idPrefix="add"
/>
{kinds.length === 1 && kinds[0] === "api_key" ? (
<p className="text-meta text-muted-foreground">这个站点只支持 API Key 登录。</p>
) : null}
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的"快照与 Feed"里新建 Feed。
</p>
)}
</form>
) : (
<div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}>
<p className="text-caption text-muted-foreground">
{created ? `已添加「${created.name}」。` : ""}
{phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}
</p>
{phase ? <LoadingState rows={2} /> : null}
<LoginResultView
result={login}
health={health}
onUseCookie={
kinds.includes("cookie")
? () => {
setCred(emptyCredential("cookie"));
setStep(2);
}
: undefined
}
/>
</div>
)}
<DialogFooter className="gap-2">
{step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
<ArrowLeft />
重选站点
</Button>
<Button type="submit" form="site-add" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{kinds.length ? "保存并测试" : "保存并检测"}
</Button>
</>
) : step === 3 ? (
<>
<Button variant="outline" onClick={() => setStep(2)} disabled={busy}>
<ArrowLeft />
返回修改
</Button>
<Button onClick={finish} disabled={busy}>
完成
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
取消
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}