diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index b0b4f5b5..9614d37e 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -1,4 +1,11 @@ -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { cursorPage, type FileEntry, @@ -281,6 +288,28 @@ export function useRecordSources(recordId: string | null) { }); } +/** HIS-1 批量重新整理:多条记录的源路径一并换算(合并 items / unresolved) */ +export function useRecordSourcesMany(recordIds: string[]) { + return useQueries({ + queries: recordIds.map((id) => ({ + queryKey: ["files", "record-sources", id], + retry: false, + queryFn: ({ signal }: { signal: AbortSignal }) => + call(FilesRecordSourcesResult, "/files/record-sources", { params: { recordId: id }, signal }), + })), + combine: combineRecordSources, + }); +} + +// 模块级函数:引用稳定,结果不变时 useQueries 不重算(避免下游 useMemo 每次渲染都变) +function combineRecordSources(rs: { isPending: boolean; error: unknown; data?: FilesRecordSourcesResult }[]) { + return { + isPending: rs.some((r) => r.isPending), + error: rs.find((r) => r.error)?.error ?? null, + data: rs.every((r) => r.data) ? rs.map((r) => r.data!) : undefined, + }; +} + /** 作品选择器里的本地作品 */ export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null }; diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 7df956d0..046368ce 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -4,6 +4,9 @@ import { cursorPage, LibraryItem, LibraryRoot, + type OrganizationDeleteRequest, + OrganizationDeleteResult, + OrganizationStats, type OrganizationStatus, type OrganizationTrigger, OrganizeAccepted, @@ -23,6 +26,10 @@ export type RecordFilters = { trigger?: OrganizationTrigger; mediaEntityId?: string; q?: string; + kind?: "movie" | "series" | "none"; + /** ISO 时间:只看之后创建的 */ + since?: string; + organizeOnly?: boolean; }; export const recordKeys = { @@ -30,8 +37,34 @@ export const recordKeys = { list: (f: RecordFilters) => ["records", "list", f] as const, one: (id: string) => ["records", "one", id] as const, entity: (id: string) => ["records", "entity", id] as const, + stats: (f: Omit) => ["records", "stats", f] as const, }; +/** HIS-1:按状态计数(筛选同列表,不含状态) */ +export function useRecordStats(f: Omit) { + return useQuery({ + queryKey: recordKeys.stats(f), + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + apiParsed(OrganizationStats, "/api/v1/organization-records/stats", { params: cleanParams(f), signal }), + }); +} + +/** HIS-1:删除整理记录(dryRun 只核对会删什么) */ +export function useDeleteRecords() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: OrganizationDeleteRequest) => + apiMutate("/api/v1/organization-records/delete", { body }).then((d) => OrganizationDeleteResult.parse(d)), + onSuccess: (r) => { + if (!r.dryRun) { + void qc.invalidateQueries({ queryKey: recordKeys.all }); + if (r.trashRecordId) void qc.invalidateQueries({ queryKey: ["files"] }); + } + }, + }); +} + export function useRecords(f: RecordFilters) { return useInfiniteQuery({ queryKey: recordKeys.list(f), @@ -109,6 +142,18 @@ export const RECORD_STATUS_TONE: Record = { auto: "自动", manual: "手动", agent: "智能体" }; +/** 整理方式 / 文件操作(记录的 action) */ +export const ACTION_LABEL = { + copy: "复制", + move: "移动", + rename: "重命名", + relocate: "目录调整", + hardlink: "硬链接", + trash: "移入回收站", + delete: "永久删除", + restore: "从回收站还原", +} as const; + /** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */ export function useArtifact(id: string | null | undefined) { return useQuery({ diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index fb390d35..f2714b7d 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -16,6 +16,7 @@ import { useIdentify, useOrganizeFiles, useRecordSources, + useRecordSourcesMany, } from "~/api/files.ts"; import { useRecord } from "~/api/records.ts"; import { useSettings } from "~/api/settings.ts"; @@ -64,7 +65,9 @@ export type OrganizeSource = /** 做种中且 H&R 未满足:只能复制 */ hnrBlocked?: boolean; } - | { kind: "record"; recordId: string }; + | { kind: "record"; recordId: string } + /** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */ + | { kind: "records"; recordIds: string[]; preset?: WizardEntity | null }; const STEPS = ["文件与作品", "选项", "预览并执行"] as const; @@ -90,6 +93,7 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined }); const recSrc = useRecordSources(isRec ? source.recordId : null); const rec = useRecord(isRec ? source.recordId : undefined); + const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []); return useMemo((): Resolved => { const base = { unresolved: [], preset: null, pending: false, error: null }; if (source.kind === "files") @@ -114,6 +118,28 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined label: `下载任务 · ${source.name}`, }; } + if (source.kind === "records") { + const label = `重新整理 ${source.recordIds.length} 条记录`; + if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true }; + if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error }; + const seen = new Set(); + const items = (many.data ?? []) + .flatMap((r) => r.items) + .filter((it) => { + const k = `${it.root}\u0000${it.path}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + return { + ...base, + items, + hnrPaths: [], + unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))], + preset: source.preset ?? null, + label, + }; + } if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true }; if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error }; const m = rec.data?.media; @@ -128,7 +154,19 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined : null, label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`, }; - }, [source, roots, maps.isLoading, maps.data, recSrc.isPending, recSrc.error, recSrc.data, rec.data]); + }, [ + source, + roots, + maps.isLoading, + maps.data, + recSrc.isPending, + recSrc.error, + recSrc.data, + rec.data, + many.isPending, + many.error, + many.data, + ]); } /** diff --git a/apps/web/app/components/organize/delete-records-dialog.tsx b/apps/web/app/components/organize/delete-records-dialog.tsx new file mode 100644 index 00000000..0bfb6f6a --- /dev/null +++ b/apps/web/app/components/organize/delete-records-dialog.tsx @@ -0,0 +1,160 @@ +import type { OrganizationDeleteResult, PublicOrganizationRecord } from "@umt/contracts"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useDeleteRecords } from "~/api/records.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatBytes } from "~/lib/utils.ts"; +import { hasFiles, recordTitle } from "./record-parts.ts"; + +// HIS-1 删除整理记录确认框(对照 MP 的四个删除选项:只删记录 / 连同源文件 / 连同媒体库文件 / 全部)。 +// 勾选变化时先 dryRun 核对,把会删什么、哪些删不了、会不会被自动整理再处理写清楚。 + +type Rec = Pick; + +export function DeleteRecordsDialog({ + records, + open, + onOpenChange, + onDone, +}: { + records: Rec[]; + open: boolean; + onOpenChange: (open: boolean) => void; + /** 删除完成(传回真正删掉的 id) */ + onDone?: (removed: string[]) => void; +}) { + const [src, setSrc] = useState(false); + const [dst, setDst] = useState(false); + const dry = useDeleteRecords(); + const exec = useDeleteRecords(); + const ids = records.map((r) => r.id); + const idKey = ids.join(","); + const anyFiles = records.some(hasFiles); + const { mutate: check, reset } = dry; + + useEffect(() => { + if (!open) { + setSrc(false); + setDst(false); + reset(); + return; + } + if (!idKey) return; + check({ ids: idKey.split(","), deleteSource: src, deleteTarget: dst, dryRun: true }); + }, [open, idKey, src, dst, check, reset]); + + const plan = dry.data; + const byId = new Map(records.map((r) => [r.id, r])); + const blocked = plan?.items.filter((i) => i.blocked) ?? []; + const deletable = plan ? plan.items.length - blocked.length : 0; + const withFiles = src || dst; + const single = records.length === 1 ? records[0] : undefined; + + return ( + + exec + .mutateAsync({ ids, deleteSource: src, deleteTarget: dst, dryRun: false }) + .then((r) => { + report(r); + onDone?.(r.removed); + }) + .catch((e) => { + toast.error(errorMessage(e, "删除失败")); + throw e; + }) + } + > +
+ {anyFiles ? ( +
+ + +
+ ) : null} + + {blocked.length ? ( +
+

{blocked.length === records.length ? "这些记录都不能删:" : `${blocked.length} 条不能删,其余照删:`}

+
    + {blocked.slice(0, 5).map((b) => { + const r = byId.get(b.id); + return ( +
  • + {r ? `${recordTitle(r)}:` : ""} + {b.message} +
  • + ); + })} + {blocked.length > 5 ?
  • 还有 {blocked.length - 5} 条
  • : null} +
+
+ ) : null} +
+
+ ); +} + +function Summary({ + plan, + pending, + error, + src, + withFiles, +}: { + plan: OrganizationDeleteResult | undefined; + pending: boolean; + error: unknown; + src: boolean; + withFiles: boolean; +}) { + if (error) return

{errorMessage(error, "核对失败")}

; + if (!plan) return

{pending ? "正在核对要删的文件…" : null}

; + const files = [ + plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null, + plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null, + ].filter(Boolean); + return ( +
+ {files.length ? ( +

+ {files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。 + {plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""} +

+ ) : ( +

{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}

+ )} + {src && plan.sourceFiles ?

下载器里的任务不会删除。

: null} + {plan.autoRetry ? ( +

{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。

+ ) : null} +
+ ); +} + +function report(r: OrganizationDeleteResult) { + const kept = r.items.filter((i) => i.blocked); + const files = r.sourceFiles + r.targetFiles; + const head = `已删除 ${r.removed.length} 条记录${files ? `,${files} 个文件移入回收站` : ""}`; + if (kept.length) toast.warning(head, { description: `${kept.length} 条没有删除:${kept[0]?.message ?? ""}` }); + else toast.success(head); +} diff --git a/apps/web/app/components/organize/record-parts.test.ts b/apps/web/app/components/organize/record-parts.test.ts new file mode 100644 index 00000000..d1a9ffbb --- /dev/null +++ b/apps/web/app/components/organize/record-parts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { canReorganize, episodeTag, hasFiles, isRunning, recordTitle } from "./record-parts.ts"; + +// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除 + +const media = ( + o: Partial<{ seasonNumber: number | null; episodeNumber: number | null; year: number | null }> = {}, +) => ({ + entityId: "00000000-0000-4000-8000-000000000001", + kind: "series" as const, + title: "Severance", + year: 2022, + contentUnitId: null, + seasonNumber: null, + episodeNumber: null, + externalIds: [], + ...o, +}); +const path = (p: string | null) => ({ + path: p, + view: "local" as const, + downloaderInstanceId: null, + libraryRootId: null, +}); + +describe("整理记录展示", () => { + it("季集标记", () => { + expect(episodeTag(null)).toBe(""); + expect(episodeTag(media())).toBe(""); + expect(episodeTag(media({ seasonNumber: 1 }))).toBe("S01"); + expect(episodeTag(media({ seasonNumber: 1, episodeNumber: 2 }))).toBe("S01E02"); + }); + + it("标题:作品 (年) 季集;没关联作品时取源文件名", () => { + expect( + recordTitle({ media: media({ seasonNumber: 2, episodeNumber: 10 }), source: path(null), target: path(null) }), + ).toBe("Severance (2022) S02E10"); + expect(recordTitle({ media: media({ year: null }), source: path(null), target: path(null) })).toBe("Severance"); + expect(recordTitle({ media: null, source: path("/dl/Some.Movie.2020/"), target: path(null) })).toBe( + "Some.Movie.2020", + ); + expect(recordTitle({ media: null, source: path(null), target: path(null) })).toBe("未识别"); + }); + + it("重新整理只对已结束的复制 / 移动 / 硬链接", () => { + expect(canReorganize({ action: "copy", status: "failed" })).toBe(true); + expect(canReorganize({ action: "hardlink", status: "succeeded" })).toBe(true); + expect(canReorganize({ action: "move", status: "running" })).toBe(false); + expect(canReorganize({ action: "trash", status: "succeeded" })).toBe(false); + expect(isRunning({ status: "queued" })).toBe(true); + expect(hasFiles({ action: "rename" })).toBe(false); + expect(hasFiles({ action: "move" })).toBe(true); + }); +}); diff --git a/apps/web/app/components/organize/record-parts.ts b/apps/web/app/components/organize/record-parts.ts new file mode 100644 index 00000000..7892d66e --- /dev/null +++ b/apps/web/app/components/organize/record-parts.ts @@ -0,0 +1,35 @@ +import type { PublicOrganizationRecord } from "@umt/contracts"; + +// 整理记录列表 / 详情 / 删除确认共用的展示小件(HIS-1)。 + +type Rec = Pick; + +/** S01E02 / S01;不是剧集返回空串 */ +export function episodeTag(m: PublicOrganizationRecord["media"]): string { + if (!m || m.seasonNumber == null) return ""; + const s = `S${String(m.seasonNumber).padStart(2, "0")}`; + return m.episodeNumber != null ? `${s}E${String(m.episodeNumber).padStart(2, "0")}` : s; +} + +/** 列表标题:作品 (年) S01E02;没关联作品时用源文件名 */ +export function recordTitle(r: Rec): string { + if (r.media) { + const ep = episodeTag(r.media); + return `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}${ep ? ` ${ep}` : ""}`; + } + const p = r.source.path ?? r.target.path ?? ""; + return p.split("/").filter(Boolean).pop() ?? "未识别"; +} + +/** 能重新整理的记录:复制 / 移动 / 硬链接,且已结束 */ +export const canReorganize = (r: Pick) => + (r.action === "copy" || r.action === "move" || r.action === "hardlink") && + r.status !== "queued" && + r.status !== "running"; + +/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */ +export const hasFiles = (r: Pick) => + r.action === "copy" || r.action === "move" || r.action === "hardlink"; + +export const isRunning = (r: Pick) => + r.status === "queued" || r.status === "running"; diff --git a/apps/web/app/routes/console/organize.$id.tsx b/apps/web/app/routes/console/organize.$id.tsx index 59aafd47..f4683d95 100644 --- a/apps/web/app/routes/console/organize.$id.tsx +++ b/apps/web/app/routes/console/organize.$id.tsx @@ -1,30 +1,23 @@ import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts"; -import { ArrowLeft, ArrowRight, Bot, ChevronRight, Wand2 } from "lucide-react"; +import { ArrowLeft, ArrowRight, Bot, ChevronRight, Trash2, 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"; +import { Link, useNavigate, useParams } from "react-router"; +import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; 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 { TechInfo } from "~/components/console/tech-info.tsx"; +import { CoverPlaceholder } from "~/components/library/parts.tsx"; +import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; +import { canReorganize, isRunning } from "~/components/organize/record-parts.ts"; import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { Button } from "~/components/ui/button.tsx"; import { actorTypeLabel } from "~/lib/labels.ts"; import { reasonCode, reasonLine } from "~/lib/reasons.ts"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; -const ACTION_LABEL = { - copy: "复制", - move: "移动", - rename: "重命名", - relocate: "目录调整", - hardlink: "硬链接", - trash: "移入回收站", - delete: "永久删除", - restore: "从回收站还原", -} as const; const FILE_STATUS: Record = { queued: { label: "排队", tone: "neutral" }, running: { label: "执行中", tone: "neutral" }, @@ -124,6 +117,8 @@ export default function OrganizeRecordPage() { const rec = useRecord(id); const r = rec.data; const [reorganize, setReorganize] = useState(false); + const [removing, setRemoving] = useState(false); + const navigate = useNavigate(); return ( <> @@ -140,7 +135,7 @@ export default function OrganizeRecordPage() { 全部记录 - {r && (r.action === "copy" || r.action === "move") && r.status !== "queued" && r.status !== "running" ? ( + {r && canReorganize(r) ? ( ) : null} + {r && !isRunning(r) ? ( + + ) : null} } /> @@ -176,10 +177,20 @@ export default function OrganizeRecordPage() {
+ {r.media ? ( + + ) : null} {RECORD_STATUS_LABEL[r.status]} {[ `${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`, + r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null, r.counts.failed ? `${r.counts.failed} 个失败` : null, r.counts.skipped ? `${r.counts.skipped} 个跳过` : null, @@ -210,6 +221,14 @@ export default function OrganizeRecordPage() { { label: "开始", value: formatDateTime(r.startedAt) }, { label: "完成", value: formatDateTime(r.finishedAt) }, { label: "操作者", value: actorTypeLabel(r.actor.type) }, + ...(r.download + ? [ + { + label: "下载任务", + value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`, + }, + ] + : []), ]} /> {r.media ? ( @@ -253,6 +273,16 @@ export default function OrganizeRecordPage() {
)} + {r ? ( + { + if (removed.includes(r.id)) navigate("/console/organize"); + }} + /> + ) : null} {reorganize && r ? ( setReorganize(false)} /> ) : null} diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 5417395b..ee4ed43d 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -1,95 +1,259 @@ import { useQueryClient } from "@tanstack/react-query"; import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts"; import { EventType } from "@umt/contracts"; -import { FileStack, FolderInput, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + Bot, + Eye, + FileStack, + FolderInput, + ListTree, + MoreHorizontal, + SquareCheck, + Trash2, + Wand2, + X, +} from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useFileRecords } from "~/api/files.ts"; -import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts"; +import { + ACTION_LABEL, + RECORD_STATUS_LABEL, + RECORD_STATUS_TONE, + type RecordFilters, + TRIGGER_LABEL, + useRecordStats, + useRecords, +} from "~/api/records.ts"; +import { openAgentDrawer } from "~/components/agent/agent-store.ts"; 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 { useQueryState } from "~/components/console/use-query-state.ts"; +import { Checkbox } from "~/components/files/file-list.tsx"; +import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx"; +import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; +import { canReorganize, isRunning, recordTitle } from "~/components/organize/record-parts.ts"; +import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { Button } from "~/components/ui/button.tsx"; -import { relTime } from "~/components/workbench/release-fields.ts"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { reasonLine } from "~/lib/reasons.ts"; import { useSse } from "~/lib/sse.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; -const ACTION_LABEL = { - copy: "复制", - move: "移动", - rename: "重命名", - relocate: "目录调整", - hardlink: "硬链接", - trash: "移入回收站", - delete: "永久删除", - restore: "从回收站还原", -} as const; +// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集、源 → 目标路径、整理方式与大小、状态与原因、来源(下载器)、时间; +// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;按作品分组; +// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。 -function Counts({ r }: { r: PublicOrganizationRecord }) { - const c = r.counts; +type Rec = PublicOrganizationRecord; + +const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const; +const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const; + +function sinceOf(t: string): string | undefined { + if (t === "today") { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d.toISOString(); + } + const days = t === "7d" ? 7 : t === "30d" ? 30 : 0; + return days ? new Date(Date.now() - days * 86_400_000).toISOString() : undefined; +} + +const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt; + +/** 状态:成功不加徽章,其余徽章 + 一句原因 */ +function StatusCell({ r }: { r: Rec }) { + if (r.status === "succeeded" && !r.reason) return 成功; + const line = reasonLine(r.reason); return ( - - - {c.succeeded}/{c.total} - - {c.failed ? · 失败 {c.failed} : null} - {c.skipped ? · 跳过 {c.skipped} : null} - +
+ {RECORD_STATUS_LABEL[r.status]} + {line ? ( + + {line} + + ) : null} +
); } -const COLUMNS: DataColumn[] = [ - { - key: "media", - header: "作品 / 目标", - cell: (r) => ( -
-

- {r.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : "未关联作品"} - {r.media?.seasonNumber != null - ? ` S${String(r.media.seasonNumber).padStart(2, "0")}${r.media.episodeNumber != null ? `E${String(r.media.episodeNumber).padStart(2, "0")}` : ""}` - : ""} -

-

- {r.target.path ?? r.source.path ?? "—"} -

-
- ), - }, - { - key: "status", - header: "状态", - cell: (r) => ( -
- {RECORD_STATUS_LABEL[r.status]} - {r.reason ? ( - {reasonLine(r.reason)} +/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */ +function shortPath(p: string | null): string | null { + if (!p) return p; + const parts = p.split("/").filter(Boolean); + return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : p; +} + +function PathLines({ r }: { r: Rec }) { + return ( +
+

+ {shortPath(r.source.path) ?? "—"} +

+

+ → {shortPath(r.target.path) ?? (r.status === "failed" ? "未生成目标路径" : "—")} +

+
+ ); +} + +function MenuItem({ + icon, + children, + onClick, + danger = false, +}: { + icon: ReactNode; + children: ReactNode; + onClick: () => void; + danger?: boolean; +}) { + return ( + + ); +} + +function RowMenu({ + r, + onOpen, + onReorganize, + onDelete, + onOnlyEntity, +}: { + r: Rec; + onOpen: () => void; + onReorganize: () => void; + onDelete: () => void; + onOnlyEntity: () => void; +}) { + const [open, setOpen] = useState(false); + const run = (fn: () => void) => () => { + setOpen(false); + fn(); + }; + const failed = r.status === "failed" || r.status === "partial"; + return ( + + + + + e.stopPropagation()}> + } onClick={run(onOpen)}> + 查看详情 + + {canReorganize(r) ? ( + } onClick={run(onReorganize)}> + 重新整理 + ) : null} -
- ), - }, - { key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] }, - { key: "files", header: "文件", hideBelow: "md", cell: (r) => }, - { key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] }, - { - key: "at", - header: "时间", - hideBelow: "sm", - cell: (r) => ( - - {relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)} - - ), - }, -]; + {failed ? ( + } + onClick={run(() => + openAgentDrawer({ + label: `整理记录:${recordTitle(r)}`, + recordId: r.id, + entityId: r.media?.entityId, + goal: "排查这次整理为什么没有全部成功,给出恢复办法", + }), + )} + > + 让智能体排查 + + ) : null} + {r.media ? ( + } onClick={run(onOnlyEntity)}> + 只看这个作品 + + ) : null} + {!isRunning(r) ? ( + } onClick={run(onDelete)} danger> + 删除… + + ) : null} + + + ); +} + +/** 按状态计数:只列非零项,点一下按该状态筛选 */ +function StatsLine({ + filters, + status, + onStatus, +}: { + filters: Omit; + status: string; + onStatus: (s: string) => void; +}) { + const stats = useRecordStats(filters); + const s = stats.data; + if (!s || s.total === 0) return null; + const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [ + { key: "succeeded", label: "成功", n: s.succeeded }, + { key: "failed", label: "失败", n: s.failed, tone: "text-destructive" }, + { key: "partial", label: "部分成功", n: s.partial, tone: "text-warning" }, + { key: "running", label: "进行中", n: s.running }, + { key: "cancelled", label: "已取消", n: s.cancelled }, + ]; + return ( +
+ + {parts + .filter((p) => p.n > 0) + .map((p) => ( + + ))} +
+ ); +} export default function OrganizePage() { const [params, setParams] = useSearchParams(); - const [status, setStatus] = useState(""); - const [trigger, setTrigger] = useState(""); - const [qInput, setQInput] = useState(""); - const [q, setQ] = useState(""); + const f = useQueryState(["status", "trigger", "kind", "time", "q", "group"] as const); + const { set: setFilter, values: v } = f; + const [qInput, setQInput] = useState(v.q); const entityId = params.get("mediaEntityId") ?? undefined; // 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新) const ids = (params.get("ids") ?? "") @@ -100,59 +264,232 @@ export default function OrganizePage() { const navigate = useNavigate(); const qc = useQueryClient(); useEffect(() => { - const t = setTimeout(() => setQ(qInput.trim()), 400); + const t = setTimeout(() => setFilter("q", qInput.trim()), 400); return () => clearTimeout(t); - }, [qInput]); - const records = useRecords({ - status: (status || undefined) as OrganizationStatus | undefined, - trigger: (trigger || undefined) as OrganizationTrigger | undefined, + }, [qInput, setFilter]); + // 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变 + const since = useMemo(() => sinceOf(v.time), [v.time]); + const base: Omit = { + trigger: (v.trigger || undefined) as OrganizationTrigger | undefined, + kind: (v.kind || undefined) as RecordFilters["kind"], + since, mediaEntityId: entityId, - q: q || undefined, - }); + q: v.q || undefined, + }; + // 统计里的"进行中"= 排队 + 执行中;点它时列表按执行中筛 + const status = (v.status || undefined) as OrganizationStatus | undefined; + const records = useRecords({ ...base, status }); useSse([EventType.organizationChanged], () => { void qc.invalidateQueries({ queryKey: ["records"] }); if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] }); }); const pinned = ids.length > 0; const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items); - const filtered = !!(status || trigger || q || entityId || pinned); + const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned; + + const [selecting, setSelecting] = useState(false); + const [sel, setSel] = useState>(new Set()); + const [wizard, setWizard] = useState(null); + const [toDelete, setToDelete] = useState(null); + const grouped = v.group === "entity"; + + const selRows = (rows ?? []).filter((r) => sel.has(r.id)); + const allPicked = !!rows?.length && selRows.length === rows.length; + const pickAll = () => setSel(allPicked ? new Set() : new Set(rows?.map((r) => r.id))); + const toggle = (id: string) => + setSel((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const exitSelect = () => { + setSelecting(false); + setSel(new Set()); + }; + const reorganize = (list: Rec[]) => { + const ok = list.filter(canReorganize); + const first = ok[0]; + if (!first) return; + if (ok.length === 1) { + setWizard({ kind: "record", recordId: first.id }); + return; + } + const ents = new Set(ok.map((r) => r.media?.entityId ?? "")); + const m = first.media; + setWizard({ + kind: "records", + recordIds: ok.map((r) => r.id), + preset: + ents.size === 1 && m ? { id: m.entityId, external: null, title: m.title, year: m.year, kind: m.kind } : null, + }); + }; + + const pickColumn: DataColumn = { + key: "pick", + header: 0 && !allPicked} label="全选" onToggle={pickAll} />, + className: "w-10", + cell: (r) => toggle(r.id)} />, + }; + const columns: DataColumn[] = [ + ...(selecting ? [pickColumn] : []), + { + key: "media", + header: "作品", + cell: (r) => ( +
+ +
+

+ {r.media ? recordTitle(r) : 未识别 · {recordTitle(r)}} +

+

+ {[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))] + .filter(Boolean) + .join(" · ")} +

+ {r.status !== "succeeded" || r.reason ? ( +
+ +
+ ) : null} +
+
+ ), + }, + { key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => }, + { + key: "action", + header: "方式", + hideBelow: "sm", + cell: (r) => ( +
+

{ACTION_LABEL[r.action]}

+ {r.sizeBytes ? ( +

{formatBytes(r.sizeBytes, 1)}

+ ) : null} +
+ ), + }, + { key: "status", header: "状态", hideBelow: "sm", cell: (r) => }, + { + key: "trigger", + header: "来源", + hideBelow: "lg", + cell: (r) => ( +
+

{TRIGGER_LABEL[r.trigger]}

+ {r.download?.downloaderName ? ( +

+ {r.download.downloaderName} +

+ ) : null} +
+ ), + }, + { + key: "at", + header: "时间", + hideBelow: "sm", + cell: (r) => ( + + {relTime(when(r))} + + ), + }, + { + key: "menu", + header: 操作, + className: "w-10 pl-0", + cell: (r) => ( + navigate(`/console/organize/${r.id}`)} + onReorganize={() => reorganize([r])} + onDelete={() => setToDelete([r])} + onOnlyEntity={() => r.media && setParams({ mediaEntityId: r.media.entityId })} + /> + ), + }, + ]; + + const selReorg = selRows.filter(canReorganize).length; + const selDeletable = selRows.filter((r) => !isRunning(r)); return ( <> navigate("/console/files")}> - - 手动整理 - + <> + + + } /> { - setStatus(""); - setTrigger(""); setQInput(""); - setParams({}); + setParams(v.group ? { group: v.group } : {}); } : undefined } + actions={ + + } > setFilter("status", x)} options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))} /> + setFilter("kind", x)} + options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))} + /> setFilter("trigger", x)} options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))} /> + setFilter("time", x)} + options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))} + /> {entityId ? ( ) : null} + {pinned ? null : setFilter("status", s)} />} + {selecting ? ( +
+ 已选 {selRows.length} + + + + + +
+ ) : null} r.id} loading={pinned ? byIds.isPending : records.isPending} error={pinned ? byIds.error : records.error} onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} - onRowClick={(r) => navigate(`/console/organize/${r.id}`)} + onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))} + selectedKey={null} hasMore={!pinned && records.hasNextPage} onLoadMore={() => void records.fetchNextPage()} loadingMore={records.isFetchingNextPage} + groupBy={ + grouped + ? { + key: (r) => r.media?.entityId ?? "", + label: (k, g) => { + const m = g[0]?.media; + return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别"; + }, + } + : undefined + } empty={{ icon: FileStack, title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录", - description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。", + description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", }} /> + {wizard ? ( + { + setWizard(null); + exitSelect(); + }} + /> + ) : null} + { + if (!o) setToDelete(null); + }} + onDone={(removed) => + setSel((prev) => { + const next = new Set(prev); + for (const id of removed) next.delete(id); + return next; + }) + } + /> ); }