From f8362a3f4c21697bbd946b067a769be3670d033d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:08:29 +0800 Subject: [PATCH] feat(web): site center page, add-site wizard, credentials, health and user-data chart (FE-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理, 按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、 等级 / VIP、做种、健康检测结果与时间、快照、限流 - 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、 删除(确认框列出影响) - 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一 (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie 并链接待处理 - 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 / 快照与 Feed / 账号(凭证管理、Passkey、登录历史) - 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数) - 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/sites.ts | 120 ++- apps/web/app/components/console/nav.test.ts | 2 +- apps/web/app/components/console/nav.ts | 4 +- apps/web/app/components/settings/sites.tsx | 697 +++--------------- .../components/sites/credential-fields.tsx | 213 ++++++ .../components/sites/site-accounts-tab.tsx | 264 +++++++ .../app/components/sites/site-add-dialog.tsx | 395 ++++++++++ apps/web/app/components/sites/site-card.tsx | 455 ++++++++++++ .../web/app/components/sites/site-dialogs.tsx | 534 ++++++++++++++ apps/web/app/components/sites/site-fields.ts | 208 ++++++ .../app/components/sites/site-overview.tsx | 115 +++ .../app/components/sites/user-data-chart.tsx | 199 +++++ .../components/workbench/site-accounts.tsx | 171 ----- .../workbench/site-register-dialog.tsx | 174 ----- apps/web/app/routes/console/downloads.tsx | 2 +- apps/web/app/routes/console/sites.$siteId.tsx | 146 +++- apps/web/app/routes/console/sites.tsx | 293 +++++--- apps/web/app/routes/console/sources.tsx | 4 +- apps/web/package.json | 1 + apps/web/scripts/smoke.mjs | 5 + deno.lock | 207 ++++++ 21 files changed, 3083 insertions(+), 1126 deletions(-) create mode 100644 apps/web/app/components/sites/credential-fields.tsx create mode 100644 apps/web/app/components/sites/site-accounts-tab.tsx create mode 100644 apps/web/app/components/sites/site-add-dialog.tsx create mode 100644 apps/web/app/components/sites/site-card.tsx create mode 100644 apps/web/app/components/sites/site-dialogs.tsx create mode 100644 apps/web/app/components/sites/site-fields.ts create mode 100644 apps/web/app/components/sites/site-overview.tsx create mode 100644 apps/web/app/components/sites/user-data-chart.tsx delete mode 100644 apps/web/app/components/workbench/site-accounts.tsx delete mode 100644 apps/web/app/components/workbench/site-register-dialog.tsx diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 83f179ef..4a6db6ea 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -1,12 +1,29 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { cursorPage, Id, Site, SiteAccount, type SiteCreate, SiteDefinitionView, SiteSnapshot } from "@umt/contracts"; +import { + AuditEvent, + cursorPage, + Id, + Site, + SiteAccount, + type SiteCreate, + type SiteCredentialsBody, + SiteCredentialsResult, + SiteDefinitionView, + SiteHealthResult, + SiteListItem, + SiteRefreshLoginAccepted, + SiteSnapshot, + type SiteUpdate, + SiteUserDataPoint, +} from "@umt/contracts"; import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; import { apiMutate, apiParsed } from "./parse.ts"; // 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s)) +// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除 -const SitePage = cursorPage(Site); +const SitePage = cursorPage(SiteListItem); const AccountPage = cursorPage(SiteAccount); const SnapshotPage = cursorPage(SiteSnapshot); @@ -17,12 +34,17 @@ export const siteKeys = { accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, definitions: () => ["sites", "definitions"] as const, + userData: (siteId: string, accountId?: string, days = 30) => + ["sites", "user-data", siteId, accountId ?? "first", days] as const, + loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const, }; +/** 站点列表(附账号与整体账号状态,站点页一次请求) */ export function useSites() { return useQuery({ queryKey: siteKeys.list(), - queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200 }, signal }), + queryFn: ({ signal }) => + apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }), }); } @@ -108,3 +130,95 @@ export function useCreateSite() { onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } + +/** 修改站点(名称、域名、限流、快照、代理) */ +export function useUpdateSite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) => + apiMutate("/api/v1/sites/:id", { method: "PATCH", pathParams: { id }, body }).then((d) => Site.parse(d)), + onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 删除站点:资源标 site_disabled 保留,账号与凭证删除 */ +export function useDeleteSite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiMutate("/api/v1/sites/:id", { method: "DELETE", pathParams: { id } }), + onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 写凭证(按 kind 判别);只写不回显,账号回到未验证 */ +export function usePutCredentials() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsBody }) => + apiMutate("/api/v1/sites/:id/credentials", { method: "PUT", pathParams: { id: siteId }, body }).then((d) => + SiteCredentialsResult.parse(d), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 删除站点账号及其凭证 */ +export function useDeleteSiteAccount() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiMutate("/api/v1/site-accounts/:id", { method: "DELETE", pathParams: { id } }), + onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 检测健康(同步,≤20 s) */ +export function useHealthCheck() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate("/api/v1/sites/:id/health-check", { pathParams: { id }, body: {} }).then((d) => + SiteHealthResult.parse(d), + ), + onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 刷新登录;界面上用 wait=true 同步拿结果(≤30 s) */ +export function useRefreshLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, accountId }: { id: string; accountId?: string }) => + apiMutate("/api/v1/sites/:id/refresh-login", { + pathParams: { id }, + body: { wait: true, ...(accountId ? { accountId } : {}) }, + }).then((d) => SiteRefreshLoginAccepted.parse(d)), + onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 用户数据按天历史 */ +export function useSiteUserData(siteId: string | undefined, accountId?: string, days = 30) { + return useQuery({ + queryKey: siteKeys.userData(siteId ?? "", accountId, days), + enabled: !!siteId, + queryFn: ({ signal }) => + apiParsed(z.object({ items: z.array(SiteUserDataPoint) }), "/api/v1/sites/:id/user-data", { + pathParams: { id: siteId ?? "" }, + params: { days, accountId }, + signal, + }), + }); +} + +/** 登录历史:审计里该账号的凭证与登录事件 */ +export function useLoginHistory(accountId: string | undefined) { + return useQuery({ + queryKey: siteKeys.loginHistory(accountId ?? ""), + enabled: !!accountId, + queryFn: ({ signal }) => + apiParsed(cursorPage(AuditEvent), "/api/v1/audit-events", { + params: { targetType: "site_account", targetId: accountId, limit: 20 }, + signal, + }), + }); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 0b3bc848..d75cafa5 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -38,7 +38,7 @@ describe("控制台导航", () => { expect(findNavItem("/console/downloads/plans")?.label).toBe("下载计划"); expect(findNavItem("/console/downloads/abc")?.label).toBe("下载队列"); expect(findNavItem("/console/tasks/abc")?.label).toBe("业务任务"); - expect(findNavItem("/console/sites/abc")?.label).toBe("站点工作台"); + expect(findNavItem("/console/sites/abc")?.label).toBe("站点"); expect(findNavItem("/nope")).toBeUndefined(); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index f98c0329..6133eca0 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -68,9 +68,9 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { to: "/console/sites", - label: "站点工作台", + label: "站点", icon: Globe, - description: "站点、站点账号、快照与 Feed", + description: "站点、凭证、账号数据与健康;资源、快照与 Feed", }, { to: "/console/sources", diff --git a/apps/web/app/components/settings/sites.tsx b/apps/web/app/components/settings/sites.tsx index 1e9d5ae1..20cd3bc4 100644 --- a/apps/web/app/components/settings/sites.tsx +++ b/apps/web/app/components/settings/sites.tsx @@ -1,625 +1,96 @@ -import { type Site, type SiteAccount, SiteCredentialsPut, SiteUpdate } from "@umt/contracts"; -import { KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { ArrowRight } from "lucide-react"; import { Link } from "react-router"; -import { toast } from "sonner"; -import { - useCreateSite, - usePutSiteCredentials, - useRemoveSiteAccount, - useSiteAccounts, - useSiteOptions, - useUpdateSite, - useUpdateSiteAccount, -} from "~/api/instances.ts"; -import { snapshotStale, useSiteDefinitions, useToggleSite } from "~/api/sites.ts"; -import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; -import { DataTable } from "~/components/console/data-table.tsx"; -import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; -import { zodFieldErrors } 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 { Switch } from "~/components/ui/switch.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { formatRelative, healthLabel } from "~/lib/labels.ts"; -import { FieldControl, type FormValues, num, str } from "./resource-section.tsx"; -import { SettingsBlock } from "./settings-block.tsx"; +import { num, str } from "./resource-section.tsx"; +import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; -// 站点与账号:站点登记 / 启停 / 快照周期 / 限流(抽屉),站点账号凭证录入与启停。 -// 站点浏览、快照记录在站点工作台。 +// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理。 +// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。 -const AUTH_KINDS = [ - { value: "cookie", label: "Cookie" }, - { value: "passkey", label: "Passkey" }, - { value: "api_key", label: "API Key" }, - { value: "password", label: "账号密码" }, -]; -const authLabel = (k: string) => AUTH_KINDS.find((a) => a.value === k)?.label ?? k; - -const ACCOUNT_STATUS: Record = { - active: { label: "有效", tone: "success" }, - invalid: { label: "凭证无效", tone: "destructive" }, - expired: { label: "已过期", tone: "warning" }, - unknown: { label: "未验证", tone: "neutral" }, -}; - -const ADAPTER: Record = { - nexusphp: "NexusPHP", - mteam_api: "M-Team API", - api: "站点 API", - generic_rss: "通用 RSS", -}; - -// ─── 站点列表 ─── - -function SitesBlock() { - const sites = useSiteOptions(); - const accounts = useSiteAccounts(); - const toggle = useToggleSite(); - const [open, setOpen] = useState(null); - const [registering, setRegistering] = useState(false); - const accountCount = (id: string) => accounts.data?.items.filter((a) => a.siteId === id).length ?? 0; - - return ( - setRegistering(true)}> - - 登记站点 - - } - > - r.id} - loading={sites.isPending} - error={sites.error} - onRetry={() => sites.refetch()} - onRowClick={(r) => setOpen(r)} - empty={{ title: "还没有登记站点", description: "从内置站点定义里登记。" }} - columns={[ - { - key: "name", - header: "站点", - cell: (r) => ( -
-

{r.name}

-

{ADAPTER[r.adapter] ?? r.adapter}

-
- ), - }, - { - key: "snapshot", - header: "快照", - hideBelow: "md", - cell: (r) => - !r.snapshotEnabled ? ( - 关闭 - ) : ( -
-

每 {Math.round((r.snapshotIntervalSec ?? 3600) / 60)} 分钟

-

- {r.lastSnapshotAt ? formatRelative(r.lastSnapshotAt) : "从未快照"} -

-
- ), - }, - { key: "accounts", header: "账号", hideBelow: "sm", cell: (r) => accountCount(r.id) }, - { - key: "health", - header: "健康", - hideBelow: "sm", - cell: (r) => { - const h = healthLabel(r.circuitState === "open" ? "circuit_open" : r.healthStatus); - return {h.label}; - }, - }, - { - key: "enabled", - header: "启用", - align: "right", - cell: (r) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,开关不触发整行打开抽屉 - e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> - { - try { - await toggle.mutateAsync({ id: r.id, enabled: v }); - toast.success(v ? `${r.name}已启用` : `${r.name}已停用`); - } catch (err) { - toast.error(errorMessage(err, "切换失败")); - } - }} - /> - - ), - }, - ]} - /> - setOpen(null)} /> - setRegistering(false)} /> -
- ); -} - -function SiteDrawer({ site, onClose }: { site: Site | null; onClose: () => void }) { - const update = useUpdateSite(); - const [v, setV] = useState(() => ({ - baseUrl: site?.baseUrl ?? "", - snapshotEnabled: site?.snapshotEnabled ?? false, - snapshotMinutes: site?.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "", - requestsPerMinute: site?.rateLimit.requestsPerMinute ? String(site.rateLimit.requestsPerMinute) : "", - minIntervalMs: site?.rateLimit.minIntervalMs !== undefined ? String(site.rateLimit.minIntervalMs) : "", - concurrency: site?.rateLimit.concurrency ? String(site.rateLimit.concurrency) : "", - })); - const [errors, setErrors] = useState>({}); - const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x })); - - const save = async (e: React.FormEvent) => { - e.preventDefault(); - if (!site) return; - const minutes = num(v.snapshotMinutes); - const rateLimit = { - ...(num(v.requestsPerMinute) !== undefined ? { requestsPerMinute: num(v.requestsPerMinute) } : {}), - ...(num(v.minIntervalMs) !== undefined ? { minIntervalMs: num(v.minIntervalMs) } : {}), - ...(num(v.concurrency) !== undefined ? { concurrency: num(v.concurrency) } : {}), +const DEFAULTS_CARD: CardSpec = { + anchor: "defaults", + title: "默认值", + description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。", + fields: [ + { + name: "sites.defaultRateLimit.requestsPerMinute", + label: "每分钟请求数", + kind: "number", + hint: "每个站点各自计数", + }, + { name: "sites.defaultRateLimit.concurrency", label: "并发", kind: "number", hint: "同一站点同时进行的请求" }, + { + name: "sites.defaultSnapshotIntervalSec", + label: "快照周期(分钟)", + kind: "number", + hint: "站点开了定时快照但没设周期时使用;5–10080", + }, + { + name: "sites.userDataMaxSeedingPages", + label: "做种列表页数上限", + kind: "number", + hint: "拉取用户数据时最多翻几页做种列表;1–50", + }, + { + name: "sites.userAgent", + label: "User-Agent", + kind: "text", + hint: "账号没填 UA 时,登录和拉取用户数据用这个", + wide: true, + }, + { + name: "sites.proxyUrl", + label: "代理地址", + kind: "url", + placeholder: "http://127.0.0.1:7890", + hint: "只对开了“使用代理”的站点生效;留空不用代理", + wide: true, + }, + ], + fromSettings: (s) => ({ + "sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute), + "sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency), + "sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)), + "sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages), + "sites.userAgent": s.sites.userAgent, + "sites.proxyUrl": s.sites.proxyUrl ?? "", + }), + toPatch: (v, s) => { + const min = num(v["sites.defaultSnapshotIntervalSec"]); + const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]); + const concurrency = num(v["sites.defaultRateLimit.concurrency"]); + return { + sites: { + defaultRateLimit: { + ...s.sites.defaultRateLimit, + requestsPerMinute: rpm, + concurrency, + }, + defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60), + userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]), + userAgent: str(v["sites.userAgent"]) || undefined, + proxyUrl: str(v["sites.proxyUrl"]) || null, + }, }; - const parsed = SiteUpdate.safeParse({ - baseUrl: str(v.baseUrl), - snapshotEnabled: v.snapshotEnabled === true, - snapshotIntervalSec: minutes === undefined ? null : Math.round(minutes * 60), - rateLimit, - }); - if (!parsed.success) { - const fe = zodFieldErrors(parsed.error.issues); - const map: Record = { - snapshotIntervalSec: "snapshotMinutes", - "rateLimit.requestsPerMinute": "requestsPerMinute", - "rateLimit.minIntervalMs": "minIntervalMs", - "rateLimit.concurrency": "concurrency", - }; - for (const [k, to] of Object.entries(map)) if (fe[k]) fe[to] = "填正整数"; - setErrors(fe); - return; - } - setErrors({}); - try { - await update.mutateAsync({ id: site.id, body: parsed.data }); - toast.success(`${site.name}已保存`); - onClose(); - } catch (err) { - toast.error(errorMessage(err, "保存失败")); - } - }; - - return ( - !o && !update.isPending && onClose()} - title={site?.name ?? ""} - description={ - site ? ( - - 在站点工作台打开 - - ) : null - } - footer={ - <> - - - - } - > -
- - - - - - - - {site ? : null} -
- ); -} - -function RegisterSiteDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { - const defs = useSiteDefinitions(); - const create = useCreateSite(); - const [key, setKey] = useState(""); - const [error, setError] = useState(); - const available = (defs.data?.items ?? []).filter((d) => !d.siteId); - const def = available.find((d) => d.key === key); - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!def) { - setError("选择站点"); - return; - } - setError(undefined); - try { - await create.mutateAsync({ key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl }); - toast.success(`${def.name}已登记`); - setKey(""); - onClose(); - } catch (err) { - toast.error(errorMessage(err, "登记失败")); - } - }; - - return ( - !o && !create.isPending && onClose()} - title="登记站点" - description="登记后再录入账号凭证。" - footer={ - <> - - - - } - > -
- ({ - value: d.key, - label: `${d.name}${d.shippedEnabled ? "" : "(未实测)"}`, - })), - }} - value={key} - error={error} - onChange={(x) => setKey(String(x))} - disabled={create.isPending} - /> - - {def ? ( -
-
地址
-
{def.baseUrl}
-
适配器
-
{ADAPTER[def.adapter] ?? def.adapter}
-
凭证类型
-
{def.authSupported.map(authLabel).join("、") || "—"}
-
默认限流
-
- 间隔 {def.defaultRateLimit.minIntervalMs} 毫秒,突发 {def.defaultRateLimit.burst} -
-
- ) : null} -
- ); -} - -// ─── 站点账号 ─── - -function CredentialForm({ siteId, onDone }: { siteId?: string; onDone?: () => void }) { - const sites = useSiteOptions(); - const put = usePutSiteCredentials(); - const [v, setV] = useState({ - siteId: siteId ?? "", - label: "default", - authKind: "cookie", - username: "", - credential: "", - }); - const [errors, setErrors] = useState>({}); - const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name })); - const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x })); - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - const fe: Record = {}; - if (!str(v.siteId)) fe.siteId = "选择站点"; - const parsed = SiteCredentialsPut.safeParse({ - label: str(v.label) || "default", - authKind: str(v.authKind), - credential: str(v.credential), - username: v.authKind === "password" ? str(v.username) || undefined : undefined, - }); - if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues)); - if (fe.credential) fe.credential = "填写凭证"; - if (Object.keys(fe).length || !parsed.success) { - setErrors(fe); - return; - } - setErrors({}); - try { - const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data }); - toast.success(r.created ? "站点账号已添加" : "凭证已替换"); - setV((s) => ({ ...s, credential: "" })); - onDone?.(); - } catch (err) { - toast.error(errorMessage(err, "保存失败")); - } - }; - - return ( -
- {siteId ? null : ( - - )} - - - {v.authKind === "password" ? ( - - ) : null} - -
- -
- - ); -} - -function AccountStatus({ a }: { a: SiteAccount }) { - if (!a.enabled) return 停用; - if (!a.secretRef) return 缺凭证; - const s = ACCOUNT_STATUS[a.status] ?? ACCOUNT_STATUS.unknown; - return {s?.label ?? a.status}; -} - -/** 站点抽屉里:本站账号 + 录入凭证 */ -function SiteAccountsInline({ siteId }: { siteId: string }) { - const accounts = useSiteAccounts(); - const rows = (accounts.data?.items ?? []).filter((a) => a.siteId === siteId); - const [adding, setAdding] = useState(rows.length === 0); - return ( - setAdding(true)}> - - 录入凭证 - - ) - } - > - {rows.length ? ( -
    - {rows.map((a) => ( -
  • - - {a.label} · {authLabel(a.authKind)} - - -
  • - ))} -
- ) : null} - {adding ? setAdding(false)} /> : null} -
- ); -} - -function AccountsBlock() { - const sites = useSiteOptions(); - const accounts = useSiteAccounts(); - const update = useUpdateSiteAccount(); - const remove = useRemoveSiteAccount(); - const [adding, setAdding] = useState(false); - const [deleting, setDeleting] = useState(null); - const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8); - - return ( - setAdding(true)}> - - 录入凭证 - - } - > - r.id} - loading={accounts.isPending} - error={accounts.error} - onRetry={() => accounts.refetch()} - empty={{ title: "还没有站点账号" }} - columns={[ - { - key: "site", - header: "站点 / 标签", - cell: (r) => ( -
-

{siteName(r.siteId)}

-

{r.label}

-
- ), - }, - { key: "kind", header: "类型", hideBelow: "sm", cell: (r) => authLabel(r.authKind) }, - { - key: "verified", - header: "最近验证", - hideBelow: "md", - cell: (r) => (r.lastVerifiedAt ? formatRelative(r.lastVerifiedAt) : "—"), - }, - { key: "status", header: "状态", cell: (r) => }, - { - key: "act", - header: 操作, - align: "right", - cell: (r) => ( -
- { - try { - await update.mutateAsync({ id: r.id, body: { enabled: v } }); - toast.success(v ? "账号已启用" : "账号已停用"); - } catch (err) { - toast.error(errorMessage(err, "切换失败")); - } - }} - /> - -
- ), - }, - ]} - /> - !o && setAdding(false)} - title="录入凭证" - description="同一站点、同一标签再次保存会替换旧凭证。" - > - setAdding(false)} /> - - !o && setDeleting(null)} - title={`删除账号「${deleting?.label ?? ""}」?`} - description="账号和凭证一并删除,无法恢复。" - confirmLabel="删除账号" - destructive - onConfirm={async () => { - if (!deleting) return; - try { - await remove.mutateAsync(deleting.id); - toast.success("站点账号已删除"); - } catch (err) { - toast.error(errorMessage(err, "删除失败")); - throw err; - } - }} - /> -
- ); -} + }, +}; export function SitesSection() { return ( -
- - +
+
+

+ 站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。 +

+ +
+ {(s) => }
); } diff --git a/apps/web/app/components/sites/credential-fields.tsx b/apps/web/app/components/sites/credential-fields.tsx new file mode 100644 index 00000000..f114f0ec --- /dev/null +++ b/apps/web/app/components/sites/credential-fields.tsx @@ -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 } { + const ua = d.userAgent.trim() || undefined; + if (d.kind === "password") { + const errors: Record = {}; + 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; + disabled?: boolean; + idPrefix?: string; +}) { + const set = (k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v }); + const id = (s: string) => `${idPrefix}-${s}`; + return ( +
+ {kinds.length > 1 ? ( +
+

凭证类型

+
+ {kinds.map((k) => ( + // biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达 + + ))} +
+
+ ) : null} + + {value.kind === "password" ? ( + <> +
+ + set("username", e.target.value)} + disabled={disabled} + /> + + + set("password", e.target.value)} + disabled={disabled} + /> + +
+ + set("totpSecret", e.target.value)} + disabled={disabled} + /> + +
+ set("saveCookieOnly", v)} + disabled={disabled} + /> + +
+ + ) : value.kind === "cookie" ? ( + +