Files
UHD_Media_Tool/apps/web/app/components/search/search-download-dialog.tsx
T
rootandClaude Opus 5.5 393a4cadcc feat(web): global search — top bar entry, Ctrl/Cmd+K palette and /console/search results (FE-3)
- palette queries media + actions: local works open, TMDB candidates view/subscribe, page jumps
- results page: works / releases / actions; release filters, facets, sort, cursor, partial-site warning
- release rows: download via /search/releases/{siteId}/{siteReleaseId}/plan, delegate to Agent
- smoke: rules tabs, preferences redirect, search page

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:06:16 +08:00

173 lines
6.1 KiB
TypeScript

import type { ManualAcquisitionResult, SearchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { usePlanFromSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
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";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
export function delegateToAgent(r: SearchRelease) {
openAgentDrawer({
label: `资源:${r.title}`,
releaseId: r.sourceReleaseId ?? undefined,
entityId: r.entity?.id,
siteId: r.siteId,
goal: r.sourceReleaseId
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
});
}
export function SearchDownloadDialog({
release,
open,
onOpenChange,
}: {
release: SearchRelease;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const plan = usePlanFromSearch();
const entityId = release.entity?.id;
const slots = useSlots({ mediaEntityId: entityId }, open && !!entityId);
const slotList = slots.data?.items ?? [];
const [slotId, setSlotId] = useState("");
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
const [done, setDone] = useState<string | null>(null);
const slotName = (id: string | null) => slotList.find((s) => s.id === id)?.name ?? null;
const go = () =>
plan.mutate(
{
siteId: release.siteId,
siteReleaseId: release.siteReleaseId,
...(slotId ? { slotId } : entityId ? { entityId } : {}),
},
{
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)),
},
);
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) {
setRejected(null);
setDone(null);
}
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.siteName} · {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>
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在“身份与元数据”指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
) : slotList.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="版本目标"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slotList.map((s) => ({
value: s.id,
label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`,
}))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<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 ? (
<p className="text-caption text-success">
计划已生成。
<Link to="/console/downloads/plans" className="underline underline-offset-4">
查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
delegateToAgent(release);
}}
>
<Bot />
委托 Agent
</Button>
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
<Download />
{plan.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}