Files
UHD_Media_Tool/apps/web/app/routes/console/sites.$siteId.tsx
T
rootandClaude Opus 5.5 7103bc8f7c feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay,
  candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search,
  unified download entry; origin view placeholder (M2)
- sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history
- tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans,
  cache volume thresholds and cleanup, business timeline (observation → plan → download →
  organization → agent/engine)
- organize: record list/detail with file-level results, manual organize with dryRun preview
- agent: session list/page and layout drawer with context prefix; streaming events,
  tool cards, rejection alerts, compaction, budget meters, model text vs server report;
  mock switch (?mock=1) until W2-D endpoints land

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:52:32 +08:00

406 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import { ArrowLeft, Bot, Camera, Globe, Radar, SearchX, TriangleAlert } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import {
accountSummary,
categoryLabel,
circuitLabel,
hrOf,
isFree,
relTime,
SITE_HEALTH,
} from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
{ value: "free", label: "免费" },
{ value: "2xfree", label: "2x 免费" },
{ value: "50%", label: "50%" },
{ value: "30%", label: "30%" },
{ value: "2x", label: "2x 上传" },
];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "未识别" },
{ value: "ambiguous", label: "有歧义" },
{ value: "unidentifiable", label: "无法识别" },
];
const SIZE = [
{ value: "lt5", label: "< 5 GB" },
{ value: "5to20", label: "5–20 GB" },
{ value: "20to60", label: "20–60 GB" },
{ value: "gt60", label: "> 60 GB" },
];
const AGE = [
{ value: "1", label: "24 小时内" },
{ value: "7", label: "7 天内" },
{ value: "30", label: "30 天内" },
];
const GB = 1024 ** 3;
/** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */
type LocalFilters = { hr: string; seeding: string; size: string; age: string };
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
if (f.hr === "no" && hrOf(r) !== "no") return false;
if (f.hr === "yes" && hrOf(r) !== "yes") return false;
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
if (f.size) {
const s = r.sizeBytes;
if (s === null) return false;
if (f.size === "lt5" && s >= 5 * GB) return false;
if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false;
if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false;
if (f.size === "gt60" && s < 60 * GB) return false;
}
if (f.age) {
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
}
return true;
}
function useDebounced<T>(value: T, ms = 400) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const site = useSite(siteId);
const accounts = useSiteAccounts(siteId);
const snap = useSnapshotSite();
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [promotion, setPromotion] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
const [live, setLive] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
};
const releases = useSiteReleases(siteId, filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const localActive = Object.values(local).some(Boolean);
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
const categories = useMemo(() => {
const m = new Map<string, string>();
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
if (category && !m.has(category)) m.set(category, category);
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site.data;
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
const selected = all.find((r) => r.id === openId) ?? null;
const freeCount = all.filter(isFree).length;
const reset = () => {
setQInput("");
setCategory("");
setPromotion("");
setResolution("");
setLocal({ hr: "", seeding: "", size: "", age: "" });
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
if (site.error && !s) return <ErrorState error={site.error} onRetry={() => void site.refetch()} />;
const health = s ? SITE_HEALTH[s.healthStatus] : null;
const account = accountSummary(accounts.data?.items ?? []);
const circuit = s ? circuitLabel(s) : null;
return (
<>
<PageHeader
title={s?.name ?? "站点工作台"}
description={
s ? (
<span className="flex flex-wrap items-center gap-1.5">
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只展示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
</span>
{snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
</span>
) : (
"加载中…"
)
}
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to="/console/sites">
<ArrowLeft />
站点
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />问 Agent
</Button>
</>
}
/>
<Tabs defaultValue="organized">
<TabsList>
<TabsTrigger value="organized">整理视图</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
</TabsList>
<TabsContent value="organized">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title="手动快照(受站点限流)"
onClick={() =>
snap.mutate(
{ id: siteId, maxPages: 2 },
{
onSuccess: () => toast.success("已排队快照,完成后刷新列表"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
</Button>
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "绕过本地快照,向站点实时搜索当前关键词" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
实时补查
</Button>
</>
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="H&R"
value={local.hr}
onChange={(v) => setLocal((l) => ({ ...l, hr: v }))}
options={[
{ value: "no", label: "无 H&R" },
{ value: "yes", label: "有 H&R" },
]}
/>
<FilterSelect
label="做种"
value={local.seeding}
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
options={[
{ value: "alive", label: "有人做种" },
{ value: "hot", label: "≥ 20 做种" },
]}
/>
<FilterSelect
label="大小"
value={local.size}
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
options={SIZE}
/>
<FilterSelect
label="上传"
value={local.age}
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
options={AGE}
/>
</FilterBar>
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState
plain
title="读取站点资源失败"
error={releases.error}
onRetry={() => void releases.refetch()}
/>
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length ? SearchX : Globe}
title={
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
}
description={
all.length
? "H&R / 做种 / 大小 / 上传时间只在已加载结果中筛选,可以加载更多或放宽条件。"
: coverage?.liveError
? `实时补查未成功(${coverage.liveError}),这不代表站点没有该资源。`
: q
? "可以点“实时补查”向站点直接搜索。"
: "点“快照”抓取站点列表;快照受站点限流。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow
key={r.id}
r={r}
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
inventoryLoading={inventory.isLoading}
staleBefore={staleBefore}
selected={openId === r.id}
onOpen={() => setOpenId(r.id)}
/>
))}
</ul>
</>
)}
{releases.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={() => void releases.fetchNextPage()}
disabled={releases.isFetchingNextPage}
>
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
</TabsContent>
<TabsContent value="origin">
<EmptyState
icon={Globe}
title="原站视图在 M2 提供"
description={
<>
原站视图会接入后端浏览器会话(camofox-browser),在同一会话里查看真实页面、人工登录或处理验证,并与 Agent
交接控制权。首版只提供整理视图;需要看原站时,整理视图的详情里保留了资源的站点 ID 与来源时间。
</>
}
/>
</TabsContent>
</Tabs>
<ReleaseDetailSheet
siteId={siteId}
release={selected}
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
onClose={() => setOpenId(null)}
/>
</>
);
}
function CoverageBanner({
coverage,
refreshError,
}: {
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
refreshError: unknown;
}) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError)
notes.push({ tone: "warning", text: `刷新失败,保留上次数据并标为过期:${errorMessage(refreshError)}` });
if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只展示历史数据" });
if (coverage.stale)
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)}),列表为既有数据`
: "从未快照,列表只来自 Feed 观察与实时补查",
});
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError};已保留本地数据` });
else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一实时搜索" : "已实时补查站点" });
}
if (!notes.length) return null;
return (
<div className="mb-3 space-y-1.5">
{notes.map((n) => (
<p
key={n.text}
className={
n.tone === "warning"
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
: n.tone === "success"
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
}
>
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 break-words">{n.text}</span>
</p>
))}
</div>
);
}