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,213 @@
|
||||
import type { SiteCredentialsBody } from "@umt/contracts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
|
||||
|
||||
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
|
||||
|
||||
export type CredentialDraft = {
|
||||
kind: LoginKind;
|
||||
username: string;
|
||||
password: string;
|
||||
totpSecret: string;
|
||||
saveCookieOnly: boolean;
|
||||
cookie: string;
|
||||
apiKey: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
|
||||
kind,
|
||||
username: "",
|
||||
password: "",
|
||||
totpSecret: "",
|
||||
saveCookieOnly: false,
|
||||
cookie: "",
|
||||
apiKey: "",
|
||||
userAgent: "",
|
||||
});
|
||||
|
||||
/** 草稿 → 请求体;缺必填返回字段错误 */
|
||||
export function credentialBody(
|
||||
d: CredentialDraft,
|
||||
label: string,
|
||||
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
|
||||
const ua = d.userAgent.trim() || undefined;
|
||||
if (d.kind === "password") {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!d.username.trim()) errors.username = "填写登录名";
|
||||
if (!d.password) errors.password = "填写密码";
|
||||
if (Object.keys(errors).length) return { body: null, errors };
|
||||
return {
|
||||
body: {
|
||||
kind: "password",
|
||||
label,
|
||||
username: d.username.trim(),
|
||||
password: d.password,
|
||||
totpSecret: d.totpSecret.trim() || undefined,
|
||||
saveCookieOnly: d.saveCookieOnly || undefined,
|
||||
userAgent: ua,
|
||||
},
|
||||
errors: null,
|
||||
};
|
||||
}
|
||||
if (d.kind === "cookie") {
|
||||
if (!d.cookie.trim()) return { body: null, errors: { cookie: "粘贴 Cookie" } };
|
||||
return { body: { kind: "cookie", label, cookie: d.cookie.trim(), userAgent: ua }, errors: null };
|
||||
}
|
||||
if (!d.apiKey.trim()) return { body: null, errors: { apiKey: "填写 API Key" } };
|
||||
return { body: { kind: "api_key", label, apiKey: d.apiKey.trim() }, errors: null };
|
||||
}
|
||||
|
||||
export function CredentialFields({
|
||||
value,
|
||||
onChange,
|
||||
kinds,
|
||||
errors,
|
||||
disabled,
|
||||
idPrefix = "cred",
|
||||
}: {
|
||||
value: CredentialDraft;
|
||||
onChange: (v: CredentialDraft) => void;
|
||||
kinds: LoginKind[];
|
||||
errors: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
|
||||
const id = (s: string) => `${idPrefix}-${s}`;
|
||||
return (
|
||||
<div className="grid gap-x-4 gap-y-1">
|
||||
{kinds.length > 1 ? (
|
||||
<div className="mb-4" role="radiogroup" aria-label="凭证类型">
|
||||
<p className="mb-1.5 text-caption font-medium text-foreground">凭证类型</p>
|
||||
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
|
||||
{kinds.map((k) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value.kind === k}
|
||||
disabled={disabled}
|
||||
onClick={() => set("kind", k)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-caption transition-colors",
|
||||
value.kind === k
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{AUTH_KIND_LABEL[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.kind === "password" ? (
|
||||
<>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<FormField label="登录名" htmlFor={id("user")} required error={errors.username}>
|
||||
<Input
|
||||
id={id("user")}
|
||||
autoComplete="off"
|
||||
value={value.username}
|
||||
aria-invalid={!!errors.username}
|
||||
onChange={(e) => set("username", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="密码" htmlFor={id("pass")} required error={errors.password}>
|
||||
<Input
|
||||
id={id("pass")}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={value.password}
|
||||
aria-invalid={!!errors.password}
|
||||
onChange={(e) => set("password", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="TOTP 密钥"
|
||||
htmlFor={id("totp")}
|
||||
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
|
||||
>
|
||||
<Input
|
||||
id={id("totp")}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={value.totpSecret}
|
||||
onChange={(e) => set("totpSecret", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<Switch
|
||||
id={id("cookie-only")}
|
||||
checked={value.saveCookieOnly}
|
||||
onCheckedChange={(v) => set("saveCookieOnly", v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label htmlFor={id("cookie-only")} className="text-caption">
|
||||
<span className="block font-medium text-foreground">只换 Cookie</span>
|
||||
<span className="block text-muted-foreground">
|
||||
密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : value.kind === "cookie" ? (
|
||||
<FormField
|
||||
label="Cookie"
|
||||
htmlFor={id("cookie")}
|
||||
required
|
||||
error={errors.cookie}
|
||||
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
|
||||
>
|
||||
<Textarea
|
||||
id={id("cookie")}
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
value={value.cookie}
|
||||
aria-invalid={!!errors.cookie}
|
||||
onChange={(e) => set("cookie", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label="API Key" htmlFor={id("apikey")} required error={errors.apiKey} hint="在站点控制台生成">
|
||||
<Input
|
||||
id={id("apikey")}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={value.apiKey}
|
||||
aria-invalid={!!errors.apiKey}
|
||||
onChange={(e) => set("apiKey", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{value.kind !== "api_key" ? (
|
||||
<FormField
|
||||
label="User-Agent"
|
||||
htmlFor={id("ua")}
|
||||
hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"}
|
||||
>
|
||||
<Input
|
||||
id={id("ua")}
|
||||
value={value.userAgent}
|
||||
onChange={(e) => set("userAgent", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user