feat(web): 整理页只留整理记录,整理队列改成标题栏按钮,记录加维度筛选

- 标题栏「队列 N」(排队 + 执行中,含刮削)点开右侧面板看队列;面板开关在 URL(?queue=1),
  旧链接 ?tab=queue 打开面板、?tab=records 去掉;整理向导「整理队列」跳到面板。
- 筛选学 MP:状态、类型、整理方式、媒体库目录、分类、来源、时间做成维度按钮(单选,带条数),
  已选条件(含作品、需要处理、关键词、本次整理)排成可移除标签 +「清除」;按作品分组照旧。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 17:50:28 +08:00
1 parent eb294b0ffa
commit 83be47aea2
8 files changed
+587 -196

No files matched your search

+1 -1
View File
@@ -133,7 +133,7 @@ export default function OrganizeRecordPage() {
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to="/console/organize?tab=records">
<Link to="/console/organize">
<ArrowLeft />
全部记录
</Link>
+180 -180
View File
@@ -1,5 +1,10 @@
import { useQueryClient } from "@tanstack/react-query";
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
import type {
OrganizationStats,
OrganizationStatus,
OrganizationTrigger,
PublicOrganizationRecord,
} from "@umt/contracts";
import { EventType } from "@umt/contracts";
import {
Bot,
@@ -16,18 +21,20 @@ import {
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useFileRecords } from "~/api/files.ts";
import { useEntityDetail } from "~/api/inventory.ts";
import {
ACTION_LABEL,
RECORD_STATUS_LABEL,
RECORD_STATUS_TONE,
type RecordFilters,
TRIGGER_LABEL,
useLibraryRoots,
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 { FilterBar } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
@@ -35,8 +42,15 @@ 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 { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
import { OrganizeQueueButton } from "~/components/organize/organize-queue.tsx";
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
import {
buildDims,
filterTags,
normalizeLegacyParams,
RECORD_FILTER_KEYS,
} from "~/components/organize/record-filter-model.ts";
import { RecordFilterChips, RecordFilterTags } from "~/components/organize/record-filters.tsx";
import {
canReorganize,
episodeRange,
@@ -54,7 +68,6 @@ import { SourceSelection } from "~/components/organize/source-selection.tsx";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { reasonLine } from "~/lib/reasons.ts";
import { useSse } from "~/lib/sse.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -65,12 +78,11 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
// 界面第二轮:整理页只有整理记录一页;整理队列是标题栏的「队列 N」按钮,点开右侧面板(?queue=1,旧链接 ?tab=queue 兼容)。
// 筛选按 MP 做成维度按钮(状态、类型、整理方式、媒体库目录、分类、来源、时间)+ 可移除的条件标签;作品条件从行菜单「只看这个作品」来。
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();
@@ -111,7 +123,7 @@ function StatusCell({ r, onReorganize }: { r: Rec; onReorganize?: () => void })
);
}
/** 整理方式(移动 / 复制 / 硬链接 / 原地入库…):中性小标签,不用状态色 */
/** 整理方式(移动 / 复制 / 硬链接 / 原地整理…):中性小标签,不用状态色 */
function ActionTag({ action }: { action: Rec["action"] }) {
return (
<span className="inline-flex items-center rounded-tight border border-border px-1.5 py-px text-meta whitespace-nowrap text-muted-foreground">
@@ -276,20 +288,18 @@ function RowMenu({
/** 按状态计数:只列非零项,点一下按该状态筛选 */
function StatsLine({
filters,
stats: s,
status,
onStatus,
attention,
onAttention,
}: {
filters: Omit<RecordFilters, "status">;
stats: OrganizationStats | undefined;
status: string;
onStatus: (s: string) => void;
attention: boolean;
onAttention: (on: boolean) => void;
}) {
const stats = useRecordStats({ ...filters, attention: undefined });
const s = stats.data;
if (!s || (s.total === 0 && !s.attention)) return null;
const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [
{ key: "succeeded", label: "成功", n: s.succeeded },
@@ -346,27 +356,24 @@ function StatsLine({
export default function OrganizePage() {
const [params, setParams] = useSearchParams();
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group", "attention"] as const);
const f = useQueryState(RECORD_FILTER_KEYS);
const { set: setFilter, values: v } = f;
const [qInput, setQInput] = useState(v.q);
const entityId = params.get("mediaEntityId") ?? undefined;
const legacyRecords = !!(
entityId ||
params.get("ids") ||
v.q ||
v.trigger ||
v.kind ||
v.time ||
v.status ||
v.attention
);
const attention = v.attention === "1";
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
const setTab = (next: string) =>
// 旧链接:?tab=queue → 打开队列面板;?tab=records → 去掉
useEffect(() => {
const next = normalizeLegacyParams(params);
if (next) setParams(next, { replace: true });
}, [params, setParams]);
const queueOpen = params.get("queue") === "1" || params.get("tab") === "queue";
const setQueueOpen = (open: boolean) =>
setParams(
(previous) => {
const updated = new URLSearchParams(previous);
updated.set("tab", next);
updated.delete("tab");
if (open) updated.set("queue", "1");
else updated.delete("queue");
return updated;
},
{ replace: true },
@@ -391,6 +398,9 @@ export default function OrganizePage() {
organizeOnly: true,
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
kind: (v.kind || undefined) as RecordFilters["kind"],
action: (v.action || undefined) as RecordFilters["action"],
libraryRootId: v.root || undefined,
category: v.category || undefined,
since,
mediaEntityId: entityId,
q: v.q || undefined,
@@ -398,6 +408,8 @@ export default function OrganizePage() {
// 统计里的"进行中"= 排队 + 执行中;点它时列表按执行中筛
const status = (v.status || undefined) as OrganizationStatus | undefined;
const records = useRecords({ ...base, status });
const stats = useRecordStats({ ...base, attention: undefined });
const roots = useLibraryRoots();
useSse([EventType.organizationChanged], () => {
void qc.invalidateQueries({ queryKey: ["records"] });
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
@@ -412,7 +424,46 @@ export default function OrganizePage() {
resetKey: JSON.stringify({ ...base, status, ids }),
});
const rows = paged.items;
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q || attention) || !!entityId || pinned;
const filtered =
!!(v.status || v.trigger || v.kind || v.time || v.q || v.action || v.root || v.category || attention) ||
!!entityId ||
pinned;
const dims = buildDims(v, {
statusLabel: RECORD_STATUS_LABEL,
triggerLabel: TRIGGER_LABEL,
facets: stats.data?.facets,
rootName: (id) => roots.data?.items.find((r) => r.id === id)?.name ?? "已移除的目录",
});
const entity = useEntityDetail(entityId);
const tags = filterTags(v, dims, {
entityId,
entityTitle: entity.data?.entity.title ?? allRows?.find((r) => r.media?.entityId === entityId)?.media?.title,
pinned: ids.length,
});
const removeTag = (key: string) => {
if (key === "ids" || key === "mediaEntityId") {
setParams(
(previous) => {
const updated = new URLSearchParams(previous);
updated.delete(key);
return updated;
},
{ replace: true },
);
return;
}
if (key === "q") setQInput("");
setFilter(key as (typeof RECORD_FILTER_KEYS)[number], "");
};
// 清除:去掉全部条件,保留分组方式与队列面板
const clearAll = () => {
setQInput("");
const keep: Record<string, string> = {};
if (v.group) keep.group = v.group;
if (queueOpen) keep.queue = "1";
setParams(keep);
};
const [selecting, setSelecting] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set());
@@ -577,7 +628,12 @@ export default function OrganizePage() {
onOpen={() => navigate(`/console/organize/${r.id}`)}
onReorganize={() => reorganize([r])}
onDelete={() => setToDelete([r])}
onOnlyEntity={() => r.media && setParams({ tab: "records", mediaEntityId: r.media.entityId })}
onOnlyEntity={() =>
r.media &&
setParams(
v.group ? { mediaEntityId: r.media.entityId, group: v.group } : { mediaEntityId: r.media.entityId },
)
}
/>
),
},
@@ -589,19 +645,18 @@ export default function OrganizePage() {
return (
<>
<PageHeader
title="整理"
title="整理记录"
actions={
<>
{tab === "records" ? (
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : <SquareCheck />}
{selecting ? "退出批量" : "批量"}
</Button>
) : null}
<OrganizeQueueButton open={queueOpen} onOpenChange={setQueueOpen} />
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : <SquareCheck />}
{selecting ? "退出批量" : "批量"}
</Button>
<Button size="sm" onClick={() => setChoosingSource(true)}>
<FolderInput />
手动整理
@@ -609,148 +664,93 @@ export default function OrganizePage() {
</>
}
/>
<Tabs value={tab} onValueChange={setTab}>
<TabsList aria-label="整理视图">
<TabsTrigger value="queue">整理队列</TabsTrigger>
<TabsTrigger value="records">整理记录</TabsTrigger>
</TabsList>
<TabsContent value="queue">
<OrganizeQueue />
</TabsContent>
<TabsContent value="records">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
onReset={
filtered
? () => {
setQInput("");
setParams(v.group ? { tab: "records", group: v.group } : { tab: "records" });
}
: undefined
}
actions={
<Button
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setGrouping(grouped ? "none" : "entity")}
>
<ListTree />
按作品分组
</Button>
}
<FilterBar
className="mb-2"
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
actions={
<Button
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setGrouping(grouped ? "none" : "entity")}
>
<FilterSelect
label="状态"
value={v.status}
onChange={(x) => setFilter("status", x)}
options={Object.entries(RECORD_STATUS_LABEL)
.filter(([value]) => value !== "queued" && value !== "running")
.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={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({ tab: "records" })}>
只看该作品
<X />
</Button>
) : null}
{pinned ? (
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
本次整理的 {ids.length} 条
<X />
</Button>
) : null}
</FilterBar>
{pinned ? null : (
<StatsLine
filters={base}
status={v.status}
onStatus={(s) => setFilter("status", s)}
attention={attention}
onAttention={(on) => setFilter("attention", on ? "1" : "")}
/>
)}
{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}
rows={rows}
pagination={paged.pagination}
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) => (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 ?? `none:${r.id}`,
label: (_k, g) => <GroupLabel rows={g} />,
meta: (_k, g) => <GroupMeta rows={g} />,
flat: (_k, g) => g.length === 1,
defaultCollapsed: true,
storageKey: "organize-records-entity",
}
: undefined
}
empty={{
icon: FileStack,
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: attention
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
: filtered
? undefined
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
</TabsContent>
</Tabs>
<ListTree />
按作品分组
</Button>
}
>
<RecordFilterChips dims={dims} values={v} onChange={(key, value) => setFilter(key, value)} />
</FilterBar>
<RecordFilterTags tags={tags} onRemove={removeTag} onClear={clearAll} />
{pinned ? null : (
<StatsLine
stats={stats.data}
status={v.status}
onStatus={(s) => setFilter("status", s)}
attention={attention}
onAttention={(on) => setFilter("attention", on ? "1" : "")}
/>
)}
{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}
rows={rows}
pagination={paged.pagination}
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) => (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 ?? `none:${r.id}`,
label: (_k, g) => <GroupLabel rows={g} />,
meta: (_k, g) => <GroupMeta rows={g} />,
flat: (_k, g) => g.length === 1,
defaultCollapsed: true,
storageKey: "organize-records-entity",
}
: undefined
}
empty={{
icon: FileStack,
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: attention
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
: filtered
? undefined
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
{choosingSource ? (
<SourceSelection
onClose={() => setChoosingSource(false)}