Merge branch 'hh2'

# Conflicts:
#	apps/server/src/modules/browser/service.ts
#	apps/web/app/components/browser/desktop-view.tsx
#	apps/web/app/components/browser/site-origin-view.tsx
#	packages/contracts/src/browser.ts
This commit is contained in:
root committed 2026-09-29 17:43:00 +08:00
commit f41cd96fee
33 files changed
+1919 -302

No files matched your search

@@ -28,7 +28,7 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
id: s.id,
title: s.title,
status: STEP_STATUS[s.status],
...(s.note ? { detail: <span className="block max-w-48 truncate">{s.note}</span> } : {}),
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}),
}));
}
@@ -3,7 +3,7 @@
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)、
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽。
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽(可压缩,标题至少留一半宽度)。
import { ChevronDown, ListTodo } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
@@ -162,8 +162,10 @@ export function TodoList({
<StatusIcon status={s} />
<span className="sr-only">{LABEL[s]}:</span>
<span
title={typeof item.title === "string" ? item.title : undefined}
className={cn(
"min-w-0 flex-1 truncate text-caption leading-5",
// 标题至少占一半宽度:备注(detail)再长也只能压缩自己,不能把标题挤成一条竖线
"min-w-[50%] flex-1 truncate text-caption leading-5",
s === "pending" && "text-muted-foreground",
s === "in-progress" && "text-foreground",
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
@@ -174,7 +176,7 @@ export function TodoList({
{item.title}
</span>
{item.detail ? (
<span className="shrink-0 text-meta text-muted-foreground">{item.detail}</span>
<span className="min-w-0 shrink truncate text-meta text-muted-foreground">{item.detail}</span>
) : null}
</li>
);
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { holderName, toViewport, waitingText } from "./coords.ts";
import { agentActive, controllerLabel, holderName, toViewport, waitingText } from "./coords.ts";
describe("原站视图坐标换算", () => {
const natural = { width: 1280, height: 800 };
@@ -29,3 +29,18 @@ describe("原站视图坐标换算", () => {
expect(holderName("admin_session:alice")).toBe("alice");
});
});
describe("当前控制文案", () => {
const now = Date.parse("2026-09-29T10:00:00Z");
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「Agent」", () => {
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:50:00Z" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("Agent");
expect(agentActive({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe(true);
});
it("人工接管 / 等待处理不看自动化时间", () => {
expect(controllerLabel({ controller: "human", lastAutomationAt: "2026-09-29T09:59:50Z" }, now)).toBe("人工接管");
expect(controllerLabel({ controller: "waiting" }, now)).toBe("等待处理");
});
});
+22 -1
View File
@@ -17,12 +17,33 @@ export function toViewport(
}
export const CONTROLLER_LABEL: Record<BrowserController, string> = {
automation: "Agent",
automation: "自动化",
human: "人工接管",
waiting: "等待处理",
closed: "已关闭",
};
/** 自动化动作在这段时间内发生过,算「Agent 正在操作」 */
export const AGENT_ACTIVE_MS = 2 * 60_000;
/** Agent 是否正在用这个会话:控制者是自动化,且最近 AGENT_ACTIVE_MS 内有过自动化动作 */
export function agentActive(
s: { controller: BrowserController; lastAutomationAt?: string | null },
now = Date.now(),
): boolean {
if (s.controller !== "automation" || !s.lastAutomationAt) return false;
const t = new Date(s.lastAutomationAt).getTime();
return Number.isFinite(t) && now - t < AGENT_ACTIVE_MS;
}
/** 「当前控制」文案:无人操作时是「自动化」,Agent 正在操作才显示「Agent」 */
export function controllerLabel(
s: { controller: BrowserController; lastAutomationAt?: string | null },
now = Date.now(),
): string {
return agentActive(s, now) ? "Agent" : CONTROLLER_LABEL[s.controller];
}
const WAITING_TEXT: Record<string, string> = {
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
@@ -9,7 +9,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { apiData, errorMessage } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
import { agentActive, controllerLabel, credentialBadge, holderName, waitingText } from "./coords.ts";
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
import { SiteOriginView } from "./site-origin-view.tsx";
@@ -153,7 +153,7 @@ function DesktopView({
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
当前控制:{CONTROLLER_LABEL[s.controller]}
当前控制:{controllerLabel(s)}
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
@@ -217,7 +217,9 @@ function DesktopView({
<p className="text-caption text-muted-foreground">
{mine
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
: "只读观看,Agent 可能正在操作。需要自己点或输入时先接管。"}
: agentActive(s)
? "只读观看,Agent 正在操作。需要自己点或输入时先接管,Agent 会暂停。"
: "只读观看,目前没有 Agent 在操作。需要自己点或输入时先接管。"}
</p>
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
@@ -34,7 +34,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { CONTROLLER_LABEL, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
import { controllerLabel, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
@@ -156,7 +156,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
当前控制:{CONTROLLER_LABEL[s.controller]}
当前控制:{controllerLabel(s)}
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
@@ -390,9 +390,9 @@ function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => voi
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" />
{r.seeders ?? "?"}
{r.seeders ?? "—"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "?"}
{r.leechers ?? "—"}
</span>
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
{tech ? <span className="break-all">{tech}</span> : null}
@@ -217,7 +217,7 @@ export function ReleaseDetailSheet({
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
{
label: "做种 / 下载",
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
value: `${r.seeders ?? "—"} / ${r.leechers ?? "—"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
},
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
]}
@@ -16,9 +16,23 @@ export function hrOf(r: Pick<SourceRelease, "raw">): "yes" | "no" | "unknown" {
return hr === true ? "yes" : hr === false ? "no" : "unknown";
}
/** 站点 RSS 规范化后的字段(raw.rss):分类名、媒体大类 */
function rssExtra(r: Pick<SourceRelease, "raw">): { categoryName?: unknown } {
const s = (r.raw as { rss?: { categoryName?: unknown } }).rss;
return s && typeof s === "object" ? s : {};
}
/** 分类名:列表快照的站点分类名 → RSS 标题前缀里的分类名 → 站点分类 ID */
export function categoryLabel(r: Pick<SourceRelease, "raw" | "categoryRaw">): string | null {
const name = siteExtra(r).categoryName;
return typeof name === "string" && name ? name : r.categoryRaw;
if (typeof name === "string" && name) return name;
const rss = rssExtra(r).categoryName;
return typeof rss === "string" && rss ? rss : r.categoryRaw;
}
/** 列表里的数字:未知显示「—」(未知 ≠ 0) */
export function countText(n: number | null | undefined): string {
return typeof n === "number" && Number.isFinite(n) ? String(n) : "—";
}
const PROMO_LABEL: Record<string, string> = {
@@ -6,6 +6,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
import {
absTime,
categoryLabel,
countText,
hrOf,
ORIGIN_LABEL,
promotionLabel,
@@ -15,8 +16,11 @@ import {
snatchedOf,
} from "./release-fields.ts";
/** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
/**
* 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明。
* H&R 未知只在 showUnknownHr 时标出(详情里);列表行不为未知字段挂徽章,避免 RSS 条目满屏「未知」。
*/
export function ReleaseBadges({ r, showUnknownHr = false }: { r: WorkbenchRelease; showUnknownHr?: boolean }) {
const promo = promotionLabel(r);
const hr = hrOf(r);
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
@@ -31,7 +35,7 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
</span>
) : null}
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
{hr === "unknown" && showUnknownHr ? <StatusBadge>H&R 未知</StatusBadge> : null}
</>
);
}
@@ -159,19 +163,17 @@ export function ReleaseRow({
{r.subtitle ?? ""}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
{cat ?? "分类未知"}
</span>
<span className="sm:w-16 sm:text-right">
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : "分类未知"}>
{cat ?? "—"}
</span>
<span className="sm:w-16 sm:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
<ArrowUp className="h-3 w-3" aria-label="做种" />
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
<span className={cn(r.seeders === 0 && "text-warning")}>{countText(r.seeders)}</span>
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
{r.leechers ?? "?"}
{countText(r.leechers)}
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
{snatched ?? "?"}
{countText(snatched)}
</span>
<span className="sm:w-20 sm:text-right" title={timeTitle}>
{relTime(r.sourcePublishedAt)}