diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 54001624..cb67eb8d 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -34,6 +34,12 @@ export type RecordFilters = { organizeOnly?: boolean; /** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */ attention?: boolean; + /** 整理方式 */ + action?: "copy" | "move" | "hardlink" | "in_place"; + /** 目标媒体库目录 */ + libraryRootId?: string; + /** 冻结计划里的二级分类 */ + category?: string; }; export const recordKeys = { diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index a70dbbcf..ac1ad654 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -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"); }} > diff --git a/apps/web/app/components/organize/organize-queue.tsx b/apps/web/app/components/organize/organize-queue.tsx index fc677041..5dd93957 100644 --- a/apps/web/app/components/organize/organize-queue.tsx +++ b/apps/web/app/components/organize/organize-queue.tsx @@ -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 (
-
-

- 下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。 -

+
); } + +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 ( + <> + + + + + 整理队列 + + 下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。 + + +
{open ? : null}
+
+
+ + ); +} diff --git a/apps/web/app/components/organize/record-filter-model.test.ts b/apps/web/app/components/organize/record-filter-model.test.ts new file mode 100644 index 00000000..ae6f623b --- /dev/null +++ b/apps/web/app/components/organize/record-filter-model.test.ts @@ -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+"); + }); +}); diff --git a/apps/web/app/components/organize/record-filter-model.ts b/apps/web/app/components/organize/record-filter-model.ts new file mode 100644 index 00000000..badcce54 --- /dev/null +++ b/apps/web/app/components/organize/record-filter-model.ts @@ -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>; + +/** + * 维度按钮的选项。整理方式 / 媒体库目录 / 分类只列有记录的值(来自统计的 facets,带条数); + * 已选但当前没有记录的值仍然列出,免得选中项"消失"。 + */ +export function buildDims( + v: Values, + ctx: { + statusLabel: Record; + triggerLabel: Record; + 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) => 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); +} diff --git a/apps/web/app/components/organize/record-filters.tsx b/apps/web/app/components/organize/record-filters.tsx new file mode 100644 index 00000000..726a9c0c --- /dev/null +++ b/apps/web/app/components/organize/record-filters.tsx @@ -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>; + onChange: (key: FilterDim["key"], value: string) => void; +}) { + return ( +
+ {dims + // 只有一个可选值又没选时不用筛(比如只有一个媒体库目录) + .filter((d) => d.options.length > 1 || !!values[d.key]) + .map((d) => ( + onChange(d.key, x)} /> + ))} +
+ ); +} + +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 ( + + + + + +
+ pick("")} /> + {dim.options.map((o) => ( + pick(value === o.value ? "" : o.value)} + /> + ))} +
+
+
+ ); +} + +function OptionRow({ on, label, n, onClick }: { on: boolean; label: string; n?: number; onClick: () => void }) { + return ( + + ); +} + +/** 维度按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */ +function Chip({ + label, + active, + className, + ...rest +}: React.ComponentProps<"button"> & { label: string; active: boolean }) { + return ( + + ); +} + +/** 已选条件:每个一个可移除标签,末尾一个「清除」 */ +export function RecordFilterTags({ + tags, + onRemove, + onClear, +}: { + tags: FilterTag[]; + onRemove: (key: string) => void; + onClear: () => void; +}) { + if (!tags.length) return null; + return ( +
+ {tags.map((t) => ( + + ))} + +
+ ); +} diff --git a/apps/web/app/routes/console/organize.$id.tsx b/apps/web/app/routes/console/organize.$id.tsx index 118b7465..6501f635 100644 --- a/apps/web/app/routes/console/organize.$id.tsx +++ b/apps/web/app/routes/console/organize.$id.tsx @@ -133,7 +133,7 @@ export default function OrganizeRecordPage() { actions={ <> - ) : null} + + - } + setGrouping(grouped ? "none" : "entity")} > - setFilter("status", x)} - options={Object.entries(RECORD_STATUS_LABEL) - .filter(([value]) => value !== "queued" && value !== "running") - .map(([value, label]) => ({ value, label }))} - /> - setFilter("kind", x)} - options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))} - /> - setFilter("trigger", x)} - options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))} - /> - setFilter("time", x)} - options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))} - /> - {entityId ? ( - - ) : null} - {pinned ? ( - - ) : null} - - {pinned ? null : ( - setFilter("status", s)} - attention={attention} - onAttention={(on) => setFilter("attention", on ? "1" : "")} - /> - )} - {selecting ? ( -
- 已选 {selRows.length} - - - - - -
- ) : null} - r.id} - loading={pinned ? byIds.isPending : records.isPending} - error={pinned ? byIds.error : records.error} - onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} - onRowClick={(r) => (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) => , - meta: (_k, g) => , - flat: (_k, g) => g.length === 1, - defaultCollapsed: true, - storageKey: "organize-records-entity", - } - : undefined - } - empty={{ - icon: FileStack, - title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录", - description: attention - ? "识别不到作品、目标冲突或整理失败的记录会出现在这里。" - : filtered - ? undefined - : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", - }} - /> - - + + 按作品分组 + + } + > + setFilter(key, value)} /> + + + {pinned ? null : ( + setFilter("status", s)} + attention={attention} + onAttention={(on) => setFilter("attention", on ? "1" : "")} + /> + )} + {selecting ? ( +
+ 已选 {selRows.length} + + + + + +
+ ) : null} + r.id} + loading={pinned ? byIds.isPending : records.isPending} + error={pinned ? byIds.error : records.error} + onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} + onRowClick={(r) => (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) => , + meta: (_k, 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 ? ( setChoosingSource(false)}