feat(web): W4-A wire console to real endpoints
- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
34e8751b45
commit
1023642ef9
30 files changed
+703
-271
No files matched your search
@@ -372,6 +372,9 @@ export function FinalReport({
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
|
||||
/** 末尾标记离视口底部不超过这么多像素算"停在底部" */
|
||||
const FOLLOW_SLACK_PX = 160;
|
||||
|
||||
export function AgentConversation({
|
||||
run,
|
||||
live,
|
||||
@@ -386,9 +389,20 @@ export function AgentConversation({
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const active = isRunActive(run.status) && !live.finished;
|
||||
const count = live.items.length;
|
||||
// 有新内容时跟随到底,但只在用户停在底部附近时跟随:往上翻(去点"中止"、看早先的工具卡)后不再把视口拽回去,
|
||||
// 翻回底部附近自动恢复。捕获阶段监听 scroll,侧栏抽屉里的滚动容器也算。
|
||||
const stick = useRef(true);
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const el = endRef.current;
|
||||
if (el) stick.current = el.getBoundingClientRect().top - window.innerHeight < FOLLOW_SLACK_PX;
|
||||
};
|
||||
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
||||
return () => document.removeEventListener("scroll", onScroll, { capture: true });
|
||||
}, []);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
|
||||
useEffect(() => {
|
||||
if (active) endRef.current?.scrollIntoView({ block: "nearest" });
|
||||
if (active && stick.current) endRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [count, active]);
|
||||
|
||||
// 追加的用户消息按时间插入
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { Send, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentMode,
|
||||
agentKeys,
|
||||
agentMockEnabled,
|
||||
isRunActive,
|
||||
useAbortAgentRun,
|
||||
@@ -15,6 +18,7 @@ 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 { useSse } from "~/lib/sse.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
@@ -120,6 +124,16 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
||||
const abort = useAbortAgentRun();
|
||||
const append = useAppendAgentMessage();
|
||||
const [msg, setMsg] = useState("");
|
||||
const qc = useQueryClient();
|
||||
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||
useSse(
|
||||
[EventType.agentRunStatus],
|
||||
(e) => {
|
||||
if ((e.payload as { runId?: string } | null)?.runId === runId)
|
||||
void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
|
||||
},
|
||||
!agentMockEnabled(),
|
||||
);
|
||||
|
||||
if (run.isPending) return <LoadingState rows={4} />;
|
||||
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
|
||||
@@ -129,7 +143,13 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
||||
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} />
|
||||
<RunStatusBadge
|
||||
status={
|
||||
(live.finished && isRunActive(r.status)) || (r.status === "queued" && live.events.length > 0)
|
||||
? "running"
|
||||
: r.status
|
||||
}
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
|
||||
@@ -288,9 +288,12 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string }
|
||||
const at = Date.now() - e.startedAt;
|
||||
e.appended.push({
|
||||
id: uuid(9000 + e.appended.length),
|
||||
runId: id,
|
||||
createdAt: iso(Date.now()),
|
||||
updatedAt: iso(Date.now()),
|
||||
text: op.append,
|
||||
status: "pending",
|
||||
createdBy: "user:mock",
|
||||
consumedAt: null,
|
||||
});
|
||||
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
|
||||
@@ -325,9 +328,18 @@ function report(e: Entry, run: AgentRunView): GoalReport | null {
|
||||
imported: 0,
|
||||
inProgress: submitted ? 1 : 0,
|
||||
failed: 0,
|
||||
plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [],
|
||||
plans: submitted
|
||||
? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }]
|
||||
: [],
|
||||
downloads: submitted
|
||||
? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }]
|
||||
? [
|
||||
{
|
||||
downloadTaskId: uuid(7002),
|
||||
planId: uuid(7001),
|
||||
status: "downloading",
|
||||
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
organization: [],
|
||||
modelText: text || null,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { cursorPage, Task, type TaskKind, type TaskStatus } from "@umt/contracts";
|
||||
import { ListTodo, RotateCcw, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { apiMutate, apiParsed, cleanParams } from "~/api/parse.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.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, TTL_SECOND } from "~/lib/api.ts";
|
||||
|
||||
// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。
|
||||
|
||||
const TaskPage = cursorPage(Task);
|
||||
|
||||
export const TASK_KIND_LABEL: Record<TaskKind, string> = {
|
||||
ingestFeed: "抓取 Feed",
|
||||
resolveObservation: "识别资源",
|
||||
evaluateAcquisition: "评估候选",
|
||||
submitDownload: "提交下载",
|
||||
verifyAndImport: "核验入库",
|
||||
refreshMetadata: "刷新元数据",
|
||||
reconcileLibrary: "资产对账",
|
||||
cleanupDownloadCache: "清理缓存",
|
||||
resolveDecision: "处理待办",
|
||||
agentRun: "Agent 运行",
|
||||
};
|
||||
|
||||
export const TASK_STATUS: Record<
|
||||
TaskStatus,
|
||||
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" }
|
||||
> = {
|
||||
pending: { label: "排队", tone: "neutral" },
|
||||
running: { label: "运行中", tone: "neutral" },
|
||||
waiting: { label: "等待", tone: "warning" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
dead: { label: "放弃", tone: "destructive" },
|
||||
};
|
||||
|
||||
const ACTIVE: TaskStatus[] = ["pending", "running", "waiting"];
|
||||
|
||||
function subjectLink(t: Task): string | null {
|
||||
if (!t.subjectId) return null;
|
||||
if (t.subjectType === "download_task") return `/console/tasks/${t.subjectId}`;
|
||||
if (t.subjectType === "organization_record") return `/console/organize/${t.subjectId}`;
|
||||
if (t.subjectType === "media_entity") return `/console/library/${t.subjectId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BusinessTasks() {
|
||||
const [kind, setKind] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const qc = useQueryClient();
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ["tasks", "list", kind, status],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiParsed(TaskPage, "/api/v1/tasks", {
|
||||
params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }),
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 5 * TTL_SECOND : false,
|
||||
});
|
||||
const action = useMutation({
|
||||
mutationFn: ({ id, op }: { id: string; op: "cancel" | "retry" }) =>
|
||||
apiMutate(`/api/v1/tasks/:id/${op}`, {
|
||||
pathParams: { id },
|
||||
body: op === "cancel" ? { reason: "控制台取消" } : {},
|
||||
}).then((d) => Task.parse(d)),
|
||||
onSuccess: (_t, v) => {
|
||||
toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队");
|
||||
void qc.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
const columns: DataColumn<Task>[] = [
|
||||
{
|
||||
key: "kind",
|
||||
header: "任务",
|
||||
cell: (t) => {
|
||||
const href = subjectLink(t);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{TASK_KIND_LABEL[t.kind] ?? t.kind}</p>
|
||||
{href ? (
|
||||
<Link to={href} className="font-mono text-meta text-muted-foreground hover:underline">
|
||||
{t.subjectType} · {t.subjectId?.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-mono text-meta text-muted-foreground">{t.subjectType ?? "—"}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (t) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusBadge tone={TASK_STATUS[t.status].tone}>{TASK_STATUS[t.status].label}</StatusBadge>
|
||||
{t.waitReason ? <span className="text-micro text-muted-foreground">{t.waitReason}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "attempts",
|
||||
header: "尝试",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className="tabular-nums">
|
||||
{t.attempts}/{t.maxAttempts}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "by",
|
||||
header: "触发",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => <span className="text-muted-foreground">{t.triggeredBy ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "创建",
|
||||
hideBelow: "sm",
|
||||
cell: (t) => <span className="whitespace-nowrap text-muted-foreground">{relTime(t.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: "ops",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
{t.engineRunId ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/console/engine/${encodeURIComponent(t.engineRunId)}`}>引擎</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{ACTIVE.includes(t.status) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={action.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.mutate({ id: t.id, op: "cancel" });
|
||||
}}
|
||||
>
|
||||
<Square />
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
{t.status === "failed" || t.status === "dead" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={action.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.mutate({ id: t.id, op: "retry" });
|
||||
}}
|
||||
>
|
||||
<RotateCcw />
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterBar
|
||||
onReset={
|
||||
kind || status
|
||||
? () => {
|
||||
setKind("");
|
||||
setStatus("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={Object.entries(TASK_KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(TASK_STATUS).map(([value, v]) => ({ value, label: v.label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(t) => t.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => void list.refetch()}
|
||||
hasMore={list.hasNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ListTodo,
|
||||
title: kind || status ? "没有符合筛选的任务" : "还没有业务任务",
|
||||
description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const STAGE_LABEL: Record<TimelineStage, string> = {
|
||||
download: "下载",
|
||||
organization: "整理",
|
||||
agent: "Agent",
|
||||
engine: "引擎",
|
||||
engine: "任务",
|
||||
};
|
||||
|
||||
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
|
||||
|
||||
@@ -45,7 +45,7 @@ function PlanPreview({ planId }: { planId: string }) {
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -152,10 +152,10 @@ export function AliasesPanel() {
|
||||
cell: (a) => (
|
||||
<Link
|
||||
to={consolePath("library", a.mediaEntityId)}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
className="inline-flex min-w-0 items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
查看
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span className="truncate">{a.mediaEntityTitle ?? "查看"}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -576,7 +576,12 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
>
|
||||
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
|
||||
<span className="font-mono text-meta text-foreground">{s.template}</span>
|
||||
<span className="min-w-0 truncate font-mono text-micro text-muted-foreground">{s.sourceReleaseId}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-micro text-muted-foreground"
|
||||
title={s.reasons?.join(";") || undefined}
|
||||
>
|
||||
{s.title ?? s.sourceReleaseId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
import { SlotCard } from "./slot-card.tsx";
|
||||
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
||||
|
||||
@@ -75,7 +76,17 @@ export function SlotsPanel({
|
||||
key={s.id}
|
||||
slot={s}
|
||||
currentAsset={
|
||||
s.currentAssetId ? <span className="font-mono text-meta">{s.currentAssetId}</span> : undefined
|
||||
s.currentAsset ? (
|
||||
<span>
|
||||
{[s.currentAsset.resolution, s.currentAsset.sourceType, s.currentAsset.releaseGroup]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "交付特征未知"}
|
||||
{s.currentAsset.sizeBytes ? ` · ${formatBytes(s.currentAsset.sizeBytes, 1)}` : ""}
|
||||
{s.currentAsset.status !== "active" ? ` · ${s.currentAsset.status}` : ""}
|
||||
</span>
|
||||
) : s.currentAssetId ? (
|
||||
<span className="font-mono text-meta">{s.currentAssetId}</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
|
||||
import type { AcquisitionSlot, ManualAcquisitionResult, 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";
|
||||
@@ -42,21 +41,28 @@ export function DownloadDialog({
|
||||
}) {
|
||||
const submit = useSubmitRelease();
|
||||
const [slotId, setSlotId] = useState("");
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const slotName = (id: string | null) => slots.find((s) => s.id === id)?.name ?? 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));
|
||||
sourceReleaseId: release.id,
|
||||
...(slotId ? { slotId } : release.entity ? { entityId: release.entity.id } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
if (r.submitted && r.plan) {
|
||||
setRejected(null);
|
||||
setDone(r.plan.id);
|
||||
toast.success(`已提交(计划${slotName(r.slotId) ? ` · ${slotName(r.slotId)}` : ""}),进度见“下载与任务”`);
|
||||
} else {
|
||||
setRejected(r.rejected);
|
||||
toast.warning("没有生成下载计划,原因见对话框");
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -65,7 +71,7 @@ export function DownloadDialog({
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setMissing(false);
|
||||
setRejected(null);
|
||||
setDone(null);
|
||||
}
|
||||
onOpenChange(o);
|
||||
@@ -97,14 +103,21 @@ export function DownloadDialog({
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{release.entity
|
||||
? "该作品还没有版本目标,提交时按默认规则建临时目标。"
|
||||
: "资源尚未识别到本地作品,提交后先进入识别。"}
|
||||
? "该作品还没有版本目标(Slot),需要先在订阅里建目标才能提交。"
|
||||
: "资源尚未识别到本地作品,请先在“身份与元数据”指定作品。"}
|
||||
</p>
|
||||
)}
|
||||
{missing ? (
|
||||
{rejected ? (
|
||||
<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
|
||||
能力走同一流程提交。
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
|
||||
{r.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{done ? (
|
||||
@@ -132,7 +145,7 @@ export function DownloadDialog({
|
||||
<Bot />
|
||||
委托 Agent
|
||||
</Button>
|
||||
<Button onClick={go} disabled={submit.isPending || !!done}>
|
||||
<Button onClick={go} disabled={submit.isPending || !!done || !release.entity}>
|
||||
<Download />
|
||||
{submit.isPending ? "提交中…" : "提交下载"}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user