diff --git a/apps/web/app/components/sites/site-card.tsx b/apps/web/app/components/sites/site-card.tsx index efd35c8a..883eb3db 100644 --- a/apps/web/app/components/sites/site-card.tsx +++ b/apps/web/app/components/sites/site-card.tsx @@ -29,6 +29,7 @@ import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx"; import { ACCOUNT_OVERALL, bytes, + cloudflareLine, dataAccount, hasLogin, healthLine, @@ -291,6 +292,7 @@ function AccountSiteCard({ site, checks, onDialog }: Props) { const hmsg = healthMessage(site.lastHealth); const stale = site.snapshotEnabled && snapshotStale(site); const circuit = circuitLabel(site); + const cf = cloudflareLine(site.config); const lastErr = acc && acc.status !== "active" ? acc.lastError : null; return ( @@ -321,6 +323,15 @@ function AccountSiteCard({ site, checks, onDialog }: Props) { {u?.vip ? VIP : null} {circuit ? {circuit} : null} + {cf ? ( + cf.takeover ? ( + + {cf.text} + + ) : ( + {cf.text} + ) + ) : null} {lastErr ? ( diff --git a/apps/web/app/components/sites/site-fields.test.ts b/apps/web/app/components/sites/site-fields.test.ts index c51bd956..1cd408b1 100644 --- a/apps/web/app/components/sites/site-fields.test.ts +++ b/apps/web/app/components/sites/site-fields.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { healthLine, healthMessage, healthTechItems } from "./site-fields.ts"; +import { cloudflareLine, cloudflareTechItems, healthLine, healthMessage, healthTechItems } from "./site-fields.ts"; describe("健康检测文案(HC-1)", () => { it("人机验证单独成类;失败原因一句,原始判定进技术信息", () => { @@ -24,3 +24,51 @@ describe("健康检测文案(HC-1)", () => { expect(healthTechItems({ ok: true, status: "ok", latencyMs: 1, message: null })).toEqual([]); }); }); + +describe("cloudflareLine / cloudflareTechItems(CF-2)", () => { + const now = new Date("2026-09-30T12:00:00+08:00"); + const cfg = (cloudflare: Record) => ({ cloudflare }); + it("没遇到过挑战不显示", () => { + expect(cloudflareLine({}, now)).toBeNull(); + expect(cloudflareLine(cfg({ lastSolvedBy: "auto" }), now)).toBeNull(); + expect(cloudflareTechItems({})).toEqual([]); + }); + it("需要你验证 > 浏览器代理中 > 自动通过", () => { + const at = "2026-09-30T11:00:00+08:00"; + expect(cloudflareLine(cfg({ lastChallengeAt: at, pending: "human", lastSolvedBy: "auto" }), now)).toEqual({ + text: "Cloudflare:需要你验证", + tone: "warning", + takeover: true, + }); + const until = new Date("2026-09-30T17:30:00+08:00"); + const line = cloudflareLine(cfg({ lastChallengeAt: at, browserUntil: until.toISOString() }), now); + const hm = `${String(until.getHours()).padStart(2, "0")}:${String(until.getMinutes()).padStart(2, "0")}`; + expect(line?.text).toBe(`Cloudflare:浏览器代理中,至 ${hm}`); + expect(cloudflareLine(cfg({ lastChallengeAt: at, lastSolvedBy: "vision" }), now)?.text).toBe( + "Cloudflare:自动通过", + ); + // 代理期已过、没通过过、有错误 + expect( + cloudflareLine(cfg({ lastChallengeAt: at, browserUntil: at, lastError: "浏览器服务不可用" }), now)?.text, + ).toBe("Cloudflare:没能通过"); + }); + it("原始记录进技术信息", () => { + const items = cloudflareTechItems( + cfg({ + lastChallengeAt: "2026-09-30T11:00:00+08:00", + lastRule: "header cf-mitigated=challenge", + lastResult: "reuse_fails", + lastSolvedBy: "auto", + cfReuse: "fails", + }), + ); + expect(items.map((i) => i.label)).toEqual([ + "最近遇到挑战", + "挑战规则", + "处理结果", + "最近通过方式", + "cf_clearance 复用", + ]); + expect(items.find((i) => i.label === "处理结果")?.value).toContain("reuse_fails"); + }); +}); diff --git a/apps/web/app/components/sites/site-fields.ts b/apps/web/app/components/sites/site-fields.ts index 04430cac..e3ab8ec7 100644 --- a/apps/web/app/components/sites/site-fields.ts +++ b/apps/web/app/components/sites/site-fields.ts @@ -11,6 +11,7 @@ import { type SiteListItem, type SiteLoginFailureReason, type SiteRateLimit, + siteCloudflareState, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; @@ -180,6 +181,64 @@ export function healthTechItems(h: SiteLastHealth | null): { label: string; valu ].filter((it) => it.value); } +/** + * Cloudflare 状态一行(CF-2):没遇到过挑战不显示。 + * 需要你验证 → 浏览器代理中,至 hh:mm → 自动通过(最近一次怎么过的)。 + */ +export function cloudflareLine( + config: unknown, + now: Date = new Date(), +): { text: string; tone: StatusTone; takeover: boolean } | null { + const st = siteCloudflareState(config); + if (!st?.lastChallengeAt) return null; + if (st.pending === "human") return { text: "Cloudflare:需要你验证", tone: "warning", takeover: true }; + if (st.browserUntil && Date.parse(st.browserUntil) > now.getTime()) { + const t = new Date(st.browserUntil); + const hm = `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`; + return { text: `Cloudflare:浏览器代理中,至 ${hm}`, tone: "neutral", takeover: false }; + } + if (st.lastSolvedBy) return { text: "Cloudflare:自动通过", tone: "success", takeover: false }; + if (st.lastError) return { text: "Cloudflare:没能通过", tone: "warning", takeover: false }; + return null; +} + +const CF_SOLVED_BY: Record = { + auto: "等它自动放行", + vision: "视觉子流程点了复选框", + human: "你在原站视图里通过", +}; +const CF_RESULT: Record = { + passed: "已通过", + reuse_ok: "已通过,HTTP 复用 cf_clearance 有效", + reuse_fails: "已通过,HTTP 复用 cf_clearance 无效,改由浏览器代抓", + browser: "由浏览器代抓", + browser_challenge: "浏览器也被拦下", + needs_human: "等你在原站视图里验证", + failed: "没能通过", + skipped: "没有已登录的账号,未处理", +}; + +/** Cloudflare 原始记录 → 技术信息条目 */ +export function cloudflareTechItems(config: unknown): { label: string; value: string; mono?: boolean }[] { + const st = siteCloudflareState(config); + if (!st?.lastChallengeAt) return []; + const t = (v: string | null) => (v ? new Date(v).toLocaleString("zh-CN", { hour12: false }) : ""); + return [ + { label: "最近遇到挑战", value: t(st.lastChallengeAt) }, + { label: "挑战规则", value: st.lastRule ?? "", mono: true }, + { label: "处理结果", value: st.lastResult ? `${CF_RESULT[st.lastResult] ?? st.lastResult}(${st.lastResult})` : "" }, + { label: "最近通过方式", value: st.lastSolvedBy ? (CF_SOLVED_BY[st.lastSolvedBy] ?? st.lastSolvedBy) : "" }, + { label: "最近通过时间", value: t(st.lastSolvedAt) }, + { + label: "cf_clearance 复用", + value: st.cfReuse === "ok" ? "有效(ok)" : st.cfReuse === "fails" ? "无效(fails)" : "", + }, + { label: "浏览器代理至", value: t(st.browserUntil) }, + { label: "最近尝试", value: t(st.lastAttemptAt) }, + { label: "最近错误", value: st.lastError ?? "" }, + ].filter((it) => it.value); +} + /** 限流:"20 次/分";空对象 = 默认 */ export function rateLimitText(r: SiteRateLimit | null | undefined): string { if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认"; @@ -202,7 +261,10 @@ export function loginFailureText( case "two_factor": return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true }; case "challenge": - return { text: "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie", suggestCookie: true }; + return { + text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在待处理里提醒你", + suggestCookie: false, + }; case "bad_credentials": return { text: "登录失败:账号或密码错误", suggestCookie: false }; case "account_blocked": diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index e094c930..59d0cb84 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,9 +1,11 @@ import type { SiteListItem } from "@umt/contracts"; +import { Link } from "react-router"; import { useFeeds } from "~/api/feeds.ts"; import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { StatTile } from "~/components/console/stat-tile.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; @@ -12,6 +14,8 @@ import { ACCOUNT_OVERALL, AUTH_KIND_LABEL, bytes, + cloudflareLine, + cloudflareTechItems, dataAccount, hasLogin, healthLine, @@ -26,6 +30,28 @@ import { } from "./site-fields.ts"; import { UserDataChart } from "./user-data-chart.tsx"; +/** Cloudflare 状态行(CF-2);需要你验证时给原站视图入口 */ +function cloudflareRow(site: SiteListItem) { + const cf = cloudflareLine(site.config); + if (!cf) return []; + const text = cf.text.replace(/^Cloudflare:/, ""); + return [ + { + label: "Cloudflare", + value: cf.takeover ? ( + + {text} + + 去原站视图 + + + ) : ( + text + ), + }, + ]; +} + const CAP_LABEL = [ ["search", "搜索"], ["list", "列表抓取"], @@ -180,11 +206,16 @@ function AccountSiteOverview({ site }: { site: SiteListItem }) { ), }, { label: "代理", value: site.proxy ? "使用" : "不使用" }, + ...cloudflareRow(site), ...(site.kind && site.kind !== "pt" ? [{ label: "类型", value: kindLabel(site.kind) }] : []), { label: "获取方式", value: capabilityText(site) }, ]} /> - +