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:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:32 +08:00
1 parent 6280d52ec3
commit 7103bc8f7c
35 files changed
+6134 -45

No files matched your search

@@ -0,0 +1,449 @@
import type { AgentRunStatus } from "@umt/contracts";
import {
Bot,
CheckCircle2,
ChevronRight,
CircleDashed,
CircleX,
Layers,
LoaderCircle,
ShieldAlert,
User,
Wrench,
} from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import {
type AgentRunView,
type GoalReport,
isRunActive,
REPORT_STATE,
RUN_STATUS_LABEL,
RUN_STATUS_TONE,
} from "~/api/agent.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn } from "~/lib/utils.ts";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
const BUDGET_LABEL: Record<string, string> = {
tool_calls: "工具调用次数",
duration: "运行时长",
tokens: "token",
cost: "费用",
};
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
function durationText(a: string, b: string | null) {
if (!b) return null;
const ms = new Date(b).getTime() - new Date(a).getTime();
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
}
/** 摘要块:长文本在块内滚动,不撑坏布局 */
function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) {
return (
<div className="min-w-0">
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
<pre
className={cn(
"max-h-48 overflow-auto rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 font-mono text-meta break-all whitespace-pre-wrap text-foreground",
tone === "destructive" && "border-destructive/30 text-destructive",
)}
>
{text}
</pre>
</div>
);
}
const TOOL_STATUS: Record<
ToolCallView["status"],
{ label: string; tone: "success" | "destructive" | "warning" | "neutral" }
> = {
running: { label: "执行中", tone: "neutral" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
denied: { label: "已拒绝", tone: "destructive" },
};
/** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */
export function ToolCallCard({ call }: { call: ToolCallView }) {
const [open, setOpen] = useState(false);
const st = TOOL_STATUS[call.status];
if (call.status === "denied") {
return (
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-destructive">
<ShieldAlert className="h-4 w-4 shrink-0" />
<span>服务端拒绝调用</span>
<code className="font-mono text-meta break-all">{call.capabilityId}</code>
</div>
<p className="mt-1 text-caption break-words text-destructive">{call.reason ?? "未授权"}</p>
<p className="mt-1 text-meta text-muted-foreground">授权校验在服务端完成;Agent 没有绕过的途径。</p>
</div>
);
}
const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2;
return (
<div className="min-w-0 rounded-xl border border-border/70">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate font-mono text-caption text-foreground">{call.capabilityId}</code>
{call.recovery ? <StatusBadge>恢复补记</StatusBadge> : null}
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{durationText(call.startedAt, call.endedAt) ?? ""}
</span>
<StatusBadge tone={st.tone}>
<Icon className={cn("h-3 w-3", call.status === "running" && "animate-spin")} />
{st.label}
</StatusBadge>
</button>
{!open && call.outputSummary ? (
<p className="truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{call.outputSummary}</p>
) : null}
{open ? (
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
{call.inputSummary ? <Summary label="输入(已脱敏)" text={call.inputSummary} /> : null}
{call.outputSummary ? (
<Summary
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
text={call.outputSummary}
tone={call.status === "failed" ? "destructive" : undefined}
/>
) : call.status === "running" ? (
<p className="text-meta text-muted-foreground">等待结果…</p>
) : null}
</div>
) : null}
</div>
);
}
function StreamBlock({ item }: { item: StreamItem }) {
const [open, setOpen] = useState(false);
switch (item.kind) {
case "text":
return (
<div className="flex gap-2.5">
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
</span>
<p className="min-w-0 flex-1 text-sm leading-relaxed break-words whitespace-pre-wrap text-foreground">
{item.text}
</p>
</div>
);
case "thinking":
return (
<div className="pl-8">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
<ChevronRight className={cn("h-3 w-3 transition-transform duration-base", open && "rotate-90")} />
思考过程
</button>
{open ? (
<p className="mt-1 max-h-48 overflow-auto text-caption break-words whitespace-pre-wrap text-muted-foreground">
{item.text}
</p>
) : null}
</div>
);
case "tool":
return (
<div className="pl-8">
<ToolCallCard call={item.call} />
</div>
);
case "compaction":
return (
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
<Layers className="h-3.5 w-3.5 shrink-0" />
<span>
上下文压缩({COMPACT_REASON[item.reason] ?? item.reason})
{item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"}
</span>
</div>
);
case "end":
if (item.ok) return null;
return (
<div
role="alert"
className={cn(
"ml-8 rounded-xl border px-3 py-2.5 text-caption",
item.reason === "budget"
? "border-warning/40 bg-warning/10 text-warning"
: item.reason === "aborted"
? "border-border bg-foreground/[0.04] text-muted-foreground"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
<p className="font-medium">
{item.reason === "budget"
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}`
: item.reason === "aborted"
? "已中止"
: "运行失败"}
</p>
<p className="mt-0.5 break-words">{item.message}</p>
</div>
);
}
}
function Meter({
label,
used,
max,
format,
}: {
label: string;
used: number | undefined;
max?: number;
format: (n: number) => string;
}) {
const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null;
return (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2 text-meta">
<span className="text-muted-foreground">{label}</span>
<span className="truncate text-foreground tabular-nums">
{used === undefined ? "未报告" : format(used)}
{max ? <span className="text-muted-foreground"> / {format(max)}</span> : null}
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
{pct !== null ? (
<div
className={cn("h-full rounded-full", pct >= 90 ? "bg-warning" : "bg-foreground/60")}
style={{ width: `${pct}%` }}
/>
) : null}
</div>
</div>
);
}
/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
const u = { ...run.usage, ...(live.usage ?? {}) };
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const durationMs =
u.durationMs ??
(run.startedAt
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined);
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Meter label="工具调用" used={toolCalls} max={run.budget.maxToolCalls} format={(n) => String(n)} />
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n}s`}
/>
<Meter label="费用(估算)" used={u.costUsd} max={run.budget.maxCostUsd} format={(n) => `$${n.toFixed(3)}`} />
</div>
);
}
/** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
export function FinalReport({
run,
report,
live,
}: {
run: AgentRunView;
report: GoalReport | null;
live: AgentStreamState;
}) {
const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool");
const tail = live.items
.slice(lastTool + 1)
.filter((i): i is Extract<StreamItem, { kind: "text" }> => i.kind === "text")
.map((i) => i.text)
.join("");
const said = report?.modelText ?? (tail || run.summary || "");
const calls = live.items
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
.map((i) => i.call);
const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length;
const state = report ? REPORT_STATE[report.state] : null;
return (
<div className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-xl border border-border/70 p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<Bot className="h-3.5 w-3.5" />
模型说的
</p>
<p className="max-h-60 overflow-auto text-sm break-words whitespace-pre-wrap text-foreground">
{said || "(没有文字汇报)"}
</p>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<CheckCircle2 className="h-3.5 w-3.5" />
系统核实的结果
</p>
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
{count("denied") ? <StatusBadge tone="destructive">被拒绝 {count("denied")}</StatusBadge> : null}
</div>
{report ? (
<>
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
{(
[
["已提交", report.submitted],
["已入库", report.imported],
["进行中", report.inProgress],
["失败", report.failed],
] as const
).map(([k, v]) => (
<div key={k} className="rounded-lg border border-border/60 px-1 py-1.5">
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
<dt className="text-meta text-muted-foreground">{k}</dt>
</div>
))}
</dl>
<ul className="mt-2 space-y-1 text-caption">
{report.downloads.map((d) => (
<li key={d.downloadTaskId} className="min-w-0 truncate">
<Link
to={`/console/tasks/${d.downloadTaskId}`}
className="text-foreground underline-offset-4 hover:underline"
>
下载 · {d.name ?? d.downloadTaskId.slice(0, 8)}
</Link>
<span className="text-muted-foreground"> · {d.status}</span>
</li>
))}
{report.organization.map((o) => (
<li key={o.recordId} className="min-w-0 truncate">
<Link
to={`/console/organize/${o.recordId}`}
className="text-foreground underline-offset-4 hover:underline"
>
整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)}
</Link>
<span className="text-muted-foreground"> · {o.status}</span>
</li>
))}
</ul>
</>
) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报尚未生成;以任务页、整理记录的实际状态为准。</p>
)}
{run.error ? (
<p className="mt-2 text-caption break-words text-destructive">
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
</p>
) : null}
</div>
</div>
);
}
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
export function AgentConversation({
run,
live,
userMessages = [],
compact = false,
}: {
run: AgentRunView;
live: AgentStreamState;
userMessages?: { at: string; text: string; pending?: boolean }[];
compact?: boolean;
}) {
const endRef = useRef<HTMLDivElement>(null);
const active = isRunActive(run.status) && !live.finished;
const count = live.items.length;
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
useEffect(() => {
if (active) endRef.current?.scrollIntoView({ block: "nearest" });
}, [count, active]);
// 追加的用户消息按时间插入
type Row = { key: string; at: string; node: ReactNode };
const rows: Row[] = live.items.map((it) => ({
key: it.key,
at: it.kind === "tool" ? it.call.startedAt : it.at,
node: <StreamBlock item={it} />,
}));
for (const m of userMessages)
rows.push({
key: `u${m.at}`,
at: m.at,
node: <UserBubble text={m.text} note={m.pending ? "等待 Agent 读取" : undefined} />,
});
rows.sort((a, b) => a.at.localeCompare(b.at));
return (
<div className={cn("space-y-3", compact && "text-sm")}>
<UserBubble text={run.goal} mode={run.mode ?? (run.scope.mode as string | undefined)} />
{rows.map((r) => (
<div key={r.key}>{r.node}</div>
))}
{active ? (
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5 animate-spin" />
{run.status === "waiting" ? "等待中…" : "正在处理…"}
</div>
) : null}
{live.error ? <p className="pl-8 text-meta text-destructive">事件读取失败:{live.error}(稍后自动重试)</p> : null}
<div ref={endRef} />
</div>
);
}
function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) {
return (
<div className="flex justify-end gap-2.5">
<div className="max-w-[85%] min-w-0 rounded-2xl border border-border/70 bg-foreground/[0.05] px-3 py-2">
{mode === "plan_only" ? <p className="mb-0.5 text-meta text-muted-foreground">只要方案</p> : null}
<p className="text-sm break-words whitespace-pre-wrap text-foreground">{text}</p>
{note ? <p className="mt-0.5 text-meta text-muted-foreground">{note}</p> : null}
</div>
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
<User className="h-3.5 w-3.5" />
</span>
</div>
);
}
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
return (
<StatusBadge tone={RUN_STATUS_TONE[status]}>
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin" /> : null}
{RUN_STATUS_LABEL[status]}
</StatusBadge>
);
}
@@ -0,0 +1,95 @@
import { Bot, ExternalLink, Plus } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useCreateAgentRun } from "~/api/agent.ts";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import {
contextPrefix,
openAgentDrawer,
setAgentDrawerOpen,
setAgentDrawerRun,
useAgentDrawer,
} from "./agent-store.ts";
/**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
*/
export function AgentDrawer() {
const { open, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const prefix = contextPrefix(context);
return (
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-lg">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
</SheetTitle>
<SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{runId ? (
<AgentRunPanel runId={runId} compact />
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal: `${prefix}${goal}`, mode },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{runId ? (
<>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus />
新目标
</Button>
<Button asChild variant="outline" size="sm">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在会话页打开
</Link>
</Button>
</>
) : (
<Button asChild variant="ghost" size="sm">
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}
/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */
export function AgentDrawerTrigger() {
return (
<Button variant="ghost" size="icon-sm" aria-label="打开 Agent" title="Agent" onClick={() => openAgentDrawer(null)}>
<Bot />
</Button>
);
}
@@ -0,0 +1,212 @@
import { Send, Square } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
type AgentMode,
agentMockEnabled,
isRunActive,
useAbortAgentRun,
useAgentRun,
useAppendAgentMessage,
} from "~/api/agent.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
/** 目标输入:execute / plan_only 分段切换 + 文本框 */
export function GoalComposer({
onSubmit,
pending,
prefix,
defaultGoal = "",
compact = false,
}: {
onSubmit: (goal: string, mode: AgentMode) => void;
pending: boolean;
/** 上下文前缀(只读展示) */
prefix?: string;
defaultGoal?: string;
compact?: boolean;
}) {
const [goal, setGoal] = useState(defaultGoal);
const [mode, setMode] = useState<AgentMode>("execute");
const submit = () => {
const g = goal.trim();
if (!g) {
toast.error("先写下目标");
return;
}
onSubmit(g, mode);
};
return (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{prefix ? (
<p
className="truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
title={prefix}
>
{prefix}
</p>
) : null}
<Textarea
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
rows={compact ? 3 : 3}
maxLength={4000}
aria-label="目标"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<fieldset aria-label="运行模式" className="flex items-center gap-1">
{(
[
["execute", "授权内执行"],
["plan_only", "只要方案"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
aria-pressed={mode === v}
onClick={() => setMode(v)}
className={cn(
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground transition-colors duration-micro hover:text-foreground",
mode === v && "pill-selected text-foreground",
)}
>
{label}
</button>
))}
</fieldset>
<Button type="submit" size="sm" disabled={pending}>
<Send />
{pending ? "提交中…" : "开始"}
</Button>
</div>
<p className="text-meta text-muted-foreground">
{mode === "plan_only"
? "只调查、出方案,不执行任何写操作。"
: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。"}
</p>
</form>
);
}
/** 一次运行:头部状态 + 预算条 + 流式对话 + 最终汇报 + 追加消息 */
export function AgentRunPanel({ runId, compact = false }: { runId: string; compact?: boolean }) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const [msg, setMsg] = useState("");
if (run.isPending) return <LoadingState rows={4} />;
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<RunStatusBadge status={live.finished && isRunActive(r.status) ? "running" : r.status} />
<span className="text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
</span>
{agentMockEnabled() ? <StatusBadge tone="warning">mock 数据</StatusBadge> : null}
{active ? (
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm" className="ml-auto" disabled={abort.isPending}>
<Square />
中止
</Button>
}
title="中止这次运行?"
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
confirmLabel="中止"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("已请求中止"),
(e) => toast.error(errorMessage(e)),
)
}
/>
) : null}
</div>
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
<BudgetBar run={r} live={live} />
</div>
<AgentConversation
run={r}
live={live}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
compact={compact}
/>
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
const text = msg.trim();
if (!text) return;
append.mutate(
{ id: r.id, text },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
}}
>
<Textarea
value={msg}
onChange={(e) => setMsg(e.target.value)}
rows={1}
maxLength={8000}
placeholder={active ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
aria-label="追加消息"
className="min-h-9 flex-1"
/>
<Button
type="submit"
size="icon"
variant="outline"
disabled={append.isPending || !msg.trim()}
aria-label="发送"
>
<Send />
</Button>
</form>
</div>
);
}
@@ -0,0 +1,62 @@
import { useSyncExternalStore } from "react";
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
export type AgentContext = {
/** 显示给用户的上下文说明,如"作品:沙丘2 (2024)" */
label?: string;
entityId?: string;
releaseId?: string;
downloadTaskId?: string;
recordId?: string;
siteId?: string;
/** 预填目标 */
goal?: string;
};
type State = { open: boolean; context: AgentContext | null; runId: string | null };
let state: State = { open: false, context: null, runId: null };
const listeners = new Set<() => void>();
const emit = () => {
for (const l of listeners) l();
};
export function openAgentDrawer(context: AgentContext | null = null) {
state = { open: true, context, runId: null };
emit();
}
export function setAgentDrawerOpen(open: boolean) {
state = { ...state, open };
emit();
}
export function setAgentDrawerRun(runId: string | null) {
state = { ...state, runId };
emit();
}
export function useAgentDrawer(): State {
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => state,
() => state,
);
}
/** 上下文 → goal 前缀(服务端按 scope.context 解析对象;前缀让模型和人都看得到指代) */
export function contextPrefix(ctx: AgentContext | null): string {
if (!ctx) return "";
const parts: string[] = [];
if (ctx.label) parts.push(ctx.label);
if (ctx.entityId) parts.push(`entityId=${ctx.entityId}`);
if (ctx.releaseId) parts.push(`sourceReleaseId=${ctx.releaseId}`);
if (ctx.downloadTaskId) parts.push(`downloadTaskId=${ctx.downloadTaskId}`);
if (ctx.recordId) parts.push(`organizationRecordId=${ctx.recordId}`);
if (ctx.siteId) parts.push(`siteId=${ctx.siteId}`);
return parts.length ? `[上下文 ${parts.join(" · ")}] ` : "";
}
+349
View File
@@ -0,0 +1,349 @@
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
type Step = { delayMs: number; body: AgentRunEventBody };
type Entry = {
run: AgentRunView;
steps: Step[];
startedAt: number;
abortedAt: number | null;
appended: RunMessage[];
};
const store = new Map<string, Entry>();
let seeded = false;
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const iso = (ms: number) => new Date(ms).toISOString();
const USAGE: AgentUsageTotals = {
inputTokens: 18_420,
outputTokens: 1_236,
cacheReadTokens: 12_800,
cacheWriteTokens: 2_048,
totalTokens: 19_656,
costUsd: 0.042,
};
function chunks(text: string, size = 6): string[] {
const out: string[] = [];
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
return out;
}
function script(goal: string, mode: AgentMode): Step[] {
const s: Step[] = [];
let t = 0;
const push = (dt: number, body: AgentRunEventBody) => {
t += dt;
s.push({ delayMs: t, body });
};
const say = (text: string) => {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
push(0, { type: "run_started", goal, resumed: false });
say("先确认本地库存与版本目标,再去站点找候选。");
push(400, {
type: "tool_call_start",
toolCallId: "t1",
capabilityId: "inventory.query",
inputSummary: '{"query":"沙丘:第二部","year":2024}',
recovery: false,
});
push(900, {
type: "tool_call_end",
toolCallId: "t1",
capabilityId: "inventory.query",
isError: false,
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
recovery: false,
});
push(300, {
type: "tool_call_start",
toolCallId: "t2",
capabilityId: "release.search",
inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}',
recovery: false,
});
push(1200, {
type: "tool_call_end",
toolCallId: "t2",
capabilityId: "release.search",
isError: false,
outputSummary:
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
recovery: false,
});
push(300, { type: "compaction", phase: "start", reason: "threshold" });
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
if (mode === "execute") {
push(300, {
type: "tool_call_start",
toolCallId: "t3",
capabilityId: "download.plan",
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
recovery: false,
});
push(800, {
type: "tool_call_end",
toolCallId: "t3",
capabilityId: "download.plan",
isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false,
});
push(300, {
type: "tool_call_rejected",
toolCallId: "t4",
toolName: "qb_delete_torrent",
capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
});
push(300, {
type: "tool_call_start",
toolCallId: "t5",
capabilityId: "download.submit",
inputSummary: '{"planId":"…9a2e"}',
recovery: false,
});
push(1000, {
type: "tool_call_end",
toolCallId: "t5",
capabilityId: "download.submit",
isError: false,
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
recovery: false,
});
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
} else {
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
}
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
return {
id,
createdAt: iso(createdAt),
updatedAt: iso(createdAt),
taskId: null,
parentRunId: null,
trigger: "user",
goal,
status,
provider: "anthropic",
model: "claude-sonnet",
thinkingLevel: "medium",
scope: { mode },
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
usage: {},
evidenceVersion: null,
sessionState: null,
summary: null,
toolCallCount: 0,
lastEventSeq: 0,
error: null,
cancelRequestedAt: null,
startedAt: iso(createdAt),
finishedAt: null,
createdBy: "admin",
mode,
dedupeKey: null,
} satisfies AgentRunView;
}
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
const id = uuid(n);
const e: Entry = {
run: baseRun(id, goal, mode, startedAt, "running"),
steps: script(goal, mode),
startedAt,
abortedAt: null,
appended: [],
};
store.set(id, e);
return e;
}
function seed() {
if (seeded) return;
seeded = true;
const now = Date.now();
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
failed.steps = [
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
{
delayMs: 100,
body: {
type: "tool_call_start",
toolCallId: "x1",
capabilityId: "task.inspect",
inputSummary: '{"instance":"qb-main"}',
recovery: false,
},
},
{
delayMs: 200,
body: {
type: "tool_call_end",
toolCallId: "x1",
capabilityId: "task.inspect",
isError: true,
outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)",
recovery: false,
},
},
{
delayMs: 300,
body: {
type: "run_failed",
reason: "budget",
budget: "duration",
message: "超出单次运行时长预算(600 秒)",
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
},
},
];
}
function revealed(e: Entry, now = Date.now()): AgentRunEvent[] {
const cut = e.abortedAt ?? now;
const out: AgentRunEvent[] = [];
let seq = 0;
for (const st of e.steps) {
if (e.startedAt + st.delayMs > cut) break;
out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent);
}
if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) {
out.push({
type: "run_failed",
reason: "aborted",
message: "用户中止",
usage: USAGE,
runId: e.run.id,
seq: seq++,
at: iso(e.abortedAt),
});
}
return out;
}
function view(e: Entry): AgentRunView {
const evs = revealed(e);
const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed");
const tools = evs.filter((x) => x.type === "tool_call_start").length;
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
const status: AgentRunView["status"] = !end
? "running"
: end.type === "run_completed"
? "succeeded"
: end.reason === "budget"
? "budget_exceeded"
: end.reason === "aborted"
? "cancelled"
: "failed";
const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length));
return {
...e.run,
status,
toolCallCount: tools,
lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0,
usage: end
? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt }
: {
inputTokens: Math.round(USAGE.inputTokens * frac),
outputTokens: Math.round(USAGE.outputTokens * frac),
totalTokens: Math.round(USAGE.totalTokens * frac),
costUsd: +(USAGE.costUsd * frac).toFixed(4),
durationMs: Date.now() - e.startedAt,
},
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
finishedAt: end ? end.at : null,
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
};
}
export function MOCK_RUNS(): AgentRunView[] {
seed();
return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot {
seed();
const e = store.get(id);
if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" });
if (op?.abort && !e.abortedAt) e.abortedAt = Date.now();
if (op?.append) {
// 追加消息:模拟 Agent 回一句确认
const at = Date.now() - e.startedAt;
e.appended.push({
id: uuid(9000 + e.appended.length),
createdAt: iso(Date.now()),
text: op.append,
status: "pending",
consumedAt: null,
});
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
delayMs: at + 200 + i * 60,
body: { type: "text_delta", delta: c },
}));
if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply);
e.steps.sort((a, b) => a.delayMs - b.delayMs);
}
const run = view(e);
return { run, messages: e.appended, report: report(e, run) };
}
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
function report(e: Entry, run: AgentRunView): GoalReport | null {
if (!run.finishedAt) return null;
const evs = revealed(e);
const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError);
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
return {
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
summary: submitted
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
: run.status === "succeeded"
? "本次没有产生下载或整理动作"
: "运行未完成,没有产生业务动作",
submitted: submitted ? 1 : 0,
imported: 0,
inProgress: submitted ? 1 : 0,
failed: 0,
plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [],
downloads: submitted
? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }]
: [],
organization: [],
modelText: text || null,
generatedAt: run.finishedAt,
};
}
export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
seed();
const e = store.get(id);
if (!e) return [];
return revealed(e).filter((x) => x.seq > after);
}
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
seed();
const e = add(goal, mode, Date.now(), store.size + 1);
return view(e);
}
@@ -0,0 +1,254 @@
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
import { useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
import { sse } from "~/lib/sse.ts";
// Agent 运行流:先按 seq 回放 agent_run_event,再跟随 SSE(agent.* 事件只作"有新事件"的提示,
// 收到后按 lastSeq 增量补读;thinking/compaction 不上全局总线,补读时一并拿到)。SSE 不可用时退回轮询。
export type ToolCallView = {
toolCallId: string;
capabilityId: string;
inputSummary: string | null;
outputSummary: string | null;
status: "running" | "succeeded" | "failed" | "denied";
reason: string | null;
startedAt: string;
endedAt: string | null;
recovery: boolean;
};
export type StreamItem =
| { kind: "text"; key: string; text: string; at: string }
| { kind: "thinking"; key: string; text: string; at: string }
| { kind: "tool"; key: string; call: ToolCallView }
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
| {
kind: "end";
key: string;
at: string;
ok: boolean;
message: string;
reason: string | null;
budget: string | null;
};
export type AgentStreamState = {
events: AgentRunEvent[];
items: StreamItem[];
usage: AgentUsageTotals | null;
finished: boolean;
error: string | null;
connected: "sse" | "poll" | "mock" | "idle";
};
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
/** 事件 → 展示项:相邻文本增量合并;工具调用按 toolCallId 折叠成一张卡 */
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const items: StreamItem[] = [];
const tools = new Map<string, ToolCallView>();
for (const e of events) {
const last = items[items.length - 1];
switch (e.type) {
case "text_delta":
if (last?.kind === "text") last.text += e.delta;
else items.push({ kind: "text", key: `t${e.seq}`, text: e.delta, at: e.at });
break;
case "thinking_delta":
if (last?.kind === "thinking") last.text += e.delta;
else items.push({ kind: "thinking", key: `th${e.seq}`, text: e.delta, at: e.at });
break;
case "tool_call_start": {
const call: ToolCallView = {
toolCallId: e.toolCallId,
capabilityId: e.capabilityId,
inputSummary: e.inputSummary,
outputSummary: null,
status: "running",
reason: null,
startedAt: e.at,
endedAt: null,
recovery: e.recovery,
};
tools.set(e.toolCallId, call);
items.push({ kind: "tool", key: `c${e.toolCallId}`, call });
break;
}
case "tool_call_end": {
const call = tools.get(e.toolCallId);
if (call) {
call.outputSummary = e.outputSummary;
call.status = e.isError ? "failed" : "succeeded";
call.endedAt = e.at;
} else {
items.push({
kind: "tool",
key: `c${e.toolCallId}`,
call: {
toolCallId: e.toolCallId,
capabilityId: e.capabilityId,
inputSummary: null,
outputSummary: e.outputSummary,
status: e.isError ? "failed" : "succeeded",
reason: null,
startedAt: e.at,
endedAt: e.at,
recovery: e.recovery,
},
});
}
break;
}
case "tool_call_rejected": {
const call = tools.get(e.toolCallId);
if (call) {
call.status = "denied";
call.reason = e.reason;
call.endedAt = e.at;
} else {
items.push({
kind: "tool",
key: `c${e.toolCallId}`,
call: {
toolCallId: e.toolCallId,
capabilityId: e.capabilityId ?? e.toolName,
inputSummary: null,
outputSummary: null,
status: "denied",
reason: e.reason,
startedAt: e.at,
endedAt: e.at,
recovery: false,
},
});
}
break;
}
case "compaction":
if (e.phase === "start")
items.push({ kind: "compaction", key: `k${e.seq}`, at: e.at, reason: e.reason, status: null, done: false });
else {
const open = [...items].reverse().find((i) => i.kind === "compaction" && !i.done);
if (open && open.kind === "compaction") {
open.done = true;
open.status = e.status ?? "completed";
} else
items.push({
kind: "compaction",
key: `k${e.seq}`,
at: e.at,
reason: e.reason,
status: e.status ?? "completed",
done: true,
});
}
break;
case "run_completed":
items.push({
kind: "end",
key: `e${e.seq}`,
at: e.at,
ok: true,
message: e.finalText,
reason: null,
budget: null,
});
break;
case "run_failed":
items.push({
kind: "end",
key: `e${e.seq}`,
at: e.at,
ok: false,
message: e.message,
reason: e.reason,
budget: e.budget ?? null,
});
break;
default:
break;
}
}
return items;
}
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
const [events, setEvents] = useState<AgentRunEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
const lastSeq = useRef(-1);
const inflight = useRef(false);
const pending = useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
useEffect(() => {
setEvents([]);
setError(null);
lastSeq.current = -1;
}, [runId]);
useEffect(() => {
if (!runId) return;
let stopped = false;
const ctrl = new AbortController();
const pull = async () => {
if (inflight.current) {
pending.current = true;
return;
}
inflight.current = true;
try {
do {
pending.current = false;
const next = await fetchRunEvents(runId, lastSeq.current, ctrl.signal);
if (stopped) return;
const fresh = next.filter((e) => e.seq > lastSeq.current).sort((a, b) => a.seq - b.seq);
if (fresh.length) {
lastSeq.current = fresh[fresh.length - 1]!.seq;
setEvents((prev) => [...prev, ...fresh]);
}
setError(null);
} while (pending.current && !stopped);
} catch (err) {
if ((err as Error).name !== "AbortError" && !stopped) setError((err as Error).message);
} finally {
inflight.current = false;
}
};
void pull();
if (!active)
return () => {
stopped = true;
ctrl.abort();
};
const mock = agentMockEnabled();
// 共用页面级 SSE 连接:agent.* 事件只当"有新事件"的提示,按 lastSeq 补读
const unsubscribe = mock
? null
: sse.subscribe(SSE_TYPES, (e) => {
const p = e.payload as { runId?: string } | null;
if (e.type === EventType.resync || p?.runId === runId) void pull();
});
setConnected(mock ? "mock" : typeof EventSource !== "undefined" ? "sse" : "poll");
// mock 模拟流式:密集轮询;真实端点:SSE 为主,2.5 秒轮询兜底
const timer = setInterval(() => void pull(), mock ? 150 : 2500);
return () => {
stopped = true;
ctrl.abort();
clearInterval(timer);
unsubscribe?.();
setConnected("idle");
};
}, [runId, active]);
return useMemo(() => {
const items = foldEvents(events);
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
return { events, items, usage, finished: !!end, error, connected };
}, [events, error, connected]);
}
@@ -0,0 +1,68 @@
import { ExternalLink } from "lucide-react";
import { Link } from "react-router";
import type { TimelineEvent, TimelineStage } from "~/api/tasks.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline } from "~/components/console/timeline.tsx";
// 业务时间线(参考 MovieClaw task-activity):把观察 → 计划 → 下载 → 整理 → Agent 串成一个业务过程,
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
export const STAGE_LABEL: Record<TimelineStage, string> = {
observation: "观察",
plan: "计划",
download: "下载",
organization: "整理",
agent: "Agent",
engine: "引擎",
};
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
export function BusinessTimeline({ events, loading }: { events: TimelineEvent[]; loading?: boolean }) {
const counts = new Map<TimelineStage, number>();
for (const e of events) counts.set(e.stage, (counts.get(e.stage) ?? 0) + 1);
const ongoing = events.filter((e) => e.ongoing);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{ORDER.map((s) => {
const n = counts.get(s) ?? 0;
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
return (
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
</StatusBadge>
);
})}
</div>
{ongoing.length ? (
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
) : null}
{loading && !events.length ? (
<LoadingState rows={3} />
) : (
<Timeline
items={events.map((e) => ({
id: e.key,
at: e.at,
tone: e.tone,
meta: STAGE_LABEL[e.stage],
title: e.href ? (
<Link to={e.href} className="inline-flex items-center gap-1 underline-offset-4 hover:underline">
{e.title}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
e.title
),
description: e.detail ?? undefined,
}))}
/>
)}
{loading && events.length ? (
<p className="text-meta text-muted-foreground">正在补全关联的整理记录与引擎 run…</p>
) : null}
</div>
);
}
@@ -0,0 +1,124 @@
import type { Dashboard } from "@umt/contracts";
import { Brush, HardDrive } from "lucide-react";
import { toast } from "sonner";
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
type Volume = Dashboard["cache"]["volumes"][number];
function VolumeBar({ v }: { v: Volume }) {
const pct = v.usedPercent;
const tripped = pct !== null && pct >= v.hardThresholdPercent;
const nearing = pct !== null && !tripped && pct >= v.resumePercent;
return (
<div className="min-w-0">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
<HardDrive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{v.volumeKey}</span>
</span>
<span className="text-meta text-muted-foreground tabular-nums">
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
{v.usedBytes !== null && v.totalBytes !== null
? ` · ${formatBytes(v.usedBytes, 1)} / ${formatBytes(v.totalBytes, 1)}`
: ""}
</span>
</div>
<div className="relative mt-1.5 h-1.5 rounded-full bg-foreground/[0.08]">
{pct !== null ? (
<div
className={cn(
"h-full rounded-full",
tripped ? "bg-destructive" : nearing ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${Math.min(100, pct)}%` }}
/>
) : null}
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
title={`硬阈值 ${v.hardThresholdPercent}%`}
/>
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-muted-foreground"
style={{ left: `${v.resumePercent}%` }}
title={`恢复水位 ${v.resumePercent}%`}
/>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>
硬阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}%
</span>
<span>在途 {formatBytes(v.pendingBytes, 1)}</span>
<span>预留 {formatBytes(v.reservedBytes, 1)}</span>
<span>可清理 {formatBytes(v.cleanableBytes, 1)}</span>
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态:已保守阻止新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">已达硬阈值:停止新提交</StatusBadge> : null}
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
</div>
{v.notes.map((n) => (
<p key={n} className="mt-1 text-meta text-muted-foreground">
{n}
</p>
))}
</div>
);
}
/** 缓存卷用量 / 阈值条 + 手动清理入口 */
export function CacheVolumes() {
const vols = useCacheVolumes();
const cleanup = useCacheCleanup();
return (
<section className="pg-slab mb-6 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">缓存与保留</h2>
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={cleanup.isPending}>
<Brush />
立即清理
</Button>
}
title="按规则清理下载缓存?"
description="只清理已核验入库、且满足保留期与 H&R 的纳管任务;未纳管任务与未满足保留的不会动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
(r) => {
const vols = (r.report as { volumes?: { removed?: unknown[]; skipped?: unknown[] }[] }).volumes ?? [];
const removed = vols.reduce((n, v) => n + (v.removed?.length ?? 0), 0);
const skipped = vols.reduce((n, v) => n + (v.skipped?.length ?? 0), 0);
toast.success(`清理完成:移除 ${removed} 个任务的缓存${skipped ? `,${skipped} 个因保留/H&R 跳过` : ""}`);
},
(e) => toast.error(errorMessage(e)),
)
}
/>
</div>
{vols.isPending ? (
<LoadingState rows={2} />
) : vols.error ? (
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
) : !vols.data.length ? (
<p className="text-caption text-muted-foreground">还没有下载器缓存卷的统计(未配置下载器或尚未巡检)。</p>
) : (
<div className="grid gap-5 md:grid-cols-2">
{vols.data.map((v) => (
<VolumeBar key={v.volumeKey} v={v} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,202 @@
import type { DownloadTask } from "@umt/contracts";
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
DOWNLOAD_STATUS_LABEL,
DOWNLOAD_STATUS_TONE,
PAUSE_REASON_LABEL,
PLAN_STATUS_LABEL,
useDownloadAction,
useDownloaderNames,
usePlan,
} from "~/api/downloads.ts";
import { useDownloadTrace } from "~/api/tasks.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
import { OrganizeDialog } from "./organize-dialog.tsx";
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
export function ProgressLine({ t }: { t: DownloadTask }) {
const known = t.sizeBytes !== null;
return (
<div className="min-w-24">
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${Math.round(t.progress * 100)}%` }} />
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{known ? `${(t.progress * 100).toFixed(1)}%` : `已下 ${formatBytes(t.downloadedBytes, 1)}`}
</p>
</div>
);
}
export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
return (
<span className="flex flex-wrap gap-1">
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
{t.pauseReason === "threshold" ? (
<StatusBadge tone="warning">阈值暂停</StatusBadge>
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
{!t.managed ? <StatusBadge>未纳管</StatusBadge> : null}
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
</span>
);
}
/** 暂停/恢复:阈值暂停在水位回落前不可手动恢复(服务端强制) */
export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | "xs" }) {
const act = useDownloadAction();
const canPause = ["queued", "downloading", "stalled"].includes(t.status);
const canResume = t.status === "paused";
if (!canPause && !canResume) return null;
const threshold = t.pauseReason === "threshold";
return (
<Button
variant="outline"
size={size}
disabled={act.isPending}
title={threshold ? "阈值暂停:缓存回落到恢复水位后由系统自动恢复" : undefined}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
act.mutate(
{ id: t.id, action: canResume ? "resume" : "pause" },
{
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停(记为用户暂停,阈值恢复不会自动恢复它)"),
onError: (err) => toast.error(errorMessage(err)),
},
);
}}
>
{canResume ? <Play /> : <Pause />}
{canResume ? "恢复" : "暂停"}
</Button>
);
}
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
export function DownloadDetail({ task }: { task: DownloadTask }) {
const plan = usePlan(task.acquisitionPlanId);
const names = useDownloaderNames();
const trace = useDownloadTrace(task, plan.data);
const [organizing, setOrganizing] = useState(false);
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<DownloadStatusBadges t={task} />
<div className="ml-auto flex flex-wrap gap-2">
<PauseResume t={task} />
<Button
variant="outline"
size="sm"
disabled={!canOrganize}
title={canOrganize ? undefined : "下载完成后才能整理"}
onClick={() => setOrganizing(true)}
>
<FolderInput />
整理
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
openAgentDrawer({
label: `下载:${task.name}`,
downloadTaskId: task.id,
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
})
}
>
<Bot />问 Agent
</Button>
</div>
</div>
{task.pauseReason === "threshold" ? (
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
缓存卷达到硬阈值,系统暂停了这个纳管任务。水位回落到恢复线后自动恢复;手动恢复、优先级或 Agent
推荐都不能绕过阈值。
</p>
) : null}
{task.noProgressSince ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展。系统只记录证据并汇入待处理,不会自动换源。
</p>
) : null}
<DetailSection title="进度">
<ProgressLine t={task} />
<KvList
className="mt-3"
items={[
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
{ label: "最近进展", value: relTime(task.lastProgressAt) },
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
{ label: "标签", value: task.tags.join(", ") },
{ label: "分类", value: task.category },
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
]}
/>
</DetailSection>
<DetailSection title="业务时间线">
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
{plan.data ? (
<DetailSection title="下载计划">
<KvList
items={[
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
{ label: "用途", value: plan.data.purpose },
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
{ label: "说明", value: plan.data.statusReason, wide: true },
]}
/>
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
</DetailSection>
) : null}
{task.fileList?.length ? (
<DetailSection title={`文件(${task.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
{task.fileList.map((f) => (
<li key={f.path} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{formatBytes(f.size, 1)}
{f.progress !== undefined ? ` · ${Math.round(f.progress * 100)}%` : ""}
</span>
</li>
))}
</ul>
</DetailSection>
) : null}
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
{organizing ? (
<OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} downloadTaskId={task.id} />
) : null}
</div>
);
}
@@ -0,0 +1,330 @@
import { OrganizeRequest } from "@umt/contracts";
import { Eye, Play } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { isMissingEndpoint } from "~/api/parse.ts";
import { OrganizePlanPreview, useArtifact, useEntitySearch, useLibraryRoots, useOrganize } from "~/api/records.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
const AUTO = "__auto__";
const EXCLUDE_REASON: Record<string, string> = {
sample: "样片",
too_small: "体积过小",
not_imported_kind: "不入库的文件类型",
orphan_sidecar: "孤立附属文件",
no_episode: "解析不出集号",
special_extra: "特典/花絮",
extra_content: "额外内容",
no_target_dir: "没有目标目录",
naming_failed: "命名失败",
};
function PlanPreview({ planId }: { planId: string }) {
const art = useArtifact(planId);
if (art.isPending) return <LoadingState rows={3} />;
if (art.error) {
return (
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
计划已生成(planId {planId.slice(0, 8)}…),
{isMissingEndpoint(art.error)
? "但后端还没有提供读取计划内容的端点(GET /artifacts/{id}),无法逐文件预览。"
: `读取计划失败:${errorMessage(art.error)}`}
</p>
);
}
const parsed = OrganizePlanPreview.safeParse(art.data.content);
if (!parsed.success) return <p className="text-caption text-muted-foreground">计划内容格式无法识别。</p>;
const p = parsed.data;
const org = p.plan?.organize;
if (!org)
return (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
无法生成计划:{p.reason ?? p.status ?? "未知原因"}
</p>
);
const blocking = org.conflicts.filter((c) => c.blocking);
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{org.operation}</StatusBadge>
{org.crossDevice ? <StatusBadge tone="warning">跨卷:Copy → 核验 → 删源</StatusBadge> : null}
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
</div>
<ul className="max-h-56 space-y-1.5 overflow-auto text-meta">
{org.files.map((f) => (
<li key={f.src} className="rounded-lg border border-border/60 px-2.5 py-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
{f.action}
</StatusBadge>
<span className="text-muted-foreground">{f.kind}</span>
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
</div>
<p className="mt-0.5 font-mono break-all text-muted-foreground">{f.src}</p>
<p className="font-mono break-all text-foreground">→ {f.dst}</p>
</li>
))}
</ul>
{org.excluded.length ? (
<p className="text-meta text-muted-foreground">
排除 {org.excluded.length} 个:
{org.excluded
.slice(0, 5)
.map((e) => `${e.src.split("/").pop()}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
.join(",")}
{org.excluded.length > 5 ? "…" : ""}
</p>
) : null}
{org.conflicts.map((c) => (
<p
key={`${c.kind}${c.src ?? ""}`}
className={cn("text-meta", c.blocking ? "text-destructive" : "text-warning")}
>
{c.blocking ? "阻塞" : "提示"}:{c.kind}
{c.detail ? ` · ${c.detail}` : ""}
</p>
))}
</div>
);
}
/**
* 手动发起整理:先 dryRun 预览计划,再执行。自动与手动产生同格式整理记录。
*/
export function OrganizeDialog({
open,
onOpenChange,
downloadTaskId,
defaultPaths,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
downloadTaskId?: string;
defaultPaths?: string[];
}) {
const navigate = useNavigate();
const organize = useOrganize();
const roots = useLibraryRoots();
const [paths, setPaths] = useState((defaultPaths ?? []).join("\n"));
const [action, setAction] = useState<"copy" | "move">("copy");
const [root, setRoot] = useState("");
const [entityQ, setEntityQ] = useState("");
const [entity, setEntity] = useState<{ id: string; title: string } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [planId, setPlanId] = useState<string | null>(null);
const search = useEntitySearch(entity ? "" : entityQ);
const body = (dryRun: boolean) => {
const sourcePaths = paths
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
return OrganizeRequest.safeParse({
...(downloadTaskId ? { downloadTaskId } : { sourcePaths }),
...(entity ? { mediaEntityId: entity.id } : {}),
...(root ? { targetLibraryRootId: root } : {}),
action,
dryRun,
});
};
const run = (dryRun: boolean) => {
const parsed = body(dryRun);
if (!parsed.success) {
const e = zodFieldErrors(parsed.error.issues);
const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths"));
setErrors({
...e,
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径(每行一个绝对路径)" : "",
});
return;
}
setErrors({});
organize.mutate(parsed.data, {
onSuccess: (r) => {
if (dryRun) setPlanId(r.planId);
else {
toast.success("已发起整理");
onOpenChange(false);
if (r.recordId) navigate(`/console/organize/${r.recordId}`);
}
},
onError: (e) => toast.error(errorMessage(e)),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>手动整理</DialogTitle>
<DialogDescription>
先预览计划(不动文件),确认后再执行。Move 跨卷时按 Copy → 核验 → 删源执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-1">
{downloadTaskId ? (
<p className="mb-2 text-caption text-muted-foreground">
源:下载任务 {downloadTaskId.slice(0, 8)}… 的内容路径
</p>
) : (
<FormField
label="源路径"
htmlFor="org-paths"
required
error={errors.paths}
hint="执行进程可访问的绝对路径,每行一个(文件或目录)"
>
<Textarea
id="org-paths"
rows={3}
className="font-mono text-caption"
value={paths}
aria-invalid={!!errors.paths}
onChange={(e) => {
setPaths(e.target.value);
setPlanId(null);
}}
/>
</FormField>
)}
<FormField label="作品" htmlFor="org-entity" hint={entity ? undefined : "可选;不选时按文件名识别"}>
{entity ? (
<div className="flex items-center gap-2">
<StatusBadge>{entity.title}</StatusBadge>
<Button
variant="ghost"
size="xs"
onClick={() => {
setEntity(null);
setPlanId(null);
}}
>
更换
</Button>
</div>
) : (
<div className="relative">
<Input
id="org-entity"
value={entityQ}
placeholder="搜索本地作品"
onChange={(e) => setEntityQ(e.target.value)}
/>
{search.data?.items.length ? (
<ul className="pg-float absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-auto rounded-xl p-1">
{search.data.items.map((it) => (
<li key={it.entity.id}>
<button
type="button"
className="w-full cursor-pointer rounded-lg px-2.5 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
onClick={() => {
setEntity({
id: it.entity.id,
title: `${it.entity.title}${it.entity.year ? ` (${it.entity.year})` : ""}`,
});
setEntityQ("");
setPlanId(null);
}}
>
{it.entity.title}
{it.entity.year ? ` (${it.entity.year})` : ""}
</button>
</li>
))}
</ul>
) : null}
</div>
)}
</FormField>
<div className="grid gap-x-3 sm:grid-cols-2">
<FormField
label="动作"
htmlFor="org-action"
hint={action === "move" ? "Move 需要对应授权" : "保留源文件(做种不受影响)"}
>
<Select
value={action}
onValueChange={(v) => {
setAction(v as "copy" | "move");
setPlanId(null);
}}
>
<SelectTrigger id="org-action" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="copy">Copy</SelectItem>
<SelectItem value="move">Move</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="目标库根" htmlFor="org-root">
<Select
value={root || AUTO}
onValueChange={(v) => {
setRoot(v === AUTO ? "" : v);
setPlanId(null);
}}
>
<SelectTrigger id="org-root" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={AUTO}>按作品类型自动</SelectItem>
{(roots.data?.items ?? []).map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{planId ? (
<div className="mt-1">
<p className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
计划预览
</p>
<PlanPreview planId={planId} />
</div>
) : null}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => run(true)} disabled={organize.isPending}>
<Eye />
预览计划
</Button>
<Button
onClick={() => run(false)}
disabled={organize.isPending || !planId}
title={planId ? undefined : "先预览计划"}
>
<Play />
执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,143 @@
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { isMissingEndpoint } from "~/api/parse.ts";
import { useSubmitRelease } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
const STEPS = [
"生成下载计划并预留 Slot 与预算(同一 Slot 同时只有一个在途计划)",
"去重:已有相同资源或同 hash 任务时走接管,不重复提交",
"缓存卷硬阈值检查:超过阈值或读不到磁盘状态时阻止新提交,不能手动绕过",
"选择下载器实例并打管理标签提交;下单前复核免费/做种等易变字段",
];
/**
* 统一下载入口:与自动收录走同一提交流程,不在前端直接调下载器。
*/
export function DownloadDialog({
release,
slots,
open,
onOpenChange,
}: {
release: WorkbenchRelease;
slots: AcquisitionSlot[];
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const submit = useSubmitRelease();
const [slotId, setSlotId] = useState("");
const [missing, setMissing] = useState(false);
const [done, setDone] = useState<string | null>(null);
const go = () =>
submit.mutate(
{ sourceReleaseId: release.id, ...(slotId ? { slotId } : {}) },
{
onSuccess: (plan) => {
setDone(plan.id);
toast.success(`已生成下载计划(${plan.status}),进度见“下载与任务”`);
},
onError: (e) => {
if (isMissingEndpoint(e)) setMissing(true);
else toast.error(errorMessage(e));
},
},
);
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) {
setMissing(false);
setDone(null);
}
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.title}
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
{STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
{slots.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="版本目标"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slots.map((s) => ({ value: s.id, label: `${s.name}(${s.status})` }))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">
{release.entity
? "该作品还没有版本目标,提交时按默认规则建临时目标。"
: "资源尚未识别到本地作品,提交后先进入识别。"}
</p>
)}
{missing ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
后端还没有开放手动提交端点(POST /acquisition-plans)。可以委托 Agent 用 download.plan / download.submit
能力走同一流程提交。
</div>
) : null}
{done ? (
<p className="text-caption text-success">
计划已生成。
<Link to="/console/tasks" className="underline underline-offset-4">
去“下载与任务”查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
openAgentDrawer({
label: `资源:${release.title}`,
releaseId: release.id,
entityId: release.entity?.id,
siteId: release.siteId,
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
});
}}
>
<Bot />
委托 Agent
</Button>
<Button onClick={go} disabled={submit.isPending || !!done}>
<Download />
{submit.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,266 @@
import {
type FeedPurpose,
type FeedSource,
FeedSourceCreate,
FeedSourceUpdate,
type Site,
type SiteAccount,
} from "@umt/contracts";
import { useState } from "react";
import { toast } from "sonner";
import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
export const PURPOSE_LABEL: Record<FeedPurpose, string> = {
movie: "电影",
series: "剧集",
variety: "综艺",
promotion: "促销",
search: "搜索",
generic: "通用",
};
const NONE = "__none__";
type Draft = {
name: string;
url: string;
purpose: FeedPurpose;
siteId: string;
siteAccountId: string;
pollMin: string;
filter: string;
enabled: boolean;
};
function initial(feed: FeedSource | null): Draft {
return {
name: feed?.name ?? "",
url: "",
purpose: feed?.purpose ?? "generic",
siteId: feed?.siteId ?? "",
siteAccountId: feed?.siteAccountId ?? "",
pollMin: String(Math.round((feed?.pollIntervalSec ?? 900) / 60)),
filter: feed && Object.keys(feed.filter).length ? JSON.stringify(feed.filter, null, 2) : "",
enabled: feed?.enabled ?? true,
};
}
/** 新建 / 编辑 Feed:URL 只写(含 passkey),编辑时留空表示不变 */
export function FeedFormDialog({
open,
onOpenChange,
feed,
sites,
accounts,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
feed: FeedSource | null;
sites: Site[];
accounts: SiteAccount[];
}) {
const [d, setD] = useState<Draft>(() => initial(feed));
const [errors, setErrors] = useState<Record<string, string>>({});
const create = useCreateFeed();
const update = useUpdateFeed();
const pending = create.isPending || update.isPending;
const siteAccounts = accounts.filter((a) => a.siteId === d.siteId);
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
const submit = () => {
let filter: Record<string, unknown> | undefined;
if (d.filter.trim()) {
try {
const parsed: unknown = JSON.parse(d.filter);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not object");
filter = parsed as Record<string, unknown>;
} catch {
setErrors({ filter: "过滤条件必须是 JSON 对象" });
return;
}
}
const raw = {
name: d.name.trim(),
purpose: d.purpose,
siteId: d.siteId || null,
siteAccountId: d.siteAccountId || null,
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
enabled: d.enabled,
...(filter !== undefined ? { filter } : feed ? { filter: {} } : {}),
...(d.url.trim() ? { url: d.url.trim() } : {}),
};
const parsed = feed ? FeedSourceUpdate.safeParse(raw) : FeedSourceCreate.safeParse(raw);
if (!parsed.success) {
const e = zodFieldErrors(parsed.error.issues);
if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟";
if (e.url) e.url = feed ? "URL 格式不对" : "需要完整的 Feed URL";
if (e.name) e.name = "名称不能为空";
setErrors(e);
return;
}
setErrors({});
const done = {
onSuccess: () => {
toast.success(feed ? "已保存" : "已新建 Feed,会按轮询策略抓取");
onOpenChange(false);
},
onError: (e: unknown) => toast.error(errorMessage(e)),
};
if (feed) update.mutate({ id: feed.id, body: parsed.data as FeedSourceUpdate }, done);
else create.mutate(parsed.data as FeedSourceCreate, done);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{feed ? "编辑 Feed" : "新建 Feed"}</DialogTitle>
<DialogDescription>URL 可能含 passkey,只写入加密凭证,之后只显示脱敏地址。</DialogDescription>
</DialogHeader>
<form
className="grid gap-1"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<FormField label="名称" htmlFor="feed-name" required error={errors.name}>
<Input
id="feed-name"
value={d.name}
aria-invalid={!!errors.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<FormField
label="URL"
htmlFor="feed-url"
required={!feed}
error={errors.url}
hint={feed ? `当前:${feed.urlDisplay};留空表示不变` : "RSS / Atom 地址"}
>
<Input
id="feed-url"
type="url"
autoComplete="off"
value={d.url}
aria-invalid={!!errors.url}
placeholder="https://…"
onChange={(e) => set("url", e.target.value)}
/>
</FormField>
<div className="grid gap-x-3 sm:grid-cols-2">
<FormField label="用途" htmlFor="feed-purpose">
<Select value={d.purpose} onValueChange={(v) => set("purpose", v as FeedPurpose)}>
<SelectTrigger id="feed-purpose" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{Object.entries(PURPOSE_LABEL).map(([v, l]) => (
<SelectItem key={v} value={v}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="轮询间隔(分钟)" htmlFor="feed-poll" error={errors.pollMin}>
<Input
id="feed-poll"
type="number"
min={1}
inputMode="numeric"
value={d.pollMin}
aria-invalid={!!errors.pollMin}
onChange={(e) => set("pollMin", e.target.value)}
/>
</FormField>
<FormField label="站点" htmlFor="feed-site">
<Select
value={d.siteId || NONE}
onValueChange={(v) => setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }))}
>
<SelectTrigger id="feed-site" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={NONE}>不关联站点</SelectItem>
{sites.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="站点账号"
htmlFor="feed-account"
hint={d.siteId && !siteAccounts.length ? "该站点没有账号" : undefined}
>
<Select
value={d.siteAccountId || NONE}
disabled={!d.siteId}
onValueChange={(v) => set("siteAccountId", v === NONE ? "" : v)}
>
<SelectTrigger id="feed-account" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={NONE}>不指定</SelectItem>
{siteAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<FormField
label="过滤条件(JSON,可选)"
htmlFor="feed-filter"
error={errors.filter}
hint='如 {"include":["2160p"],"exclude":["CAM"]}'
>
<Textarea
id="feed-filter"
rows={3}
className="font-mono text-caption"
value={d.filter}
aria-invalid={!!errors.filter}
onChange={(e) => set("filter", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 py-1 text-caption text-foreground">
<label htmlFor="feed-enabled">启用(关闭后停止轮询,历史观察保留)</label>
<Switch id="feed-enabled" checked={d.enabled} onCheckedChange={(v) => set("enabled", v)} />
</div>
<DialogFooter className="mt-2 gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={pending}>
{pending ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,273 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { Bot, Download, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useEntityDetail } from "~/api/inventory.ts";
import { useRefreshReleaseDetail, useReleaseDetail } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { DownloadDialog } from "./download-dialog.tsx";
import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts";
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
const DECISION_LABEL = {
accept: { label: "符合,可收录", tone: "success" },
upgrade: { label: "可升级", tone: "success" },
reject: { label: "不收录", tone: "destructive" },
unknown_field: { label: "缺字段,待复核", tone: "warning" },
skipped: { label: "被跳过", tone: "neutral" },
} as const;
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: InventoryEntry | undefined }) {
const detail = useEntityDetail(release.entity?.id);
if (!release.entity) {
return (
<p className="text-caption text-muted-foreground">
尚未识别到本地作品({release.resolutionStatus}),不显示库存推断。
<Link to="/console/identity" className="ml-1 underline underline-offset-4">
去身份与元数据处理
</Link>
</p>
);
}
const candidates = detail.data?.candidates.filter((c) => c.sourceReleaseId === release.id) ?? [];
const slots = detail.data?.slots ?? [];
return (
<div className="space-y-3">
<InventoryOverlay release={release} entry={entry} loading={false} />
{entry?.versions.length ? (
<ul className="space-y-1 text-caption">
{entry.versions.map((v) => (
<li key={v.assetId} className="flex flex-wrap items-center gap-2">
<StatusBadge tone={v.available ? "success" : "warning"}>{v.available ? "可用" : "不可用"}</StatusBadge>
<span className="text-foreground">
{[v.resolution, v.sourceType, v.releaseGroup, v.contentEdition].filter(Boolean).join(" · ") ||
"版本特征未知"}
</span>
{v.sizeBytes ? <span className="text-muted-foreground">{formatBytes(v.sizeBytes, 1)}</span> : null}
</li>
))}
</ul>
) : null}
{detail.isPending ? (
<LoadingState rows={2} />
) : detail.error ? (
<p className="text-meta text-destructive">作品详情读取失败:{errorMessage(detail.error)}</p>
) : (
<>
<p className="text-meta text-muted-foreground">
{slots.length ? `${slots.length} 个版本目标(已订阅)` : "没有版本目标(未订阅)"}
</p>
{candidates.length ? (
<ul className="space-y-2">
{candidates.map((c) => {
const d = DECISION_LABEL[c.decision];
const slot = slots.find((s) => s.id === c.slotId);
return (
<li key={`${c.slotId}-${c.evaluatedAt}`} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge tone={d.tone}>{d.label}</StatusBadge>
<span className="text-foreground">{slot ? `目标 ${slot.name}` : "版本目标"}</span>
<span className="text-muted-foreground">{relTime(c.evaluatedAt)}评估</span>
</div>
{c.reasons.length ? (
<ul className="mt-1 list-disc pl-5 text-meta text-muted-foreground">
{c.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
{c.rejected.length ? (
<ul className="mt-1 list-disc pl-5 text-meta text-destructive">
{c.rejected.map((r) => (
<li key={r.code}>{r.message}</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : (
<p className="text-meta text-muted-foreground">规则引擎还没评估过这条资源。</p>
)}
</>
)}
</div>
);
}
export function ReleaseDetailSheet({
siteId,
release,
entry,
onClose,
}: {
siteId: string;
release: WorkbenchRelease | null;
entry: InventoryEntry | undefined;
onClose: () => void;
}) {
const detail = useReleaseDetail(siteId, release?.id);
const refresh = useRefreshReleaseDetail();
const entity = useEntityDetail(release?.entity?.id);
const [dl, setDl] = useState(false);
const r = detail.data?.release ?? release;
return (
<DetailSheet
open={!!release}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r?.title ?? ""}
description={r?.subtitle ?? undefined}
footer={
r ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
openAgentDrawer({
label: `资源:${r.title}`,
releaseId: r.id,
entityId: r.entity?.id,
siteId,
})
}
>
<Bot />问 Agent
</Button>
<Button size="sm" onClick={() => setDl(true)}>
<Download />
下载
</Button>
</>
) : null
}
>
{!r ? null : (
<>
<div className="flex flex-wrap gap-1.5">
<ReleaseBadges r={r} />
<StatusBadge>{ORIGIN_LABEL[r.origin]}</StatusBadge>
</div>
<DetailSection
title="易变字段复核"
actions={
<Button
variant="outline"
size="xs"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(
{ siteId, rid: r.id },
{
onSuccess: (d) =>
d.refreshError
? toast.warning(`复核失败,保留既有数据:${d.refreshError}`)
: toast.success("已从站点复核详情"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
复核详情
</Button>
}
>
{detail.isPending ? (
<LoadingState rows={2} />
) : detail.error ? (
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
) : detail.data ? (
<>
{detail.data.refreshError ? (
<p className="mb-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
最近一次复核失败,以下为既有数据(已过期):{detail.data.refreshError}
</p>
) : null}
<KvList
items={[
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
{
label: "做种 / 下载",
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
},
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
]}
/>
<p className="mt-2 text-meta text-muted-foreground">
缓存命中不代替下单前复核:提交下载时会再次核对免费与做种状态。
</p>
</>
) : null}
</DetailSection>
<DetailSection title="本地库存与候选理由">
<LocalInventory release={r} entry={entry} />
</DetailSection>
<DetailSection title="资源信息">
<KvList
items={[
{ label: "分类", value: categoryLabel(r) },
{ label: "大小", value: r.sizeBytes === null ? "未知" : formatBytes(r.sizeBytes, 2) },
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
{ label: "Feed 观察次数", value: r.observedByFeeds },
{ label: "站点资源 ID", value: r.siteReleaseId, mono: true },
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
]}
/>
</DetailSection>
{detail.data?.observations.length ? (
<DetailSection title="Feed 观察">
<ul className="space-y-1 text-caption">
{detail.data.observations.map((o) => (
<li key={o.observationId} className="flex flex-wrap justify-between gap-2">
<span className="text-foreground">{o.feedName}</span>
<span className="text-muted-foreground">
{formatDateTime(o.firstSeenAt)} → {relTime(o.lastSeenAt)}
</span>
</li>
))}
</ul>
</DetailSection>
) : null}
{r.fileList?.length ? (
<DetailSection title={`文件(${r.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
{r.fileList.map((f) => (
<li key={f.path} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
</li>
))}
</ul>
</DetailSection>
) : null}
<JsonView label="解析结果" value={r.parsed} />
</>
)}
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
</DetailSheet>
);
}
@@ -0,0 +1,95 @@
import type { Site, SiteAccount, SiteHealth, SourceRelease, WorkbenchRelease } from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// 站点资源字段的展示口径:缺失字段按"未知"显示,不当成 0 / 否。
type SiteExtra = { hr?: unknown; categoryName?: unknown; snatched?: unknown; detailUrl?: unknown };
export function siteExtra(r: Pick<SourceRelease, "raw">): SiteExtra {
const s = (r.raw as { _site?: SiteExtra })._site;
return s && typeof s === "object" ? s : {};
}
/** H&R 三态 */
export function hrOf(r: Pick<SourceRelease, "raw">): "yes" | "no" | "unknown" {
const hr = siteExtra(r).hr;
return hr === true ? "yes" : hr === false ? "no" : "unknown";
}
export function categoryLabel(r: Pick<SourceRelease, "raw" | "categoryRaw">): string | null {
const name = siteExtra(r).categoryName;
return typeof name === "string" && name ? name : r.categoryRaw;
}
const PROMO_LABEL: Record<string, string> = {
free: "免费",
"2xfree": "2x 免费",
"50%": "50%",
"2x50%": "2x 50%",
"30%": "30%",
"2x": "2x 上传",
};
/** 促销标签;none / unknown 返回 null */
export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionExpiresAt">, now = Date.now()) {
const kind = r.promotion.kind;
if (!kind || kind === "none" || kind === "unknown") return null;
const expired = r.promotionExpiresAt ? new Date(r.promotionExpiresAt).getTime() < now : false;
return { label: PROMO_LABEL[kind] ?? kind, free: kind === "free" || kind === "2xfree", expired };
}
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
/** 相对时间(中文);未来时间显示"x 后" */
export function relTime(iso: string | null | undefined, now = Date.now()): string {
if (!iso) return "—";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const diff = now - t;
const abs = Math.abs(diff);
const suffix = diff >= 0 ? "前" : "后";
if (abs < 60_000) return diff >= 0 ? "刚刚" : "即将";
if (abs < 3_600_000) return `${Math.round(abs / 60_000)} 分钟${suffix}`;
if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)} 小时${suffix}`;
if (abs < 30 * 86_400_000) return `${Math.round(abs / 86_400_000)} 天${suffix}`;
return new Date(iso).toLocaleDateString("zh-CN");
}
export const ORIGIN_LABEL: Record<WorkbenchRelease["origin"], string> = {
snapshot: "快照",
feed: "Feed",
live: "实时补查",
unknown: "来源未知",
};
export const SITE_HEALTH: Record<SiteHealth, { label: string; tone: StatusTone }> = {
unknown: { label: "健康未知", tone: "neutral" },
healthy: { label: "健康", tone: "success" },
degraded: { label: "降级", tone: "warning" },
failing: { label: "故障", tone: "destructive" },
disabled: { label: "已停用", tone: "neutral" },
};
export const ACCOUNT_STATUS: Record<SiteAccount["status"], { label: string; tone: StatusTone }> = {
unknown: { label: "未验证", tone: "neutral" },
active: { label: "有效", tone: "success" },
invalid: { label: "凭证无效", tone: "destructive" },
expired: { label: "已过期", tone: "warning" },
};
/** 站点下的账号总体状态:有效优先,其次最差的那个 */
export function accountSummary(accounts: SiteAccount[]): { label: string; tone: StatusTone } {
const enabled = accounts.filter((a) => a.enabled);
if (!enabled.length) return { label: accounts.length ? "账号已停用" : "未配置账号", tone: "neutral" };
if (enabled.some((a) => a.status === "active")) return ACCOUNT_STATUS.active;
for (const s of ["invalid", "expired", "unknown"] as const)
if (enabled.some((a) => a.status === s)) return ACCOUNT_STATUS[s];
return ACCOUNT_STATUS.unknown;
}
export function circuitLabel(site: Pick<Site, "circuitState" | "circuitOpenUntil">): string | null {
if (site.circuitState === "closed") return null;
if (site.circuitState === "half_open") return "熔断半开(试探中)";
return `熔断中${site.circuitOpenUntil ? `,${relTime(site.circuitOpenUntil)}恢复` : ""}`;
}
@@ -0,0 +1,123 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
import { inventoryFlags } from "~/api/inventory.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
/** 促销 / H&R 徽章(列表与详情共用) */
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
const promo = promotionLabel(r);
const hr = hrOf(r);
return (
<>
{promo ? (
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
{promo.label}
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
</StatusBadge>
) : null}
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</>
);
}
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
export function InventoryOverlay({
release,
entry,
loading,
}: {
release: WorkbenchRelease;
entry: InventoryEntry | undefined;
loading: boolean;
}) {
if (!release.entity) {
const label =
release.resolutionStatus === "ambiguous"
? "识别有歧义"
: release.resolutionStatus === "unidentifiable"
? "无法识别"
: release.resolutionStatus === "rejected"
? "已排除"
: "未识别到本地作品";
return <span className="text-meta text-muted-foreground">{label}</span>;
}
const flags = inventoryFlags(entry, release.resolutionStatus);
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-meta text-foreground">
{release.entity.title}
{release.entity.year ? ` (${release.entity.year})` : ""}
</span>
{loading ? <span className="text-meta text-muted-foreground">库存加载中…</span> : null}
{!loading && entry && flags.length === 0 ? <StatusBadge>本地无版本</StatusBadge> : null}
{flags.map((f) => (
<StatusBadge key={f.key} tone={f.tone}>
{f.label}
</StatusBadge>
))}
</span>
);
}
export function ReleaseRow({
r,
entry,
inventoryLoading,
staleBefore,
selected,
onOpen,
}: {
r: WorkbenchRelease;
entry: InventoryEntry | undefined;
inventoryLoading: boolean;
/** 早于此时间确认的数据标"过期" */
staleBefore: number | null;
selected: boolean;
onOpen: () => void;
}) {
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
const cat = categoryLabel(r);
return (
<li>
<button
type="button"
onClick={onOpen}
aria-current={selected ? "true" : undefined}
className={cn(
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
selected && "bg-foreground/[0.05]",
)}
>
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
<span className="flex flex-wrap gap-1">
<ReleaseBadges r={r} />
</span>
</div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
{cat ? <span>{cat}</span> : null}
<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 ?? "?"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "?"}
</span>
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
<Clock className="h-3 w-3" />
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
</span>
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
</div>
<div className="mt-1.5">
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
</div>
</button>
</li>
);
}
@@ -0,0 +1,111 @@
import type { Site, SiteSnapshot } from "@umt/contracts";
import { Camera } from "lucide-react";
import { toast } from "sonner";
import { snapshotStale, useSiteSnapshots, useSnapshotSite } from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { relTime, SITE_HEALTH } from "./release-fields.ts";
const KIND: Record<SiteSnapshot["kind"], string> = {
periodic: "定期快照",
backfill: "缺口补漏",
live_search: "实时补查",
manual: "手动快照",
};
const STATUS: Record<SiteSnapshot["status"], { label: string; tone: StatusTone }> = {
running: { label: "进行中", tone: "neutral" },
ok: { label: "完成", tone: "success" },
partial: { label: "部分完成", tone: "warning" },
failed: { label: "失败", tone: "destructive" },
};
function duration(s: SiteSnapshot) {
if (!s.finishedAt) return null;
const ms = new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime();
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
}
/** 单个站点的快照状态 + 最近记录 + 手动快照 */
export function SnapshotPanel({ site }: { site: Site }) {
const snaps = useSiteSnapshots(site.id, 8);
const snap = useSnapshotSite();
const health = SITE_HEALTH[site.healthStatus];
const stale = site.snapshotEnabled && snapshotStale(site);
return (
<div className="pg-slab min-w-0 rounded-xl p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-lead font-semibold text-foreground">{site.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
{!site.enabled ? <StatusBadge tone="warning">站点已停用</StatusBadge> : null}
{site.snapshotEnabled ? (
<StatusBadge tone={stale ? "warning" : "success"}>
{stale ? "快照过期" : "快照新鲜"} · {relTime(site.lastSnapshotAt)}
</StatusBadge>
) : (
<StatusBadge>未开启定期快照</StatusBadge>
)}
{site.snapshotIntervalSec ? (
<span className="text-meta text-muted-foreground">
每 {Math.round(site.snapshotIntervalSec / 60)} 分钟
</span>
) : null}
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={!site.enabled || snap.isPending}
onClick={() =>
snap.mutate(
{ id: site.id, maxPages: 2 },
{
onSuccess: () => toast.success(`${site.name}:已排队手动快照`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
手动快照
</Button>
</div>
<div className="mt-3">
{snaps.isPending ? (
<LoadingState rows={2} />
) : snaps.error ? (
<ErrorState plain error={snaps.error} onRetry={() => void snaps.refetch()} />
) : !snaps.data.items.length ? (
<p className="text-caption text-muted-foreground">还没有快照记录。</p>
) : (
<ul className="divide-y divide-border/60 text-caption">
{snaps.data.items.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
<span className="text-foreground">{KIND[s.kind]}</span>
{s.query ? <span className="min-w-0 truncate text-muted-foreground">“{s.query}”</span> : null}
<span className="text-muted-foreground tabular-nums">
{s.pages.length ? `第 ${s.pages.join(",")} 页 · ` : ""}
{s.itemCount} 条(新 {s.newCount})
</span>
<span className="ml-auto text-meta text-muted-foreground" title={formatDateTime(s.startedAt)}>
{relTime(s.startedAt)}
{duration(s) ? ` · ${duration(s)}` : ""}
</span>
{s.error ? (
<span className="w-full text-meta break-words text-destructive">
{String((s.error as { message?: unknown }).message ?? JSON.stringify(s.error))}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
);
}