feat(web): simplify subscription and manual download flows and sync UHD styles
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user