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:
rootandClaude Opus 5.5 committed 2026-09-30 23:57:19 +08:00
1 parent 51e82f1cd9
commit 03c651085f
8 files changed
+898 -110

No files matched your search

+44 -14
View File
@@ -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<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
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() {
全部记录
</Link>
</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)}>
<Wand2 />
重新整理
@@ -163,6 +158,12 @@ export default function OrganizeRecordPage() {
让智能体排查
</Button>
) : 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">
<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">
{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>
<span className="text-meta text-muted-foreground tabular-nums">
{[
`${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}` : ""}`,
},
]
: []),
]}
/>
<TechInfo
@@ -224,6 +243,7 @@ export default function OrganizeRecordPage() {
{ label: "变更序号", value: r.changeSeq },
{ label: "记录 ID", value: r.id, wide: true },
{ label: "任务 ID", value: r.taskId, wide: true },
{ label: "种子 hash", value: r.download?.hash, wide: true },
]}
/>
{r.media ? (
@@ -253,6 +273,16 @@ export default function OrganizeRecordPage() {
</section>
</div>
)}
{r ? (
<DeleteRecordsDialog
records={[r]}
open={removing}
onOpenChange={setRemoving}
onDone={(removed) => {
if (removed.includes(r.id)) navigate("/console/organize");
}}
/>
) : null}
{reorganize && r ? (
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
) : null}
+490 -93
View File
@@ -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 <span className="text-muted-foreground">成功</span>;
const line = reasonLine(r.reason);
return (
<span className="whitespace-nowrap tabular-nums">
<span className="text-foreground">
{c.succeeded}/{c.total}
</span>
{c.failed ? <span className="text-destructive"> · 失败 {c.failed}</span> : null}
{c.skipped ? <span className="text-muted-foreground"> · 跳过 {c.skipped}</span> : null}
</span>
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{line ? (
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
{line}
</span>
) : null}
</div>
);
}
const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
{
key: "media",
header: "作品 / 目标",
cell: (r) => (
<div className="min-w-0 max-w-md">
<p className="truncate text-foreground">
{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")}` : ""}`
: ""}
</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={r.target.path ?? undefined}>
{r.target.path ?? r.source.path ?? "—"}
</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (r) => (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{r.reason ? (
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 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 (
<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 className="truncate text-foreground" title={r.target.path ?? undefined}>
→ {shortPath(r.target.path) ?? (r.status === "failed" ? "未生成目标路径" : "—")}
</p>
</div>
);
}
function MenuItem({
icon,
children,
onClick,
danger = false,
}: {
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}
</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>
),
},
];
{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>
);
}
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<RecordFilters, "status"> = {
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<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 (
<>
<PageHeader
title="整理记录"
actions={
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
手动整理
</Button>
<>
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : <SquareCheck />}
{selecting ? "退出批量" : "批量"}
</Button>
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
手动整理
</Button>
</>
}
/>
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索作品或路径" }}
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
onReset={
filtered
? () => {
setStatus("");
setTrigger("");
setQInput("");
setParams({});
setParams(v.group ? { group: v.group } : {});
}
: undefined
}
actions={
<Button
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setFilter("group", grouped ? "" : "entity")}
>
<ListTree />
按作品分组
</Button>
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
value={v.status}
onChange={(x) => setFilter("status", x)}
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
label="来源"
value={trigger}
onChange={setTrigger}
value={v.trigger}
onChange={(x) => setFilter("trigger", x)}
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 ? (
<Button variant="outline" size="sm" onClick={() => setParams({})}>
只看该作品
@@ -166,23 +503,83 @@ export default function OrganizePage() {
</Button>
) : null}
</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
columns={COLUMNS}
columns={columns}
rows={rows}
rowKey={(r) => 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 ? (
<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;
})
}
/>
</>
);
}