Files
UHD_Media_Tool/apps/web/app/components/downloads/manual-download-dialog.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

227 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AssetVersion, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { downloaders } from "~/api/instances.ts";
import { useLibraryVersions } from "~/api/library-versions.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 { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
export type ManualDownloadOptions = Omit<ManualAcquisitionRequest, "sourceReleaseId">;
export interface ManualDownloadResource {
title: string;
sizeBytes: number | null;
entityId?: string;
}
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
export function ManualDownloadDialog({
resource,
open,
onOpenChange,
submit,
initialUnrecognized = false,
onDelegate,
}: {
resource: ManualDownloadResource;
open: boolean;
onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
initialUnrecognized?: boolean;
onDelegate?: () => void;
}) {
const instances = downloaders.useList();
const formId = useId();
const inventory = useLibraryVersions(open ? resource.entityId : undefined);
const [instanceId, setInstanceId] = useState("");
const [savePath, setSavePath] = useState("");
const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ManualAcquisitionResult | null>(null);
const versions = new Map<string, AssetVersion>();
for (const v of inventory.data?.movie?.versions ?? []) versions.set(v.assetId, v);
for (const season of inventory.data?.seasons ?? [])
for (const episode of season.episodes) for (const v of episode.versions) versions.set(v.assetId, v);
for (const v of inventory.data?.unplaced ?? []) versions.set(v.assetId, v);
const existing = [...versions.values()];
const single = existing.length === 1 ? existing[0]!.assetId : null;
useEffect(() => {
if (mode === "replace" && single) setSelected([single]);
}, [mode, single]);
useEffect(() => {
if (open) {
setResult(null);
setUnrecognized(initialUnrecognized);
}
}, [open, initialUnrecognized]);
const go = async () => {
setBusy(true);
try {
const r = await submit({
...(resource.entityId ? { entityId: resource.entityId } : {}),
...(instanceId ? { downloaderInstanceId: instanceId } : {}),
...(savePath.trim() ? { savePath: savePath.trim() } : {}),
...(unrecognized ? { allowUnrecognized: true } : {}),
importMode: mode,
replacementAssetIds: mode === "replace" ? selected : [],
});
setResult(r);
if (r.requiresConfirmation) setUnrecognized(true);
if (r.submitted) toast.success("已提交下载");
} catch (e) {
toast.error(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{resource.title}
{resource.sizeBytes !== null ? ` · ${formatBytes(resource.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FilterSelect
label="下载器"
value={instanceId}
onChange={setInstanceId}
allLabel="默认下载器"
options={(instances.data?.items ?? [])
.filter((v) => v.enabled)
.map((v) => ({ value: v.id, label: v.name }))}
/>
<label htmlFor={`${formId}-path`} className="block space-y-1 text-caption">
保存目录
<Input
id={`${formId}-path`}
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
placeholder="留空使用默认下载目录"
/>
</label>
<FilterSelect
label="入库方式"
value={mode === "normal" ? "" : mode}
onChange={(v) => setMode(v === "replace" ? "replace" : "normal")}
allLabel="正常入库,保留已有版本"
options={[{ value: "replace", label: "一次性洗版入库" }]}
/>
{resource.entityId && inventory.isPending ? (
<p className="text-caption text-muted-foreground">正在读取已有版本…</p>
) : null}
{inventory.error ? (
<p className="text-caption text-destructive">已有版本读取失败,请重试后选择洗版。</p>
) : null}
{existing.length ? (
<fieldset className="max-h-56 space-y-2 overflow-auto rounded-lg border p-3">
<legend className="px-1 text-caption">
{mode === "replace" ? "选择要替换的已有文件" : "媒体库已有版本"}
</legend>
{existing.map((v) => (
<div key={v.assetId} className="flex items-start gap-2 text-caption">
{mode === "replace" ? (
<input
type="checkbox"
id={`${formId}-${v.assetId}`}
checked={selected.includes(v.assetId)}
onChange={(e) =>
setSelected((ids) =>
e.target.checked ? [...ids, v.assetId] : ids.filter((id) => id !== v.assetId),
)
}
/>
) : null}
{mode === "replace" ? (
<label htmlFor={`${formId}-${v.assetId}`} className="min-w-0 break-all">
<VersionDescription version={v} />
</label>
) : (
<VersionDescription version={v} />
)}
</div>
))}
</fieldset>
) : mode === "replace" ? (
<p className="text-caption text-muted-foreground">没有可替换的已有文件,请选择正常入库。</p>
) : null}
{mode === "replace" ? (
<p className="text-meta text-muted-foreground">
本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
</p>
) : null}
{unrecognized ? (
<p className="rounded-lg border border-warning/40 p-3 text-caption text-warning">
暂未识别到作品。可以继续下载;整理时会尝试补全识别和本地元数据。
</p>
) : null}
{result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
) : null}
{result?.submitted ? (
<Link className="text-caption underline" to={`/console/downloads?plan=${result.plan?.id ?? ""}`}>
查看下载任务
</Link>
) : null}
</div>
<DialogFooter>
{onDelegate ? (
<Button
variant="outline"
disabled={busy}
onClick={() => {
onOpenChange(false);
onDelegate();
}}
>
<Bot />
委托智能体
</Button>
) : null}
<Button
onClick={() => void go()}
disabled={
busy ||
!!result?.submitted ||
(mode === "replace" && (!selected.length || inventory.isPending || !!inventory.error))
}
>
<Download />
{busy ? "提交中…" : unrecognized ? "继续下载" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function VersionDescription({ version }: { version: AssetVersion }) {
return (
<span className="min-w-0 break-all">
{version.fileName}
<span className="block text-meta text-muted-foreground">
{[version.resolution, version.source, version.videoCodec, ...version.hdr, version.edition]
.filter(Boolean)
.join(" · ")}
</span>
</span>
);
}