From 563098c77a567aadf048a3461c8230953c8e82f7 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:26:52 +0800 Subject: [PATCH] feat(web): downloads and organize records open the shared organize wizard (FE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载详情"整理"按内容路径(经下载器路径映射)换算成登记目录后打开向导;整理记录详情加"重新整理"; 整理记录页"手动整理"转到文件管理;删除旧的 downloads/organize-dialog。 Co-Authored-By: Claude Opus 5.5 --- .../components/downloads/download-detail.tsx | 13 +- .../components/downloads/organize-dialog.tsx | 350 ------------------ apps/web/app/routes/console/organize.$id.tsx | 13 +- apps/web/app/routes/console/organize.tsx | 7 +- 4 files changed, 25 insertions(+), 358 deletions(-) delete mode 100644 apps/web/app/components/downloads/organize-dialog.tsx diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index ea12db62..5dd65d30 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -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 = { fill: "补缺", upgrade: "升级", replace_source: "换源" }; @@ -197,7 +197,16 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { {task.lastError ? : null} {organizing ? ( - !o && setOrganizing(false)} downloadTaskId={task.id} /> + setOrganizing(false)} + /> ) : null} ); diff --git a/apps/web/app/components/downloads/organize-dialog.tsx b/apps/web/app/components/downloads/organize-dialog.tsx deleted file mode 100644 index 0f64aee4..00000000 --- a/apps/web/app/components/downloads/organize-dialog.tsx +++ /dev/null @@ -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 = { transfer: "入库", skip: "跳过", replace: "替换", hold: "暂缓" }; -const FILE_KIND: Record = { video: "视频", subtitle: "字幕", audio: "音轨", nfo: "NFO", other: "其它" }; - -const EXCLUDE_REASON: Record = { - 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 = { - source_size_mismatch: "源文件大小与下载器记录不一致,可能还在写入;稍后重新预览", - source_missing: "源文件不存在,检查路径映射与挂载", - source_path_unknown: "找不到下载任务的保存路径或路径映射", -}; - -function PlanPreview({ planId }: { planId: string }) { - const art = useArtifact(planId); - if (art.isPending) return ; - if (art.error) { - return ( -

- 计划已生成(planId {planId.slice(0, 8)}…), - {isMissingEndpoint(art.error) ? "计划已过期,重新预览。" : `读取计划失败:${errorMessage(art.error)}`} -

- ); - } - const parsed = OrganizePlanPreview.safeParse(art.data.content); - if (!parsed.success) return

计划内容格式无法识别。

; - const p = parsed.data; - const org = p.plan?.organize; - if (!org) - return ( -

- 无法生成计划:{(p.reason && PLAN_FAIL_TEXT[p.reason]) ?? p.reason ?? p.status ?? "未知原因"} -

- ); - const blocking = org.conflicts.filter((c) => c.blocking); - return ( -
-
- {organizationActionLabel(org.operation)} - {org.crossDevice ? 跨卷:复制 → 核验 → 删源 : null} - {org.requiresApproval ? 需要审批 : null} - {blocking.length ? 阻塞冲突 {blocking.length} : null} -
-
    - {org.files.map((f) => ( -
  • -
    - - {FILE_ACTION[f.action] ?? f.action} - - {FILE_KIND[f.kind] ?? f.kind} - {formatBytes(f.size, 1)} - {f.skipReason ? ({f.skipReason}) : null} -
    -

    {f.src}

    -

    → {f.dst}

    -
  • - ))} -
- {org.excluded.length ? ( -

- 排除 {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 ? "…" : ""} -

- ) : null} - {org.conflicts.map((c) => ( -

- {c.blocking ? "阻塞" : "提示"}:{c.kind} - {c.detail ? ` · ${c.detail}` : ""} -

- ))} -
- ); -} - -/** - * 手动发起整理:先 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>({}); - const [planId, setPlanId] = useState(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 ( - - - - 手动整理 - 先预览计划(不动文件),确认后执行。跨卷移动按复制 → 核验 → 删源执行。 - -
- {downloadTaskId ? ( -

- 源:下载任务 {downloadTaskId.slice(0, 8)}… 的内容路径 -

- ) : ( - -