feat(web): organize records page parity with MP history (HIS-1)
Poster, source -> target, size, downloader; type/time filters; status counts; group by work; row menu; batch select, delete and reorganize; delete confirm dialog with dry-run summary; detail delete and download info. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
51e82f1cd9
commit
03c651085f
8 files changed
+889
-101
No files matched your search
@@ -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 {
|
import {
|
||||||
cursorPage,
|
cursorPage,
|
||||||
type FileEntry,
|
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 };
|
export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null };
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
cursorPage,
|
cursorPage,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
LibraryRoot,
|
LibraryRoot,
|
||||||
|
type OrganizationDeleteRequest,
|
||||||
|
OrganizationDeleteResult,
|
||||||
|
OrganizationStats,
|
||||||
type OrganizationStatus,
|
type OrganizationStatus,
|
||||||
type OrganizationTrigger,
|
type OrganizationTrigger,
|
||||||
OrganizeAccepted,
|
OrganizeAccepted,
|
||||||
@@ -23,6 +26,10 @@ export type RecordFilters = {
|
|||||||
trigger?: OrganizationTrigger;
|
trigger?: OrganizationTrigger;
|
||||||
mediaEntityId?: string;
|
mediaEntityId?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
kind?: "movie" | "series" | "none";
|
||||||
|
/** ISO 时间:只看之后创建的 */
|
||||||
|
since?: string;
|
||||||
|
organizeOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordKeys = {
|
export const recordKeys = {
|
||||||
@@ -30,8 +37,34 @@ export const recordKeys = {
|
|||||||
list: (f: RecordFilters) => ["records", "list", f] as const,
|
list: (f: RecordFilters) => ["records", "list", f] as const,
|
||||||
one: (id: string) => ["records", "one", id] as const,
|
one: (id: string) => ["records", "one", id] as const,
|
||||||
entity: (id: string) => ["records", "entity", id] as const,
|
entity: (id: string) => ["records", "entity", id] as const,
|
||||||
|
stats: (f: Omit<RecordFilters, "status">) => ["records", "stats", f] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** HIS-1:按状态计数(筛选同列表,不含状态) */
|
||||||
|
export function useRecordStats(f: Omit<RecordFilters, "status">) {
|
||||||
|
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) {
|
export function useRecords(f: RecordFilters) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: recordKeys.list(f),
|
queryKey: recordKeys.list(f),
|
||||||
@@ -109,6 +142,18 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
|
|||||||
|
|
||||||
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "智能体" };
|
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { 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} 读取逐文件预览 */
|
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
|
||||||
export function useArtifact(id: string | null | undefined) {
|
export function useArtifact(id: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useIdentify,
|
useIdentify,
|
||||||
useOrganizeFiles,
|
useOrganizeFiles,
|
||||||
useRecordSources,
|
useRecordSources,
|
||||||
|
useRecordSourcesMany,
|
||||||
} from "~/api/files.ts";
|
} from "~/api/files.ts";
|
||||||
import { useRecord } from "~/api/records.ts";
|
import { useRecord } from "~/api/records.ts";
|
||||||
import { useSettings } from "~/api/settings.ts";
|
import { useSettings } from "~/api/settings.ts";
|
||||||
@@ -64,7 +65,9 @@ export type OrganizeSource =
|
|||||||
/** 做种中且 H&R 未满足:只能复制 */
|
/** 做种中且 H&R 未满足:只能复制 */
|
||||||
hnrBlocked?: boolean;
|
hnrBlocked?: boolean;
|
||||||
}
|
}
|
||||||
| { kind: "record"; recordId: string };
|
| { kind: "record"; recordId: string }
|
||||||
|
/** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */
|
||||||
|
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
|
||||||
|
|
||||||
const STEPS = ["文件与作品", "选项", "预览并执行"] as const;
|
const STEPS = ["文件与作品", "选项", "预览并执行"] as const;
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined
|
|||||||
});
|
});
|
||||||
const recSrc = useRecordSources(isRec ? source.recordId : null);
|
const recSrc = useRecordSources(isRec ? source.recordId : null);
|
||||||
const rec = useRecord(isRec ? source.recordId : undefined);
|
const rec = useRecord(isRec ? source.recordId : undefined);
|
||||||
|
const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []);
|
||||||
return useMemo((): Resolved => {
|
return useMemo((): Resolved => {
|
||||||
const base = { unresolved: [], preset: null, pending: false, error: null };
|
const base = { unresolved: [], preset: null, pending: false, error: null };
|
||||||
if (source.kind === "files")
|
if (source.kind === "files")
|
||||||
@@ -114,6 +118,28 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined
|
|||||||
label: `下载任务 · ${source.name}`,
|
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<string>();
|
||||||
|
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.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
|
||||||
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
|
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
|
||||||
const m = rec.data?.media;
|
const m = rec.data?.media;
|
||||||
@@ -128,7 +154,19 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined
|
|||||||
: null,
|
: null,
|
||||||
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
|
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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<PublicOrganizationRecord, "id" | "media" | "source" | "target" | "action">;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={single ? `删除「${recordTitle(single)}」的整理记录?` : `删除 ${records.length} 条整理记录?`}
|
||||||
|
confirmLabel={withFiles ? "删除记录和文件" : "删除记录"}
|
||||||
|
destructive
|
||||||
|
confirmDisabled={!plan || dry.isPending || deletable === 0}
|
||||||
|
onConfirm={() =>
|
||||||
|
exec
|
||||||
|
.mutateAsync({ ids, deleteSource: src, deleteTarget: dst, dryRun: false })
|
||||||
|
.then((r) => {
|
||||||
|
report(r);
|
||||||
|
onDone?.(r.removed);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error(errorMessage(e, "删除失败"));
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-caption">
|
||||||
|
{anyFiles ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2.5">
|
||||||
|
<input type="checkbox" className="mt-1" checked={src} onChange={(e) => setSrc(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
同时删除源文件
|
||||||
|
<span className="block text-meta text-muted-foreground">下载目录里整理前的文件。</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2.5">
|
||||||
|
<input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
同时删除媒体库里的文件
|
||||||
|
<span className="block text-meta text-muted-foreground">整理后放进媒体库的文件,会从媒体库移除。</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Summary plan={plan} pending={dry.isPending} error={dry.error} src={src} withFiles={withFiles} />
|
||||||
|
{blocked.length ? (
|
||||||
|
<div className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-warning">
|
||||||
|
<p>{blocked.length === records.length ? "这些记录都不能删:" : `${blocked.length} 条不能删,其余照删:`}</p>
|
||||||
|
<ul className="mt-1 space-y-0.5 text-meta">
|
||||||
|
{blocked.slice(0, 5).map((b) => {
|
||||||
|
const r = byId.get(b.id);
|
||||||
|
return (
|
||||||
|
<li key={b.id} className="break-all">
|
||||||
|
{r ? `${recordTitle(r)}:` : ""}
|
||||||
|
{b.message}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{blocked.length > 5 ? <li>还有 {blocked.length - 5} 条</li> : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({
|
||||||
|
plan,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
src,
|
||||||
|
withFiles,
|
||||||
|
}: {
|
||||||
|
plan: OrganizationDeleteResult | undefined;
|
||||||
|
pending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
src: boolean;
|
||||||
|
withFiles: boolean;
|
||||||
|
}) {
|
||||||
|
if (error) return <p className="text-destructive">{errorMessage(error, "核对失败")}</p>;
|
||||||
|
if (!plan) return <p className="text-muted-foreground">{pending ? "正在核对要删的文件…" : null}</p>;
|
||||||
|
const files = [
|
||||||
|
plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null,
|
||||||
|
plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-muted-foreground">
|
||||||
|
{files.length ? (
|
||||||
|
<p>
|
||||||
|
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
|
||||||
|
{plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
|
||||||
|
)}
|
||||||
|
{src && plan.sourceFiles ? <p>下载器里的任务不会删除。</p> : null}
|
||||||
|
{plan.autoRetry ? (
|
||||||
|
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PublicOrganizationRecord } from "@umt/contracts";
|
||||||
|
|
||||||
|
// 整理记录列表 / 详情 / 删除确认共用的展示小件(HIS-1)。
|
||||||
|
|
||||||
|
type Rec = Pick<PublicOrganizationRecord, "media" | "source" | "target">;
|
||||||
|
|
||||||
|
/** 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<PublicOrganizationRecord, "action" | "status">) =>
|
||||||
|
(r.action === "copy" || r.action === "move" || r.action === "hardlink") &&
|
||||||
|
r.status !== "queued" &&
|
||||||
|
r.status !== "running";
|
||||||
|
|
||||||
|
/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */
|
||||||
|
export const hasFiles = (r: Pick<PublicOrganizationRecord, "action">) =>
|
||||||
|
r.action === "copy" || r.action === "move" || r.action === "hardlink";
|
||||||
|
|
||||||
|
export const isRunning = (r: Pick<PublicOrganizationRecord, "status">) =>
|
||||||
|
r.status === "queued" || r.status === "running";
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
|
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 { useState } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
|
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 { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { KvList } from "~/components/console/kv-list.tsx";
|
import { KvList } from "~/components/console/kv-list.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
import { TechInfo } from "~/components/console/tech-info.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 { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { actorTypeLabel } from "~/lib/labels.ts";
|
import { actorTypeLabel } from "~/lib/labels.ts";
|
||||||
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
|
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.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<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
|
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
|
||||||
queued: { label: "排队", tone: "neutral" },
|
queued: { label: "排队", tone: "neutral" },
|
||||||
running: { label: "执行中", tone: "neutral" },
|
running: { label: "执行中", tone: "neutral" },
|
||||||
@@ -124,6 +117,8 @@ export default function OrganizeRecordPage() {
|
|||||||
const rec = useRecord(id);
|
const rec = useRecord(id);
|
||||||
const r = rec.data;
|
const r = rec.data;
|
||||||
const [reorganize, setReorganize] = useState(false);
|
const [reorganize, setReorganize] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -140,7 +135,7 @@ export default function OrganizeRecordPage() {
|
|||||||
全部记录
|
全部记录
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{r && (r.action === "copy" || r.action === "move") && r.status !== "queued" && r.status !== "running" ? (
|
{r && canReorganize(r) ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
|
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
|
||||||
<Wand2 />
|
<Wand2 />
|
||||||
重新整理
|
重新整理
|
||||||
@@ -163,6 +158,12 @@ export default function OrganizeRecordPage() {
|
|||||||
让智能体排查
|
让智能体排查
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{r && !isRunning(r) ? (
|
||||||
|
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setRemoving(true)}>
|
||||||
|
<Trash2 />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -176,10 +177,20 @@ export default function OrganizeRecordPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
|
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{r.media ? (
|
||||||
|
<CoverPlaceholder
|
||||||
|
small
|
||||||
|
title={r.media.title}
|
||||||
|
kind={r.media.kind}
|
||||||
|
posterPath={r.media.posterPath}
|
||||||
|
className="mr-1 h-12 w-8 rounded-md"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||||
<span className="text-meta text-muted-foreground tabular-nums">
|
<span className="text-meta text-muted-foreground tabular-nums">
|
||||||
{[
|
{[
|
||||||
`${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`,
|
`${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.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null,
|
||||||
r.counts.failed ? `${r.counts.failed} 个失败` : null,
|
r.counts.failed ? `${r.counts.failed} 个失败` : null,
|
||||||
r.counts.skipped ? `${r.counts.skipped} 个跳过` : 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.startedAt) },
|
||||||
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
||||||
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
|
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
|
||||||
|
...(r.download
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "下载任务",
|
||||||
|
value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<TechInfo
|
<TechInfo
|
||||||
@@ -224,6 +243,7 @@ export default function OrganizeRecordPage() {
|
|||||||
{ label: "变更序号", value: r.changeSeq },
|
{ label: "变更序号", value: r.changeSeq },
|
||||||
{ label: "记录 ID", value: r.id, wide: true },
|
{ label: "记录 ID", value: r.id, wide: true },
|
||||||
{ label: "任务 ID", value: r.taskId, wide: true },
|
{ label: "任务 ID", value: r.taskId, wide: true },
|
||||||
|
{ label: "种子 hash", value: r.download?.hash, wide: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{r.media ? (
|
{r.media ? (
|
||||||
@@ -253,6 +273,16 @@ export default function OrganizeRecordPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{r ? (
|
||||||
|
<DeleteRecordsDialog
|
||||||
|
records={[r]}
|
||||||
|
open={removing}
|
||||||
|
onOpenChange={setRemoving}
|
||||||
|
onDone={(removed) => {
|
||||||
|
if (removed.includes(r.id)) navigate("/console/organize");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{reorganize && r ? (
|
{reorganize && r ? (
|
||||||
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
|
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,95 +1,259 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
|
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
|
||||||
import { EventType } from "@umt/contracts";
|
import { EventType } from "@umt/contracts";
|
||||||
import { FileStack, FolderInput, X } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
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 { useNavigate, useSearchParams } from "react-router";
|
||||||
import { useFileRecords } from "~/api/files.ts";
|
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 { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.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 { 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 { reasonLine } from "~/lib/reasons.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集、源 → 目标路径、整理方式与大小、状态与原因、来源(下载器)、时间;
|
||||||
copy: "复制",
|
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;按作品分组;
|
||||||
move: "移动",
|
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
||||||
rename: "重命名",
|
|
||||||
relocate: "目录调整",
|
|
||||||
hardlink: "硬链接",
|
|
||||||
trash: "移入回收站",
|
|
||||||
delete: "永久删除",
|
|
||||||
restore: "从回收站还原",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function Counts({ r }: { r: PublicOrganizationRecord }) {
|
type Rec = PublicOrganizationRecord;
|
||||||
const c = r.counts;
|
|
||||||
|
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 <span className="text-muted-foreground">成功</span>;
|
||||||
|
const line = reasonLine(r.reason);
|
||||||
return (
|
return (
|
||||||
<span className="whitespace-nowrap tabular-nums">
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
<span className="text-foreground">
|
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||||
{c.succeeded}/{c.total}
|
{line ? (
|
||||||
</span>
|
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
|
||||||
{c.failed ? <span className="text-destructive"> · 失败 {c.failed}</span> : null}
|
{line}
|
||||||
{c.skipped ? <span className="text-muted-foreground"> · 跳过 {c.skipped}</span> : null}
|
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
|
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */
|
||||||
{
|
function shortPath(p: string | null): string | null {
|
||||||
key: "media",
|
if (!p) return p;
|
||||||
header: "作品 / 目标",
|
const parts = p.split("/").filter(Boolean);
|
||||||
cell: (r) => (
|
return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : p;
|
||||||
<div className="min-w-0 max-w-md">
|
}
|
||||||
<p className="truncate text-foreground">
|
|
||||||
{r.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : "未关联作品"}
|
function PathLines({ r }: { r: Rec }) {
|
||||||
{r.media?.seasonNumber != null
|
return (
|
||||||
? ` S${String(r.media.seasonNumber).padStart(2, "0")}${r.media.episodeNumber != null ? `E${String(r.media.episodeNumber).padStart(2, "0")}` : ""}`
|
<div className="min-w-0 max-w-sm font-mono text-meta">
|
||||||
: ""}
|
<p className="truncate text-muted-foreground" title={r.source.path ?? undefined}>
|
||||||
|
{shortPath(r.source.path) ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.target.path ?? undefined}>
|
<p className="truncate text-foreground" title={r.target.path ?? undefined}>
|
||||||
{r.target.path ?? r.source.path ?? "—"}
|
→ {shortPath(r.target.path) ?? (r.status === "failed" ? "未生成目标路径" : "—")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
},
|
}
|
||||||
{
|
|
||||||
key: "status",
|
function MenuItem({
|
||||||
header: "状态",
|
icon,
|
||||||
cell: (r) => (
|
children,
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
onClick,
|
||||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
danger = false,
|
||||||
{r.reason ? (
|
}: {
|
||||||
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-caption transition-colors duration-micro hover:bg-foreground/[0.06] [&_svg]:h-4 [&_svg]:w-4",
|
||||||
|
danger ? "text-destructive" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`操作:${recordTitle(r)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-44 p-1.5" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<MenuItem icon={<Eye />} onClick={run(onOpen)}>
|
||||||
|
查看详情
|
||||||
|
</MenuItem>
|
||||||
|
{canReorganize(r) ? (
|
||||||
|
<MenuItem icon={<Wand2 />} onClick={run(onReorganize)}>
|
||||||
|
重新整理
|
||||||
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{failed ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={<Bot />}
|
||||||
|
onClick={run(() =>
|
||||||
|
openAgentDrawer({
|
||||||
|
label: `整理记录:${recordTitle(r)}`,
|
||||||
|
recordId: r.id,
|
||||||
|
entityId: r.media?.entityId,
|
||||||
|
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
让智能体排查
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{r.media ? (
|
||||||
|
<MenuItem icon={<ListTree />} onClick={run(onOnlyEntity)}>
|
||||||
|
只看这个作品
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{!isRunning(r) ? (
|
||||||
|
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
||||||
|
删除…
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按状态计数:只列非零项,点一下按该状态筛选 */
|
||||||
|
function StatsLine({
|
||||||
|
filters,
|
||||||
|
status,
|
||||||
|
onStatus,
|
||||||
|
}: {
|
||||||
|
filters: Omit<RecordFilters, "status">;
|
||||||
|
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 (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-x-1 gap-y-1 text-caption text-muted-foreground tabular-nums">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatus("")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||||
|
!status && "bg-foreground/[0.06] text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
共 {s.total} 条
|
||||||
|
</button>
|
||||||
|
{parts
|
||||||
|
.filter((p) => p.n > 0)
|
||||||
|
.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={status === p.key}
|
||||||
|
onClick={() => onStatus(status === p.key ? "" : p.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||||
|
p.tone,
|
||||||
|
status === p.key && "bg-foreground/[0.06]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label} {p.n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
},
|
}
|
||||||
{ key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] },
|
|
||||||
{ key: "files", header: "文件", hideBelow: "md", cell: (r) => <Counts r={r} /> },
|
|
||||||
{ key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] },
|
|
||||||
{
|
|
||||||
key: "at",
|
|
||||||
header: "时间",
|
|
||||||
hideBelow: "sm",
|
|
||||||
cell: (r) => (
|
|
||||||
<span className="whitespace-nowrap text-muted-foreground">
|
|
||||||
{relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function OrganizePage() {
|
export default function OrganizePage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [status, setStatus] = useState("");
|
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group"] as const);
|
||||||
const [trigger, setTrigger] = useState("");
|
const { set: setFilter, values: v } = f;
|
||||||
const [qInput, setQInput] = useState("");
|
const [qInput, setQInput] = useState(v.q);
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||||
// 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新)
|
// 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新)
|
||||||
const ids = (params.get("ids") ?? "")
|
const ids = (params.get("ids") ?? "")
|
||||||
@@ -100,59 +264,232 @@ export default function OrganizePage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setQ(qInput.trim()), 400);
|
const t = setTimeout(() => setFilter("q", qInput.trim()), 400);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [qInput]);
|
}, [qInput, setFilter]);
|
||||||
const records = useRecords({
|
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
|
||||||
status: (status || undefined) as OrganizationStatus | undefined,
|
const since = useMemo(() => sinceOf(v.time), [v.time]);
|
||||||
trigger: (trigger || undefined) as OrganizationTrigger | undefined,
|
const base: Omit<RecordFilters, "status"> = {
|
||||||
|
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
|
||||||
|
kind: (v.kind || undefined) as RecordFilters["kind"],
|
||||||
|
since,
|
||||||
mediaEntityId: entityId,
|
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], () => {
|
useSse([EventType.organizationChanged], () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["records"] });
|
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||||
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
||||||
});
|
});
|
||||||
const pinned = ids.length > 0;
|
const pinned = ids.length > 0;
|
||||||
const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
|
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<Set<string>>(new Set());
|
||||||
|
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
|
||||||
|
const [toDelete, setToDelete] = useState<Rec[] | null>(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<Rec> = {
|
||||||
|
key: "pick",
|
||||||
|
header: <Checkbox checked={allPicked} partial={selRows.length > 0 && !allPicked} label="全选" onToggle={pickAll} />,
|
||||||
|
className: "w-10",
|
||||||
|
cell: (r) => <Checkbox checked={sel.has(r.id)} label={`选择 ${recordTitle(r)}`} onToggle={() => toggle(r.id)} />,
|
||||||
|
};
|
||||||
|
const columns: DataColumn<Rec>[] = [
|
||||||
|
...(selecting ? [pickColumn] : []),
|
||||||
|
{
|
||||||
|
key: "media",
|
||||||
|
header: "作品",
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="flex min-w-0 max-w-md items-center gap-3">
|
||||||
|
<CoverPlaceholder
|
||||||
|
small
|
||||||
|
title={r.media?.title ?? "?"}
|
||||||
|
kind={r.media?.kind ?? "other"}
|
||||||
|
posterPath={r.media?.posterPath}
|
||||||
|
className="h-12 w-8 rounded-md"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-foreground" title={recordTitle(r)}>
|
||||||
|
{r.media ? recordTitle(r) : <span className="text-muted-foreground">未识别 · {recordTitle(r)}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-meta text-muted-foreground sm:hidden">
|
||||||
|
{[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
{r.status !== "succeeded" || r.reason ? (
|
||||||
|
<div className="mt-1 sm:hidden">
|
||||||
|
<StatusCell r={r} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => <PathLines r={r} /> },
|
||||||
|
{
|
||||||
|
key: "action",
|
||||||
|
header: "方式",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<p>{ACTION_LABEL[r.action]}</p>
|
||||||
|
{r.sizeBytes ? (
|
||||||
|
<p className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "status", header: "状态", hideBelow: "sm", cell: (r) => <StatusCell r={r} /> },
|
||||||
|
{
|
||||||
|
key: "trigger",
|
||||||
|
header: "来源",
|
||||||
|
hideBelow: "lg",
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<p>{TRIGGER_LABEL[r.trigger]}</p>
|
||||||
|
{r.download?.downloaderName ? (
|
||||||
|
<p className="max-w-32 truncate text-meta text-muted-foreground" title={r.download.name}>
|
||||||
|
{r.download.downloaderName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "at",
|
||||||
|
header: "时间",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (r) => (
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(when(r))}>
|
||||||
|
{relTime(when(r))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "menu",
|
||||||
|
header: <span className="sr-only">操作</span>,
|
||||||
|
className: "w-10 pl-0",
|
||||||
|
cell: (r) => (
|
||||||
|
<RowMenu
|
||||||
|
r={r}
|
||||||
|
onOpen={() => 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="整理记录"
|
title="整理记录"
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={selecting ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||||
|
>
|
||||||
|
{selecting ? <X /> : <SquareCheck />}
|
||||||
|
{selecting ? "退出批量" : "批量"}
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||||
<FolderInput />
|
<FolderInput />
|
||||||
手动整理
|
手动整理
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterBar
|
<FilterBar
|
||||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索作品或路径" }}
|
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||||
onReset={
|
onReset={
|
||||||
filtered
|
filtered
|
||||||
? () => {
|
? () => {
|
||||||
setStatus("");
|
|
||||||
setTrigger("");
|
|
||||||
setQInput("");
|
setQInput("");
|
||||||
setParams({});
|
setParams(v.group ? { group: v.group } : {});
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant={grouped ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={grouped}
|
||||||
|
onClick={() => setFilter("group", grouped ? "" : "entity")}
|
||||||
|
>
|
||||||
|
<ListTree />
|
||||||
|
按作品分组
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="状态"
|
label="状态"
|
||||||
value={status}
|
value={v.status}
|
||||||
onChange={setStatus}
|
onChange={(x) => setFilter("status", x)}
|
||||||
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="类型"
|
||||||
|
value={v.kind}
|
||||||
|
onChange={(x) => setFilter("kind", x)}
|
||||||
|
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="来源"
|
label="来源"
|
||||||
value={trigger}
|
value={v.trigger}
|
||||||
onChange={setTrigger}
|
onChange={(x) => setFilter("trigger", x)}
|
||||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="时间"
|
||||||
|
value={v.time}
|
||||||
|
allLabel="不限"
|
||||||
|
onChange={(x) => setFilter("time", x)}
|
||||||
|
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
{entityId ? (
|
{entityId ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||||
只看该作品
|
只看该作品
|
||||||
@@ -166,23 +503,83 @@ export default function OrganizePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
|
||||||
|
{selecting ? (
|
||||||
|
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||||
|
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={pickAll}>
|
||||||
|
{allPicked ? "取消全选" : "全选"}
|
||||||
|
</Button>
|
||||||
|
<span className="ml-auto flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
||||||
|
<Wand2 />
|
||||||
|
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive"
|
||||||
|
disabled={!selDeletable.length}
|
||||||
|
onClick={() => setToDelete(selDeletable)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={COLUMNS}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
loading={pinned ? byIds.isPending : records.isPending}
|
loading={pinned ? byIds.isPending : records.isPending}
|
||||||
error={pinned ? byIds.error : records.error}
|
error={pinned ? byIds.error : records.error}
|
||||||
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
|
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}
|
hasMore={!pinned && records.hasNextPage}
|
||||||
onLoadMore={() => void records.fetchNextPage()}
|
onLoadMore={() => void records.fetchNextPage()}
|
||||||
loadingMore={records.isFetchingNextPage}
|
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={{
|
empty={{
|
||||||
icon: FileStack,
|
icon: FileStack,
|
||||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||||
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{wizard ? (
|
||||||
|
<OrganizeWizard
|
||||||
|
source={wizard}
|
||||||
|
onClose={() => {
|
||||||
|
setWizard(null);
|
||||||
|
exitSelect();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DeleteRecordsDialog
|
||||||
|
records={toDelete ?? []}
|
||||||
|
open={!!toDelete}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setToDelete(null);
|
||||||
|
}}
|
||||||
|
onDone={(removed) =>
|
||||||
|
setSel((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of removed) next.delete(id);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user