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:
1 parent
eb294b0ffa
commit
83be47aea2
8 files changed
+497
-106
No files matched your search
@@ -34,6 +34,12 @@ export type RecordFilters = {
|
||||
organizeOnly?: boolean;
|
||||
/** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */
|
||||
attention?: boolean;
|
||||
/** 整理方式 */
|
||||
action?: "copy" | "move" | "hardlink" | "in_place";
|
||||
/** 目标媒体库目录 */
|
||||
libraryRootId?: string;
|
||||
/** 冻结计划里的二级分类 */
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export const recordKeys = {
|
||||
|
||||
@@ -861,7 +861,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
onClick={() => {
|
||||
if (submitting.current) return;
|
||||
onClose();
|
||||
navigate("/console/organize?tab=queue");
|
||||
navigate("/console/organize?queue=1");
|
||||
}}
|
||||
>
|
||||
<FileStack />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { EngineRun } from "@umt/contracts";
|
||||
import { FileStack, RefreshCw } from "lucide-react";
|
||||
import { FileStack, ListOrdered, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useDownloadTask } from "~/api/downloads.ts";
|
||||
import { useEngineRun, useEngineRuns } from "~/api/engine.ts";
|
||||
import { useEngineRun, useEngineRuns, useEngineRunsPage } from "~/api/engine.ts";
|
||||
import { useScrapeRecords } from "~/api/files.ts";
|
||||
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
|
||||
import { useRecordsForDownload } from "~/api/tasks.ts";
|
||||
@@ -13,9 +13,11 @@ import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { relTime } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
|
||||
import { queueCountLabel } from "./record-filter-model.ts";
|
||||
import { recordFileProgress, recordTitle } from "./record-parts.ts";
|
||||
|
||||
function ScrapeQueueItem({ run }: { run: EngineRun }) {
|
||||
@@ -81,7 +83,7 @@ function QueueItem({ run }: { run: EngineRun }) {
|
||||
const download = useDownloadTask(downloadId);
|
||||
const record = recordQuery.data ?? matchingRecord;
|
||||
const progress = record ? recordFileProgress(record) : null;
|
||||
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验入库任务");
|
||||
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验整理任务");
|
||||
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
|
||||
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
|
||||
return (
|
||||
@@ -135,18 +137,19 @@ function QueueItem({ run }: { run: EngineRun }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Existing engine workflows are authoritative, including jobs that have not created a record yet. */
|
||||
export function OrganizeQueue() {
|
||||
const QUEUE_FILTER = { workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" } as const;
|
||||
|
||||
function useQueueInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
const runs = useEngineRuns(
|
||||
{ workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" },
|
||||
true,
|
||||
5_000,
|
||||
);
|
||||
useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.all("engine") });
|
||||
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||
});
|
||||
}
|
||||
|
||||
/** 排队与执行中的整理 / 刮削任务;以引擎运行为准(含还没生成整理记录的任务) */
|
||||
export function OrganizeQueue() {
|
||||
const runs = useEngineRuns(QUEUE_FILTER, true, 5_000);
|
||||
const rows = runs.data?.pages.flatMap((page) => page.items);
|
||||
const paged = useListPagination(rows, {
|
||||
hasMore: runs.hasNextPage,
|
||||
@@ -157,10 +160,7 @@ export function OrganizeQueue() {
|
||||
});
|
||||
return (
|
||||
<section aria-label="排队与执行中的整理任务" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
|
||||
<RefreshCw />
|
||||
刷新
|
||||
@@ -187,3 +187,44 @@ export function OrganizeQueue() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const COUNT_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* 整理队列入口(对照 MP 整理历史页的「整理队列」按钮):标题栏一个按钮写当前排队 + 执行中的任务数,
|
||||
* 点开右侧面板看队列。面板开关由页面放在 URL(?queue=1),旧链接 ?tab=queue 也落到这里。
|
||||
*/
|
||||
export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
useQueueInvalidation();
|
||||
const page = useEngineRunsPage(QUEUE_FILTER, COUNT_LIMIT, 10_000);
|
||||
const items = page.data?.items ?? [];
|
||||
const running = items.some((r) => r.status === "running");
|
||||
const count = queueCountLabel(items.length, !!page.data?.nextCursor);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={count ? `整理队列:${count} 个任务` : "整理队列"}
|
||||
onClick={() => onOpenChange(true)}
|
||||
>
|
||||
{running ? <Loader2 className="animate-spin" /> : <ListOrdered />}
|
||||
队列
|
||||
{count ? <span className="tabular-nums">{count}</span> : null}
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-xl">
|
||||
<SheetHeader className="pr-12">
|
||||
<SheetTitle>整理队列</SheetTitle>
|
||||
<SheetDescription className="text-meta">
|
||||
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{open ? <OrganizeQueue /> : null}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RECORD_STATUS_LABEL, TRIGGER_LABEL } from "~/api/records.ts";
|
||||
import { buildDims, filterTags, normalizeLegacyParams, queueCountLabel } from "./record-filter-model.ts";
|
||||
|
||||
const ctx = {
|
||||
statusLabel: RECORD_STATUS_LABEL,
|
||||
triggerLabel: TRIGGER_LABEL,
|
||||
rootName: (id: string) => ({ r1: "电影库", r2: "剧集库" })[id] ?? "已移除的目录",
|
||||
facets: {
|
||||
action: [
|
||||
{ value: "copy", n: 12 },
|
||||
{ value: "move", n: 3 },
|
||||
],
|
||||
libraryRootId: [
|
||||
{ value: "r1", n: 10 },
|
||||
{ value: "r2", n: 5 },
|
||||
],
|
||||
category: [{ value: "欧美电影", n: 7 }],
|
||||
},
|
||||
};
|
||||
|
||||
describe("整理记录筛选维度", () => {
|
||||
it("维度顺序与选项:状态不含排队 / 执行中,整理方式与目录来自统计并带条数", () => {
|
||||
const dims = buildDims({}, ctx);
|
||||
expect(dims.map((d) => d.label)).toEqual(["状态", "类型", "整理方式", "媒体库目录", "分类", "来源", "时间"]);
|
||||
expect(dims[0]!.options.map((o) => o.value)).toEqual(["succeeded", "partial", "failed", "cancelled"]);
|
||||
expect(dims[2]!.options).toEqual([
|
||||
{ value: "copy", label: "复制", n: 12 },
|
||||
{ value: "move", label: "移动", n: 3 },
|
||||
]);
|
||||
expect(dims[3]!.options.map((o) => o.label)).toEqual(["电影库", "剧集库"]);
|
||||
});
|
||||
|
||||
it("已选值当前没有记录时仍列出,免得选中项消失", () => {
|
||||
const dims = buildDims({ category: "纪录片", action: "in_place" }, ctx);
|
||||
expect(dims.find((d) => d.key === "category")!.options).toContainEqual({ value: "纪录片", label: "纪录片", n: 0 });
|
||||
expect(dims.find((d) => d.key === "action")!.options).toContainEqual({
|
||||
value: "in_place",
|
||||
label: "原地整理",
|
||||
n: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("统计还没回来时整理方式列全部,目录与分类为空", () => {
|
||||
const dims = buildDims({}, { ...ctx, facets: undefined });
|
||||
expect(dims.find((d) => d.key === "action")!.options.map((o) => o.label)).toEqual([
|
||||
"复制",
|
||||
"移动",
|
||||
"硬链接",
|
||||
"原地整理",
|
||||
]);
|
||||
expect(dims.find((d) => d.key === "root")!.options).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("已选条件标签", () => {
|
||||
it("本次整理、作品、需要处理在前,其余按维度顺序,关键词最后", () => {
|
||||
const v = { status: "failed", root: "r1", category: "欧美电影", attention: "1", q: "Arrival" };
|
||||
const tags = filterTags(v, buildDims(v, ctx), { entityId: "e1", entityTitle: "沙丘", pinned: 0 });
|
||||
expect(tags.map((t) => `${t.dimLabel}:${t.label}`)).toEqual([
|
||||
"作品:沙丘",
|
||||
"只看:需要处理",
|
||||
"状态:失败",
|
||||
"媒体库目录:电影库",
|
||||
"分类:欧美电影",
|
||||
"关键词:Arrival",
|
||||
]);
|
||||
});
|
||||
|
||||
it("没有条件时没有标签;作品标题没取到时写「已选作品」", () => {
|
||||
expect(filterTags({}, buildDims({}, ctx), {})).toEqual([]);
|
||||
expect(filterTags({}, buildDims({}, ctx), { entityId: "e1" })[0]).toMatchObject({ label: "已选作品" });
|
||||
expect(filterTags({}, buildDims({}, ctx), { pinned: 3 })[0]).toMatchObject({ dimLabel: "本次整理", label: "3 条" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("旧链接与队列按钮", () => {
|
||||
it("?tab=queue 打开队列面板,?tab=records 去掉,其余参数保留", () => {
|
||||
expect(normalizeLegacyParams(new URLSearchParams("tab=queue"))?.toString()).toBe("queue=1");
|
||||
expect(normalizeLegacyParams(new URLSearchParams("tab=records&attention=1"))?.toString()).toBe("attention=1");
|
||||
expect(normalizeLegacyParams(new URLSearchParams("status=failed"))).toBeNull();
|
||||
});
|
||||
|
||||
it("队列数:0 不写,超过一页写 N+", () => {
|
||||
expect(queueCountLabel(0, false)).toBeNull();
|
||||
expect(queueCountLabel(3, false)).toBe("3");
|
||||
expect(queueCountLabel(100, true)).toBe("100+");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { OrganizationStats } from "@umt/contracts";
|
||||
|
||||
// 整理记录筛选(对照 MP 的维度按钮 + 可删条件标签):每个维度一个按钮,点开单选;已选条件排成可移除的标签。
|
||||
// 条件都写在 URL 里(刷新、分享不丢);旧链接 ?tab=queue / ?tab=records 换成单页 + 队列面板(?queue=1)。
|
||||
|
||||
export const RECORD_FILTER_KEYS = [
|
||||
"status",
|
||||
"kind",
|
||||
"action",
|
||||
"root",
|
||||
"category",
|
||||
"trigger",
|
||||
"time",
|
||||
"q",
|
||||
"group",
|
||||
"attention",
|
||||
] as const;
|
||||
export type RecordFilterKey = (typeof RECORD_FILTER_KEYS)[number];
|
||||
|
||||
export const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const;
|
||||
export const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const;
|
||||
/** 整理记录能筛的整理方式(不含文件管理的改名、删除、还原) */
|
||||
export const ORGANIZE_ACTION_LABEL = { copy: "复制", move: "移动", hardlink: "硬链接", in_place: "原地整理" } as const;
|
||||
|
||||
export type FilterOptionN = { value: string; label: string; n?: number };
|
||||
|
||||
/** 一个维度按钮:单选,value 为空 = 不限 */
|
||||
export type FilterDim = {
|
||||
key: "status" | "kind" | "action" | "root" | "category" | "trigger" | "time";
|
||||
label: string;
|
||||
options: FilterOptionN[];
|
||||
};
|
||||
|
||||
/** 可移除的条件标签 */
|
||||
export type FilterTag = { key: string; dimLabel: string; label: string };
|
||||
|
||||
type Values = Partial<Record<RecordFilterKey, string>>;
|
||||
|
||||
/**
|
||||
* 维度按钮的选项。整理方式 / 媒体库目录 / 分类只列有记录的值(来自统计的 facets,带条数);
|
||||
* 已选但当前没有记录的值仍然列出,免得选中项"消失"。
|
||||
*/
|
||||
export function buildDims(
|
||||
v: Values,
|
||||
ctx: {
|
||||
statusLabel: Record<string, string>;
|
||||
triggerLabel: Record<string, string>;
|
||||
facets?: OrganizationStats["facets"];
|
||||
rootName: (id: string) => string;
|
||||
},
|
||||
): FilterDim[] {
|
||||
const facet = (list: { value: string; n: number }[] | undefined, chosen: string, label: (x: string) => string) => {
|
||||
const opts: FilterOptionN[] = (list ?? []).map((x) => ({ value: x.value, label: label(x.value), n: x.n }));
|
||||
if (chosen && !opts.some((o) => o.value === chosen)) opts.push({ value: chosen, label: label(chosen), n: 0 });
|
||||
return opts;
|
||||
};
|
||||
const entries = (o: Record<string, string>) => Object.entries(o).map(([value, label]) => ({ value, label }));
|
||||
const actionLabel = (x: string) => ORGANIZE_ACTION_LABEL[x as keyof typeof ORGANIZE_ACTION_LABEL] ?? x;
|
||||
return [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
options: entries(ctx.statusLabel).filter((o) => o.value !== "queued" && o.value !== "running"),
|
||||
},
|
||||
{ key: "kind", label: "类型", options: entries(KIND_LABEL) },
|
||||
{
|
||||
key: "action",
|
||||
label: "整理方式",
|
||||
options: ctx.facets ? facet(ctx.facets.action, v.action ?? "", actionLabel) : entries(ORGANIZE_ACTION_LABEL),
|
||||
},
|
||||
{ key: "root", label: "媒体库目录", options: facet(ctx.facets?.libraryRootId, v.root ?? "", ctx.rootName) },
|
||||
{ key: "category", label: "分类", options: facet(ctx.facets?.category, v.category ?? "", (x) => x) },
|
||||
{ key: "trigger", label: "来源", options: entries(ctx.triggerLabel) },
|
||||
{ key: "time", label: "时间", options: entries(TIME_LABEL) },
|
||||
];
|
||||
}
|
||||
|
||||
/** 已选条件 → 标签(按维度顺序;作品、需要处理、本次整理的记录也算条件) */
|
||||
export function filterTags(
|
||||
v: Values,
|
||||
dims: FilterDim[],
|
||||
extra: { entityTitle?: string | null; entityId?: string | null; pinned?: number },
|
||||
): FilterTag[] {
|
||||
const tags: FilterTag[] = [];
|
||||
if (extra.pinned) tags.push({ key: "ids", dimLabel: "本次整理", label: `${extra.pinned} 条` });
|
||||
if (extra.entityId) tags.push({ key: "mediaEntityId", dimLabel: "作品", label: extra.entityTitle || "已选作品" });
|
||||
if (v.attention === "1") tags.push({ key: "attention", dimLabel: "只看", label: "需要处理" });
|
||||
for (const d of dims) {
|
||||
const value = v[d.key];
|
||||
if (!value) continue;
|
||||
tags.push({ key: d.key, dimLabel: d.label, label: d.options.find((o) => o.value === value)?.label ?? value });
|
||||
}
|
||||
if (v.q) tags.push({ key: "q", dimLabel: "关键词", label: v.q });
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧链接兼容:整理页曾分「整理队列」「整理记录」两个标签页。
|
||||
* ?tab=queue → 打开队列面板(?queue=1);?tab=records 或其它值 → 去掉。不需要改时返回 null。
|
||||
*/
|
||||
export function normalizeLegacyParams(params: URLSearchParams): URLSearchParams | null {
|
||||
const tab = params.get("tab");
|
||||
if (tab === null) return null;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("tab");
|
||||
if (tab === "queue") next.set("queue", "1");
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 队列按钮上的数:0 不写;超过一页写 N+ */
|
||||
export function queueCountLabel(n: number, more: boolean): string | null {
|
||||
if (!n) return null;
|
||||
return more ? `${n}+` : String(n);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import type { FilterDim, FilterTag } from "./record-filter-model.ts";
|
||||
|
||||
// 整理记录筛选条:一行可横向滚动的维度按钮(单选弹层),下面一行已选条件标签(点一下移除)+「清除」。
|
||||
// 外观与资源快速筛选(components/search/quick-filter-bar.tsx)一致。
|
||||
|
||||
export function RecordFilterChips({
|
||||
dims,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
dims: FilterDim[];
|
||||
values: Partial<Record<FilterDim["key"], string>>;
|
||||
onChange: (key: FilterDim["key"], value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="筛选维度"
|
||||
className="scroll-bleed -mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
|
||||
>
|
||||
{dims
|
||||
// 只有一个可选值又没选时不用筛(比如只有一个媒体库目录)
|
||||
.filter((d) => d.options.length > 1 || !!values[d.key])
|
||||
.map((d) => (
|
||||
<DimButton key={d.key} dim={d} value={values[d.key] ?? ""} onChange={(x) => onChange(d.key, x)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DimButton({ dim, value, onChange }: { dim: FilterDim; value: string; onChange: (v: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pick = (x: string) => {
|
||||
setOpen(false);
|
||||
onChange(x);
|
||||
};
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Chip label={dim.label} active={!!value} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-1" aria-label={dim.label}>
|
||||
<div className="flex max-h-[min(60dvh,22rem)] flex-col overflow-y-auto">
|
||||
<OptionRow on={!value} label="不限" onClick={() => pick("")} />
|
||||
{dim.options.map((o) => (
|
||||
<OptionRow
|
||||
key={o.value}
|
||||
on={value === o.value}
|
||||
label={o.label}
|
||||
n={o.n}
|
||||
onClick={() => pick(value === o.value ? "" : o.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionRow({ on, label, n, onClick }: { on: boolean; label: string; n?: number; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
onClick={onClick}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm text-foreground transition-colors duration-micro hover:bg-foreground/[0.06] md:py-1.5"
|
||||
>
|
||||
<span aria-hidden className="grid h-4 w-4 shrink-0 place-items-center">
|
||||
{on ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{n ? <span className="text-meta text-muted-foreground tabular-nums">{n}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 维度按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */
|
||||
function Chip({
|
||||
label,
|
||||
active,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<"button"> & { label: string; active: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
{...rest}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
|
||||
active
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 已选条件:每个一个可移除标签,末尾一个「清除」 */
|
||||
export function RecordFilterTags({
|
||||
tags,
|
||||
onRemove,
|
||||
onClear,
|
||||
}: {
|
||||
tags: FilterTag[];
|
||||
onRemove: (key: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
if (!tags.length) return null;
|
||||
return (
|
||||
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{tags.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => onRemove(t.key)}
|
||||
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
|
||||
>
|
||||
<span className="text-muted-foreground">{t.dimLabel}</span>
|
||||
<span className="max-w-40 truncate">{t.label}</span>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={onClear}>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,10 +645,10 @@ export default function OrganizePage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理"
|
||||
title="整理记录"
|
||||
actions={
|
||||
<>
|
||||
{tab === "records" ? (
|
||||
<OrganizeQueueButton open={queueOpen} onOpenChange={setQueueOpen} />
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
@@ -601,7 +657,6 @@ export default function OrganizePage() {
|
||||
{selecting ? <X /> : <SquareCheck />}
|
||||
{selecting ? "退出批量" : "批量"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => setChoosingSource(true)}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
@@ -609,25 +664,9 @@ 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
|
||||
className="mb-2"
|
||||
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"}
|
||||
@@ -640,49 +679,12 @@ export default function OrganizePage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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}
|
||||
<RecordFilterChips dims={dims} values={v} onChange={(key, value) => setFilter(key, value)} />
|
||||
</FilterBar>
|
||||
<RecordFilterTags tags={tags} onRemove={removeTag} onClear={clearAll} />
|
||||
{pinned ? null : (
|
||||
<StatsLine
|
||||
filters={base}
|
||||
stats={stats.data}
|
||||
status={v.status}
|
||||
onStatus={(s) => setFilter("status", s)}
|
||||
attention={attention}
|
||||
@@ -749,8 +751,6 @@ export default function OrganizePage() {
|
||||
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{choosingSource ? (
|
||||
<SourceSelection
|
||||
onClose={() => setChoosingSource(false)}
|
||||
|
||||
Reference in new issue
Block a user