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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user