feat(web): downloads and organize records open the shared organize wizard (FE-4)

下载详情"整理"按内容路径(经下载器路径映射)换算成登记目录后打开向导;整理记录详情加"重新整理";
整理记录页"手动整理"转到文件管理;删除旧的 downloads/organize-dialog。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:26:52 +08:00
1 parent c31df8b69a
commit 563098c77a
4 files changed
+25 -358

No files matched your search

@@ -17,13 +17,13 @@ import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { planReasonText } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
import { OrganizeDialog } from "./organize-dialog.tsx";
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
@@ -197,7 +197,16 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
{organizing ? (
<OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} downloadTaskId={task.id} />
<OrganizeWizard
source={{
kind: "download",
taskId: task.id,
instanceId: task.instanceId,
contentPath: task.contentPath,
name: task.name,
}}
onClose={() => setOrganizing(false)}
/>
) : null}
</div>
);
@@ -1,350 +0,0 @@
import { OrganizeRequest } from "@umt/contracts";
import { Eye, Play } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { isMissingEndpoint } from "~/api/parse.ts";
import { OrganizePlanPreview, useArtifact, useEntitySearch, useLibraryRoots, useOrganize } from "~/api/records.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { organizationActionLabel } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
const AUTO = "__auto__";
const FILE_ACTION: Record<string, string> = { transfer: "入库", skip: "跳过", replace: "替换", hold: "暂缓" };
const FILE_KIND: Record<string, string> = { video: "视频", subtitle: "字幕", audio: "音轨", nfo: "NFO", other: "其它" };
const EXCLUDE_REASON: Record<string, string> = {
sample: "样片",
too_small: "体积过小",
not_imported_kind: "不入库的文件类型",
orphan_sidecar: "孤立附属文件",
no_episode: "解析不出集号",
special_extra: "特典/花絮",
extra_content: "额外内容",
no_target_dir: "没有目标目录",
naming_failed: "命名失败",
};
/** 预览里的计划能否执行:没生成整理计划或有阻塞冲突时不能执行(执行只会得到一条失败记录) */
function usePlanExecutable(planId: string | null) {
const art = useArtifact(planId);
const parsed = art.data ? OrganizePlanPreview.safeParse(art.data.content) : null;
const org = parsed?.success ? parsed.data.plan?.organize : undefined;
return !!org && !org.conflicts.some((c) => c.blocking);
}
const PLAN_FAIL_TEXT: Record<string, string> = {
source_size_mismatch: "源文件大小与下载器记录不一致,可能还在写入;稍后重新预览",
source_missing: "源文件不存在,检查路径映射与挂载",
source_path_unknown: "找不到下载任务的保存路径或路径映射",
};
function PlanPreview({ planId }: { planId: string }) {
const art = useArtifact(planId);
if (art.isPending) return <LoadingState rows={3} />;
if (art.error) {
return (
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
计划已生成(planId {planId.slice(0, 8)}…),
{isMissingEndpoint(art.error) ? "计划已过期,重新预览。" : `读取计划失败:${errorMessage(art.error)}`}
</p>
);
}
const parsed = OrganizePlanPreview.safeParse(art.data.content);
if (!parsed.success) return <p className="text-caption text-muted-foreground">计划内容格式无法识别。</p>;
const p = parsed.data;
const org = p.plan?.organize;
if (!org)
return (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
无法生成计划:{(p.reason && PLAN_FAIL_TEXT[p.reason]) ?? p.reason ?? p.status ?? "未知原因"}
</p>
);
const blocking = org.conflicts.filter((c) => c.blocking);
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{organizationActionLabel(org.operation)}</StatusBadge>
{org.crossDevice ? <StatusBadge tone="warning">跨卷:复制 → 核验 → 删源</StatusBadge> : null}
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
</div>
<ul className="max-h-56 space-y-1.5 overflow-auto text-meta">
{org.files.map((f) => (
<li key={f.src} className="rounded-lg border border-border/60 px-2.5 py-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
{FILE_ACTION[f.action] ?? f.action}
</StatusBadge>
<span className="text-muted-foreground">{FILE_KIND[f.kind] ?? f.kind}</span>
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
</div>
<p className="mt-0.5 font-mono break-all text-muted-foreground">{f.src}</p>
<p className="font-mono break-all text-foreground">→ {f.dst}</p>
</li>
))}
</ul>
{org.excluded.length ? (
<p className="text-meta text-muted-foreground">
排除 {org.excluded.length} 个:
{org.excluded
.slice(0, 5)
.map((e) => `${e.src.split("/").pop()}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
.join(",")}
{org.excluded.length > 5 ? "…" : ""}
</p>
) : null}
{org.conflicts.map((c) => (
<p
key={`${c.kind}${c.src ?? ""}`}
className={cn("text-meta", c.blocking ? "text-destructive" : "text-warning")}
>
{c.blocking ? "阻塞" : "提示"}:{c.kind}
{c.detail ? ` · ${c.detail}` : ""}
</p>
))}
</div>
);
}
/**
* 手动发起整理:先 dryRun 预览计划,再执行。自动与手动产生同格式整理记录。
*/
export function OrganizeDialog({
open,
onOpenChange,
downloadTaskId,
defaultPaths,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
downloadTaskId?: string;
defaultPaths?: string[];
}) {
const navigate = useNavigate();
const organize = useOrganize();
const roots = useLibraryRoots();
const [paths, setPaths] = useState((defaultPaths ?? []).join("\n"));
const [action, setAction] = useState<"copy" | "move">("copy");
const [root, setRoot] = useState("");
const [entityQ, setEntityQ] = useState("");
const [entity, setEntity] = useState<{ id: string; title: string } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [planId, setPlanId] = useState<string | null>(null);
const search = useEntitySearch(entity ? "" : entityQ);
const executable = usePlanExecutable(planId);
const body = (dryRun: boolean) => {
const sourcePaths = paths
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
return OrganizeRequest.safeParse({
...(downloadTaskId ? { downloadTaskId } : { sourcePaths }),
...(entity ? { mediaEntityId: entity.id } : {}),
...(root ? { targetLibraryRootId: root } : {}),
action,
dryRun,
});
};
const run = (dryRun: boolean) => {
const parsed = body(dryRun);
if (!parsed.success) {
const e = zodFieldErrors(parsed.error.issues);
const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths"));
setErrors({
...e,
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径" : "",
});
return;
}
setErrors({});
organize.mutate(parsed.data, {
onSuccess: (r) => {
if (dryRun) {
setPlanId(r.planId);
if (r.truncated)
toast.warning("源目录文件过多,预览只含前一部分", {
description: "执行会被拒绝。把源路径缩小到具体的作品目录",
});
} else {
toast.success("已发起整理");
onOpenChange(false);
if (r.recordId) navigate(`/console/organize/${r.recordId}`);
}
},
onError: (e) => toast.error(errorMessage(e)),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>手动整理</DialogTitle>
<DialogDescription>先预览计划(不动文件),确认后执行。跨卷移动按复制 → 核验 → 删源执行。</DialogDescription>
</DialogHeader>
<div className="grid gap-1">
{downloadTaskId ? (
<p className="mb-2 text-caption text-muted-foreground">
源:下载任务 {downloadTaskId.slice(0, 8)}… 的内容路径
</p>
) : (
<FormField
label="源路径"
htmlFor="org-paths"
required
error={errors.paths}
hint="执行进程可访问的绝对路径,每行一个(文件或目录)"
>
<Textarea
id="org-paths"
rows={3}
className="font-mono text-caption"
value={paths}
aria-invalid={!!errors.paths}
onChange={(e) => {
setPaths(e.target.value);
setPlanId(null);
}}
/>
</FormField>
)}
<FormField label="作品" htmlFor="org-entity" hint={entity ? undefined : "可选;不选时按文件名识别"}>
{entity ? (
<div className="flex items-center gap-2">
<StatusBadge>{entity.title}</StatusBadge>
<Button
variant="ghost"
size="xs"
onClick={() => {
setEntity(null);
setPlanId(null);
}}
>
更换
</Button>
</div>
) : (
<div className="relative">
<Input
id="org-entity"
value={entityQ}
placeholder="搜索本地作品"
onChange={(e) => setEntityQ(e.target.value)}
/>
{search.data?.items.length ? (
<ul className="pg-float absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-auto rounded-xl p-1">
{search.data.items.map((it) => (
<li key={it.entity.id}>
<button
type="button"
className="w-full cursor-pointer rounded-lg px-2.5 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
onClick={() => {
setEntity({
id: it.entity.id,
title: `${it.entity.title}${it.entity.year ? ` (${it.entity.year})` : ""}`,
});
setEntityQ("");
setPlanId(null);
}}
>
{it.entity.title}
{it.entity.year ? ` (${it.entity.year})` : ""}
</button>
</li>
))}
</ul>
) : null}
</div>
)}
</FormField>
<div className="grid gap-x-3 sm:grid-cols-2">
<FormField
label="动作"
htmlFor="org-action"
hint={action === "move" ? "移动需要对应授权" : "保留源文件,不影响做种"}
>
<Select
value={action}
onValueChange={(v) => {
setAction(v as "copy" | "move");
setPlanId(null);
}}
>
<SelectTrigger id="org-action" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="copy">复制</SelectItem>
<SelectItem value="move">移动</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="目标库根" htmlFor="org-root">
<Select
value={root || AUTO}
onValueChange={(v) => {
setRoot(v === AUTO ? "" : v);
setPlanId(null);
}}
>
<SelectTrigger id="org-root" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={AUTO}>按作品类型自动</SelectItem>
{(roots.data?.items ?? []).map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{planId ? (
<div className="mt-1">
<p className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
计划预览
</p>
<PlanPreview planId={planId} />
</div>
) : null}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => run(true)} disabled={organize.isPending}>
<Eye />
预览计划
</Button>
<Button
onClick={() => run(false)}
disabled={organize.isPending || !planId || !executable}
title={!planId ? "先预览计划" : executable ? undefined : "计划不可执行"}
>
<Play />
执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+12 -1
View File
@@ -1,5 +1,5 @@
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
import { ArrowLeft, ArrowRight, Bot, ChevronRight } from "lucide-react";
import { ArrowLeft, ArrowRight, Bot, ChevronRight, Wand2 } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
@@ -8,6 +8,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { actorTypeLabel } from "~/lib/labels.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -118,6 +119,7 @@ export default function OrganizeRecordPage() {
const { id = "" } = useParams();
const rec = useRecord(id);
const r = rec.data;
const [reorganize, setReorganize] = useState(false);
return (
<>
@@ -134,6 +136,12 @@ export default function OrganizeRecordPage() {
全部记录
</Link>
</Button>
{r && (r.action === "copy" || r.action === "move") && r.status !== "queued" && r.status !== "running" ? (
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
<Wand2 />
重新整理
</Button>
) : null}
{r && (r.status === "failed" || r.status === "partial") ? (
<Button
variant="outline"
@@ -236,6 +244,9 @@ export default function OrganizeRecordPage() {
</section>
</div>
)}
{reorganize && r ? (
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
) : null}
</>
);
}
+2 -5
View File
@@ -9,7 +9,6 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { OrganizeDialog } from "~/components/downloads/organize-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { useSse } from "~/lib/sse.ts";
@@ -89,7 +88,6 @@ export default function OrganizePage() {
const [trigger, setTrigger] = useState("");
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [organizing, setOrganizing] = useState(false);
const entityId = params.get("mediaEntityId") ?? undefined;
const navigate = useNavigate();
const qc = useQueryClient();
@@ -113,7 +111,7 @@ export default function OrganizePage() {
title="整理记录"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => setOrganizing(true)}>
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
手动整理
</Button>
@@ -165,10 +163,9 @@ export default function OrganizePage() {
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: "下载完成后会自动整理,也可以点“手动整理”。",
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
{organizing ? <OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} /> : null}
</>
);
}