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]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -1,6 +1,405 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
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";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user