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
@@ -1,171 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Site, SiteCredentialsPut } from "@umt/contracts";
|
||||
import { KeyRound, LoaderCircle, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { usePutSiteCredentials } from "~/api/instances.ts";
|
||||
import { siteKeys, useSiteAccounts } from "~/api/sites.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { ACCOUNT_STATUS, relTime } from "./release-fields.ts";
|
||||
|
||||
const AUTH_KINDS = [
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "passkey", label: "Passkey" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "password", label: "账号密码" },
|
||||
] as const;
|
||||
type AuthKind = (typeof AUTH_KINDS)[number]["value"];
|
||||
|
||||
/** 站点详情"账号"页签:已录入账号 + 按标签新建或替换凭证(凭证只写,不回显) */
|
||||
export function SiteAccountsPanel({ site }: { site: Site }) {
|
||||
const accounts = useSiteAccounts(site.id);
|
||||
const put = usePutSiteCredentials();
|
||||
const qc = useQueryClient();
|
||||
const [v, setV] = useState({ label: "default", authKind: "cookie" as AuthKind, username: "", credential: "" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = SiteCredentialsPut.safeParse({
|
||||
label: v.label.trim() || "default",
|
||||
authKind: v.authKind,
|
||||
credential: v.credential.trim(),
|
||||
username: v.authKind === "password" ? v.username.trim() || undefined : undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.credential) fe.credential = "填写凭证";
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await put.mutateAsync({ siteId: site.id, body: parsed.data });
|
||||
toast.success(r.created ? "已添加站点账号" : "已替换凭证");
|
||||
setV((s) => ({ ...s, credential: "" }));
|
||||
void qc.invalidateQueries({ queryKey: siteKeys.all });
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = put.isPending;
|
||||
return (
|
||||
<div className="grid gap-6 pt-2 lg:grid-cols-2">
|
||||
<DetailSection title="站点账号">
|
||||
<DataTable
|
||||
rows={accounts.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={accounts.isPending}
|
||||
error={accounts.error}
|
||||
onRetry={() => void accounts.refetch()}
|
||||
empty={{ icon: UserRound, title: "还没有账号", description: "保存凭证后出现在这里。" }}
|
||||
columns={[
|
||||
{
|
||||
key: "label",
|
||||
header: "标签",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{r.label}</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<StatusBadge tone={ACCOUNT_STATUS[r.status].tone}>{ACCOUNT_STATUS[r.status].label}</StatusBadge>
|
||||
{!r.enabled ? <StatusBadge>已停用</StatusBadge> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "verified",
|
||||
header: "上次验证",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="text-muted-foreground">{relTime(r.lastVerifiedAt)}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
|
||||
>
|
||||
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
|
||||
同一标签再次保存会替换凭证。凭证加密保存,不回显。
|
||||
</p>
|
||||
<FormField label="标签" htmlFor="acct-label" error={errors.label} hint="同一站点多个账号时区分">
|
||||
<Input
|
||||
id="acct-label"
|
||||
value={v.label}
|
||||
aria-invalid={!!errors.label}
|
||||
onChange={(e) => setV((s) => ({ ...s, label: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="凭证类型" htmlFor="acct-kind">
|
||||
<Select value={v.authKind} onValueChange={(x) => setV((s) => ({ ...s, authKind: x as AuthKind }))}>
|
||||
<SelectTrigger id="acct-kind" className="w-full" disabled={busy}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{AUTH_KINDS.map((k) => (
|
||||
<SelectItem key={k.value} value={k.value}>
|
||||
{k.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
{v.authKind === "password" ? (
|
||||
<FormField label="登录名" htmlFor="acct-user" error={errors.username} className="sm:col-span-2">
|
||||
<Input
|
||||
id="acct-user"
|
||||
value={v.username}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setV((s) => ({ ...s, username: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label={v.authKind === "password" ? "密码" : "凭证"}
|
||||
htmlFor="acct-cred"
|
||||
required
|
||||
error={errors.credential}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
id="acct-cred"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={v.credential}
|
||||
aria-invalid={!!errors.credential}
|
||||
placeholder={v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined}
|
||||
onChange={(e) => setV((s) => ({ ...s, credential: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="sm:col-span-2">
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{busy ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
|
||||
保存凭证
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { SiteAdapter, SiteCreate } from "@umt/contracts";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateSite, useSiteDefinitions, useSites } from "~/api/sites.ts";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
|
||||
nexusphp: "NexusPHP",
|
||||
mteam_api: "M-Team API",
|
||||
api: "站点 API",
|
||||
generic_rss: "通用 RSS",
|
||||
};
|
||||
|
||||
const CUSTOM = "__custom";
|
||||
type Draft = { key: string; name: string; adapter: SiteAdapter; baseUrl: string; snapshotEnabled: boolean };
|
||||
const EMPTY: Draft = { key: "", name: "", adapter: "nexusphp", baseUrl: "", snapshotEnabled: true };
|
||||
|
||||
/** 登记站点:从内置站点定义选,或自定义。登记后去站点详情的"账号"页签填凭证 */
|
||||
export function SiteRegisterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const defs = useSiteDefinitions();
|
||||
const sites = useSites();
|
||||
const create = useCreateSite();
|
||||
const navigate = useNavigate();
|
||||
const [preset, setPreset] = useState(CUSTOM);
|
||||
const [d, setD] = useState<Draft>(EMPTY);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const taken = new Set((sites.data?.items ?? []).map((s) => s.key));
|
||||
const available = (defs.data?.items ?? []).filter((x) => !taken.has(x.key));
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((s) => ({ ...s, [k]: v }));
|
||||
|
||||
const pick = (key: string) => {
|
||||
setPreset(key);
|
||||
const def = available.find((x) => x.key === key);
|
||||
setD(
|
||||
def ? { key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl, snapshotEnabled: true } : EMPTY,
|
||||
);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = SiteCreate.safeParse({ ...d, key: d.key.trim(), name: d.name.trim(), baseUrl: d.baseUrl.trim() });
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.key) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
|
||||
if (fe.baseUrl) fe.baseUrl = "填完整地址,如 https://example.org";
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
if (taken.has(parsed.data.key)) {
|
||||
setErrors({ key: "这个标识已被占用" });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const site = await create.mutateAsync(parsed.data);
|
||||
toast.success(`已登记 ${site.name}`);
|
||||
onOpenChange(false);
|
||||
navigate(`${consolePath("sites", site.id)}?tab=accounts`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "登记失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = create.isPending;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>登记站点</DialogTitle>
|
||||
<DialogDescription>登记后在站点的"账号"页签填写凭证。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="site-register" onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
<FormField label="站点定义" htmlFor="site-preset" className="sm:col-span-2">
|
||||
<Select value={preset} onValueChange={pick} disabled={busy}>
|
||||
<SelectTrigger id="site-preset" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={CUSTOM}>自定义</SelectItem>
|
||||
{available.map((x) => (
|
||||
<SelectItem key={x.key} value={x.key}>
|
||||
{x.name}
|
||||
{x.shippedEnabled ? "" : "(未实测)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="名称" htmlFor="site-name" required error={errors.name}>
|
||||
<Input
|
||||
id="site-name"
|
||||
value={d.name}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="标识" htmlFor="site-key" required error={errors.key} hint="登记后不能改">
|
||||
<Input
|
||||
id="site-key"
|
||||
value={d.key}
|
||||
aria-invalid={!!errors.key}
|
||||
onChange={(e) => set("key", e.target.value)}
|
||||
disabled={busy || preset !== CUSTOM}
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor="site-adapter">
|
||||
<Select value={d.adapter} onValueChange={(v) => set("adapter", v as SiteAdapter)} disabled={busy}>
|
||||
<SelectTrigger id="site-adapter" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{SiteAdapter.options.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{ADAPTER_LABEL[a]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="地址" htmlFor="site-url" required error={errors.baseUrl}>
|
||||
<Input
|
||||
id="site-url"
|
||||
inputMode="url"
|
||||
value={d.baseUrl}
|
||||
aria-invalid={!!errors.baseUrl}
|
||||
onChange={(e) => set("baseUrl", e.target.value)}
|
||||
placeholder="https://"
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center gap-2 pb-4 sm:col-span-2">
|
||||
<Switch
|
||||
id="site-snapshot"
|
||||
checked={d.snapshotEnabled}
|
||||
onCheckedChange={(v) => set("snapshotEnabled", v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<label htmlFor="site-snapshot" className="text-caption text-foreground">
|
||||
定时抓取快照
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="site-register" disabled={busy}>
|
||||
{busy ? <LoaderCircle className="animate-spin" /> : null}
|
||||
登记
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user