feat(web): simplify subscription and manual download flows and sync UHD styles

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 27c0f27434
commit c3c4456913
33 files changed
+1083 -633

No files matched your search

@@ -6,8 +6,7 @@ import { toast } from "sonner";
import { apiMutate } from "~/api/parse.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
import { subjectLink, taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -62,28 +61,6 @@ export const SUBJECT_LABEL: Record<string, string> = {
cache_volume: "下载盘",
};
/** 业务任务的对象 → 它的"家"页面 */
export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): string | null {
if (!t.subjectId) return null;
switch (t.subjectType) {
case "download_task":
return recordPath("downloads", t.subjectId);
case "acquisition_plan":
return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
case "organization_record":
return recordPath("organize", t.subjectId);
case "media_entity":
return consolePath("library", t.subjectId);
case "site":
return recordPath("sites", t.subjectId);
case "feed_source":
// RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
default:
return null;
}
}
export function subjectText(t: Pick<Task, "subjectType" | "subjectId">): string {
if (!t.subjectType) return "—";
const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType;
@@ -1,7 +1,7 @@
import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts";
import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react";
import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, Settings2, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { Link } from "react-router";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useDownloadSavePaths } from "~/api/downloads.ts";
import { useEntityDetail } from "~/api/library.ts";
@@ -67,6 +67,75 @@ export interface ManualDownloadResource {
const AUTO = "__auto__";
/** 搜索与工作台直接下载,只有高级选项或未识别确认才打开窗口。 */
export function ManualDownloadActions({
resource,
submit,
onDelegate,
}: {
resource: ManualDownloadResource;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
onDelegate?: () => void;
}) {
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(false);
const [unrecognized, setUnrecognized] = useState(false);
const go = async () => {
setBusy(true);
try {
const result = await submit({ importMode: "normal", replacementAssetIds: [] });
if (result.requiresConfirmation) {
setUnrecognized(true);
setOpen(true);
} else if (result.submitted) {
const planId = result.plan?.id;
toast.success("已提交下载", {
...(planId
? { action: { label: "查看下载", onClick: () => navigate(`/console/downloads?plan=${planId}`) } }
: {}),
});
} else toast.warning(result.rejected.map((r) => r.message).join(";") || "没有提交成功");
} catch (e) {
toast.error(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<>
<Button size="sm" disabled={busy} onClick={() => void go()}>
<Download />
{busy ? "提交中…" : "下载"}
</Button>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
aria-label="高级下载选项"
title="高级下载选项"
onClick={() => {
setUnrecognized(false);
setOpen(true);
}}
>
<Settings2 />
</Button>
{open ? (
<ManualDownloadDialog
resource={resource}
open={open}
onOpenChange={setOpen}
submit={submit}
initialUnrecognized={unrecognized}
initialAdvanced={!unrecognized}
onDelegate={onDelegate}
/>
) : null}
</>
);
}
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
export function ManualDownloadDialog({
resource,
@@ -74,6 +143,7 @@ export function ManualDownloadDialog({
onOpenChange,
submit,
initialUnrecognized = false,
initialAdvanced = false,
onDelegate,
}: {
resource: ManualDownloadResource;
@@ -81,6 +151,7 @@ export function ManualDownloadDialog({
onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
initialUnrecognized?: boolean;
initialAdvanced?: boolean;
onDelegate?: () => void;
}) {
const formId = useId();
@@ -95,6 +166,7 @@ export function ManualDownloadDialog({
const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
const [advanced, setAdvanced] = useState(initialAdvanced);
const [identityOpen, setIdentityOpen] = useState(!entityId);
// null = 没动过,显示作品当前的值
const [tmdbType, setTmdbType] = useState<TmdbType | null>(null);
@@ -132,9 +204,9 @@ export function ManualDownloadDialog({
if (open) {
setResult(null);
setUnrecognized(initialUnrecognized);
if (initialUnrecognized) setIdentityOpen(true);
setAdvanced(initialAdvanced);
}
}, [open, initialUnrecognized]);
}, [open, initialUnrecognized, initialAdvanced]);
const go = async () => {
setBusy(true);
@@ -156,7 +228,6 @@ export function ManualDownloadDialog({
setResult(r);
if (r.requiresConfirmation) {
setUnrecognized(true);
setIdentityOpen(true);
}
if (r.submitted) toast.success("已提交下载");
} catch (e) {
@@ -170,6 +241,7 @@ export function ManualDownloadDialog({
const siteName = resource.siteName ?? site.data?.name ?? null;
const missingCustom = dir === CUSTOM_DIR && !custom.trim();
const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId);
const savePath = resolveSavePath(target, dir, custom);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -203,65 +275,92 @@ export function ManualDownloadDialog({
</DialogHeader>
<div className="min-w-0 space-y-4">
<ResourceSummary resource={resource} />
<SaveTargetFields
formId={formId}
instances={paths.data?.instances ?? []}
autoInstanceId={paths.data?.autoInstanceId ?? null}
loading={paths.isPending}
error={paths.error ? errorMessage(paths.error) : null}
instanceId={instanceId}
onInstanceChange={(v) => {
setInstanceId(v);
if (dir !== CUSTOM_DIR) setDir("");
}}
target={target}
dir={dir}
onDirChange={setDir}
custom={custom}
onCustomChange={setCustom}
/>
<IdentitySection
formId={formId}
open={identityOpen}
onToggle={() => setIdentityOpen((v) => !v)}
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
unrecognized={!entityId || unrecognized}
type={shownType}
onTypeChange={(v) => {
setTmdbType(v);
setPicked(null);
}}
tmdbId={shownId}
onTmdbIdChange={(v) => {
setTmdbId(v.trim());
setPicked(null);
}}
picked={picked}
onPick={(w) => {
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
else {
setPicked(null);
setTmdbType(w.tmdb.type);
setTmdbId(w.tmdb.id);
}
}}
onClearPick={() => setPicked(null)}
changed={changed}
subject={resource.title}
/>
{entityId && !changed ? (
<ExistingVersionsSection
formId={formId}
loading={inventory.isPending}
error={!!inventory.error}
summary={versionSummaryText(inventory.data?.summary)}
versions={existing}
mode={mode}
onModeChange={setMode}
selected={selected}
onSelectedChange={setSelected}
/>
<div className="space-y-1 text-caption">
<p className={paths.error || (!paths.isPending && !target) ? "text-destructive" : "text-muted-foreground"}>
{paths.error
? errorMessage(paths.error)
: paths.isPending
? "读取下载去向…"
: target
? `${target.name} · ${savePath ? `保存到:${savePathText(savePath)}` : "填写保存目录"}`
: "没有可用的下载器,先在设置里添加下载器和保存目录"}
</p>
{changed ? (
<p>本次作品:{picked ? picked.title : `${shownType === "movie" ? "电影" : "剧集"} · TMDB ${shownId}`}</p>
) : null}
{effectiveMode === "replace" ? <p>本次洗版:替换 {selected.length} 个已有文件</p> : null}
</div>
{unrecognized && !changed ? (
<p className="text-caption text-warning">
没识别出作品。可以继续下载,整理时再识别;也可在高级选项里指定作品。
</p>
) : null}
<details open={advanced} onToggle={(e) => setAdvanced(e.currentTarget.open)}>
<summary className="cursor-pointer text-caption text-muted-foreground">高级选项</summary>
{advanced ? (
<div className="mt-3 space-y-4">
<SaveTargetFields
formId={formId}
instances={paths.data?.instances ?? []}
autoInstanceId={paths.data?.autoInstanceId ?? null}
loading={paths.isPending}
error={paths.error ? errorMessage(paths.error) : null}
instanceId={instanceId}
onInstanceChange={(v) => {
setInstanceId(v);
if (dir !== CUSTOM_DIR) setDir("");
}}
target={target}
dir={dir}
onDirChange={setDir}
custom={custom}
onCustomChange={setCustom}
/>
<IdentitySection
formId={formId}
open={identityOpen}
onToggle={() => setIdentityOpen((v) => !v)}
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
unrecognized={!entityId || unrecognized}
type={shownType}
onTypeChange={(v) => {
setTmdbType(v);
setPicked(null);
}}
tmdbId={shownId}
onTmdbIdChange={(v) => {
setTmdbId(v.trim());
setPicked(null);
}}
picked={picked}
onPick={(w) => {
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
else {
setPicked(null);
setTmdbType(w.tmdb.type);
setTmdbId(w.tmdb.id);
}
}}
onClearPick={() => setPicked(null)}
changed={changed}
subject={resource.title}
/>
{entityId && !changed ? (
<ExistingVersionsSection
formId={formId}
loading={inventory.isPending}
error={!!inventory.error}
summary={versionSummaryText(inventory.data?.summary)}
versions={existing}
mode={mode}
onModeChange={setMode}
selected={selected}
onSelectedChange={setSelected}
/>
) : null}
</div>
) : null}
</details>
{result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
) : null}
@@ -272,7 +371,7 @@ export function ManualDownloadDialog({
) : null}
</div>
<DialogFooter>
{onDelegate ? (
{onDelegate && (advanced || unrecognized) ? (
<Button
variant="outline"
disabled={busy}