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) },
]}
/>
-
+