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:
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>
|
||||
);
|
||||
|
||||
@@ -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("等待处理");
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user