merge: ui/l-files-organize
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
8d2fcdc7a4
13 files changed
+454
-59
No files matched your search
@@ -70,6 +70,10 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value;
|
const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value;
|
||||||
return typeof v === "string" && v ? v : null;
|
return typeof v === "string" && v ? v : null;
|
||||||
};
|
};
|
||||||
|
const categoryOf = (plan: unknown) => {
|
||||||
|
const v = (plan as { classification?: { category?: unknown } } | null)?.classification?.category;
|
||||||
|
return typeof v === "string" && v ? v : null;
|
||||||
|
};
|
||||||
// blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看
|
// blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看
|
||||||
const conflictText = (err: unknown) =>
|
const conflictText = (err: unknown) =>
|
||||||
((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? [])
|
((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? [])
|
||||||
@@ -119,6 +123,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
externalId: i.external_id,
|
externalId: i.external_id,
|
||||||
})),
|
})),
|
||||||
posterPath: posterOf(e.metadata),
|
posterPath: posterOf(e.metadata),
|
||||||
|
category: categoryOf(r.plan),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
trigger: r.trigger,
|
trigger: r.trigger,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
media_entity_id: r.entity,
|
media_entity_id: r.entity,
|
||||||
download_task_id: "task" in r ? r.task : null,
|
download_task_id: "task" in r ? r.task : null,
|
||||||
source_path: r.src,
|
source_path: r.src,
|
||||||
|
// 冻结计划里的二级分类(列表带出 media.category)
|
||||||
|
...(r.k === "film"
|
||||||
|
? { plan: JSON.stringify({ classification: { category: "欧美电影", matchedFields: [], fallback: false } }) }
|
||||||
|
: {}),
|
||||||
action: r.action,
|
action: r.action,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
...("at" in r ? { created_at: r.at } : {}),
|
...("at" in r ? { created_at: r.at } : {}),
|
||||||
@@ -135,13 +139,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("列表项带海报、大小、下载器名称与 hash", async () => {
|
it("列表项带海报、分类、大小、下载器名称与 hash", async () => {
|
||||||
const [film] = await list("q=Arrival.2016");
|
const [film] = await list("q=Arrival.2016");
|
||||||
expect(film).toMatchObject({
|
expect(film).toMatchObject({
|
||||||
media: { title: "Arrival", posterPath: "/arrival.jpg" },
|
media: { title: "Arrival", posterPath: "/arrival.jpg", category: "欧美电影" },
|
||||||
sizeBytes: 1000,
|
sizeBytes: 1000,
|
||||||
download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" },
|
download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" },
|
||||||
});
|
});
|
||||||
|
const [show] = await list("q=Severance");
|
||||||
|
expect(show).toMatchObject({ media: { title: "Severance", category: null } });
|
||||||
const [unknown] = await list("kind=none");
|
const [unknown] = await list("kind=none");
|
||||||
expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 });
|
expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export type DataGroupBy<T> = {
|
|||||||
/** 组顺序,缺省按首次出现 */
|
/** 组顺序,缺省按首次出现 */
|
||||||
order?: (a: string, b: string) => number;
|
order?: (a: string, b: string) => number;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
|
/** 组头右侧的计数位,缺省为行数 */
|
||||||
|
meta?: (key: string, rows: T[]) => ReactNode;
|
||||||
|
/** 组缺省折叠(点开的组才记住);缺省展开 */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/** 返回 true 的组不画组头、直接平铺行(如只有一行的组) */
|
||||||
|
flat?: (key: string, rows: T[]) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
||||||
@@ -189,7 +195,9 @@ export function DataTable<T>({
|
|||||||
</thead>
|
</thead>
|
||||||
{groups ? (
|
{groups ? (
|
||||||
groups.map((g) => {
|
groups.map((g) => {
|
||||||
const open = !collapsed.has(g.key);
|
if (groupBy?.flat?.(g.key, g.rows)) return <tbody key={`g:${g.key}`}>{g.rows.map(renderRow)}</tbody>;
|
||||||
|
// defaultCollapsed 时集合里存的是点开的组
|
||||||
|
const open = groupBy?.defaultCollapsed ? collapsed.has(g.key) : !collapsed.has(g.key);
|
||||||
return (
|
return (
|
||||||
<Fragment key={`g:${g.key}`}>
|
<Fragment key={`g:${g.key}`}>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -211,7 +219,11 @@ export function DataTable<T>({
|
|||||||
<span className="min-w-0 truncate font-medium text-foreground">
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
{groupBy?.label ? groupBy.label(g.key, g.rows) : g.key}
|
{groupBy?.label ? groupBy.label(g.key, g.rows) : g.key}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">{g.rows.length}</span>
|
{groupBy?.meta ? (
|
||||||
|
groupBy.meta(g.key, g.rows)
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">{g.rows.length}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Music,
|
Music,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
RefreshCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Subtitles,
|
Subtitles,
|
||||||
Wand2,
|
Wand2,
|
||||||
@@ -28,10 +29,10 @@ import {
|
|||||||
clickSelect,
|
clickSelect,
|
||||||
entryStatusView,
|
entryStatusView,
|
||||||
entrySubline,
|
entrySubline,
|
||||||
|
hoverOpLabel,
|
||||||
hoverOps,
|
hoverOps,
|
||||||
type MenuKey,
|
type MenuKey,
|
||||||
moveFocus,
|
moveFocus,
|
||||||
OP_LABEL,
|
|
||||||
type Selection,
|
type Selection,
|
||||||
selectAll,
|
selectAll,
|
||||||
toggleSelect,
|
toggleSelect,
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
const HOVER_ICON: Partial<Record<MenuKey, LucideIcon>> = {
|
const HOVER_ICON: Partial<Record<MenuKey, LucideIcon>> = {
|
||||||
organize: Wand2,
|
organize: Wand2,
|
||||||
identify: Sparkles,
|
identify: Sparkles,
|
||||||
|
writeNfo: FileText,
|
||||||
|
scrape: RefreshCw,
|
||||||
rename: PenLine,
|
rename: PenLine,
|
||||||
move: FolderInput,
|
move: FolderInput,
|
||||||
openMedia: Library,
|
openMedia: Library,
|
||||||
@@ -228,7 +231,8 @@ export function FileList({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
if ((ev.target as HTMLElement).closest("input,textarea,[role=menu]")) return;
|
// 行内操作按钮获焦时(Tab 进入)Enter / Space 交给按钮本身
|
||||||
|
if ((ev.target as HTMLElement).closest("input,textarea,[role=menu],[data-hover-op]")) return;
|
||||||
const focused = selection.focus ? byKey.get(selection.focus) : undefined;
|
const focused = selection.focus ? byKey.get(selection.focus) : undefined;
|
||||||
const mod = ev.ctrlKey || ev.metaKey;
|
const mod = ev.ctrlKey || ev.metaKey;
|
||||||
const step = (delta: number) => {
|
const step = (delta: number) => {
|
||||||
@@ -284,6 +288,17 @@ export function FileList({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "Tab": {
|
||||||
|
// Tab 进入焦点行的行内操作(列表里的链接不打断);Shift+Tab 照常离开列表
|
||||||
|
if (ev.shiftKey || !focused) return;
|
||||||
|
// 按行 id 找(行键里有 \0,CSS.escape 会把它换成 U+FFFD,属性选择器匹配不上)
|
||||||
|
const op = ref.current?.querySelector<HTMLElement>(`#file-row-${focusIndex} [data-hover-op]`);
|
||||||
|
if (op) {
|
||||||
|
ev.preventDefault();
|
||||||
|
op.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "Escape":
|
case "Escape":
|
||||||
if (selection.keys.size) {
|
if (selection.keys.size) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -294,7 +309,7 @@ export function FileList({
|
|||||||
case "F10":
|
case "F10":
|
||||||
if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) {
|
if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const row = ref.current?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(keyOf(focused))}"]`);
|
const row = ref.current?.querySelector<HTMLElement>(`#file-row-${focusIndex}`);
|
||||||
const r = row?.getBoundingClientRect();
|
const r = row?.getBoundingClientRect();
|
||||||
onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 });
|
onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 });
|
||||||
}
|
}
|
||||||
@@ -355,7 +370,7 @@ export function FileList({
|
|||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] });
|
if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] });
|
||||||
}}
|
}}
|
||||||
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
|
className="group/grid relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
{!mobile ? (
|
{!mobile ? (
|
||||||
// biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行
|
// biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行
|
||||||
@@ -401,6 +416,7 @@ export function FileList({
|
|||||||
const selected = selection.keys.has(k);
|
const selected = selection.keys.has(k);
|
||||||
const focused = selection.focus === k;
|
const focused = selection.focus === k;
|
||||||
const isDropDir = e.type === "dir" && !e.escape;
|
const isDropDir = e.type === "dir" && !e.escape;
|
||||||
|
const hasOps = !!onAction && !e.escape;
|
||||||
const common = {
|
const common = {
|
||||||
id: `file-row-${i}`,
|
id: `file-row-${i}`,
|
||||||
role: "row",
|
role: "row",
|
||||||
@@ -512,10 +528,31 @@ export function FileList({
|
|||||||
<Cell className="min-w-0 overflow-hidden">
|
<Cell className="min-w-0 overflow-hidden">
|
||||||
<EntryStatus e={e} />
|
<EntryStatus e={e} />
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
|
{/* 行内操作浮出时让出位置:lg 起盖在修改时间上,更窄时盖在大小上 */}
|
||||||
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
|
<Cell
|
||||||
|
className={cn(
|
||||||
|
"truncate text-right text-caption tabular-nums",
|
||||||
|
hasOps && "max-lg:group-hover/row:invisible",
|
||||||
|
hasOps &&
|
||||||
|
focused &&
|
||||||
|
"max-lg:group-focus-visible/grid:invisible max-lg:group-has-focus-visible/row:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sizeCell(e)}
|
||||||
|
</Cell>
|
||||||
|
<Cell
|
||||||
|
className={cn(
|
||||||
|
"hidden truncate text-caption text-muted-foreground lg:block",
|
||||||
|
hasOps && "group-hover/row:invisible",
|
||||||
|
hasOps && focused && "group-focus-visible/grid:invisible group-has-focus-visible/row:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{relTime(e.mtime)}
|
||||||
|
</Cell>
|
||||||
<Cell className="relative grid place-items-center">
|
<Cell className="relative grid place-items-center">
|
||||||
{onAction && !e.escape ? <HoverOps e={e} onAction={onAction} opState={opState} /> : null}
|
{hasOps && onAction ? (
|
||||||
|
<HoverOps e={e} active={focused} onAction={onAction} opState={opState} />
|
||||||
|
) : null}
|
||||||
{menuBtn}
|
{menuBtn}
|
||||||
</Cell>
|
</Cell>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,13 +596,19 @@ export const DRAG_MIME = "application/x-umt-files";
|
|||||||
const DESKTOP_COLS =
|
const DESKTOP_COLS =
|
||||||
"grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]";
|
"grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]";
|
||||||
|
|
||||||
/** 行内 hover 时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */
|
/**
|
||||||
|
* 行内常用操作(§5.1,照 MP 悬停露出图标):鼠标悬停该行,或键盘焦点在该行(列表获焦且是键盘操作)时,
|
||||||
|
* 在行尾浮出 2–3 个图标按钮,盖在修改时间列上;每个带 tooltip 与 aria-label,被状态阻止的灰显并写原因。
|
||||||
|
* 焦点行的按钮可用 Tab 进入。窄屏走卡片 + ⋯ 菜单,不依赖悬停。
|
||||||
|
*/
|
||||||
function HoverOps({
|
function HoverOps({
|
||||||
e,
|
e,
|
||||||
|
active,
|
||||||
onAction,
|
onAction,
|
||||||
opState,
|
opState,
|
||||||
}: {
|
}: {
|
||||||
e: FileEntry;
|
e: FileEntry;
|
||||||
|
active: boolean;
|
||||||
onAction: (e: FileEntry, op: MenuKey) => void;
|
onAction: (e: FileEntry, op: MenuKey) => void;
|
||||||
opState?: (e: FileEntry, op: MenuKey) => string | null;
|
opState?: (e: FileEntry, op: MenuKey) => string | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -573,34 +616,43 @@ function HoverOps({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
// 只在鼠标悬浮时盖在大小/时间列上;选中/键盘聚焦的行不常驻(否则大小和时间一直被挡),
|
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
||||||
// 键盘操作走右键菜单与快捷键。
|
"group-hover/row:pointer-events-auto group-hover/row:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100",
|
||||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-1 rounded-full bg-background/95 py-0.5 pl-1 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
active && "group-focus-visible/grid:pointer-events-auto group-focus-visible/grid:opacity-100",
|
||||||
"group-hover/row:pointer-events-auto group-hover/row:opacity-100",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{ops.map((op) => {
|
{ops.map((op) => {
|
||||||
const Icon = HOVER_ICON[op];
|
const Icon = HOVER_ICON[op];
|
||||||
|
const label = hoverOpLabel(op);
|
||||||
const why = opState?.(e, op) ?? null;
|
const why = opState?.(e, op) ?? null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={op}
|
key={op}
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
data-hover-op={op}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
aria-label={label}
|
||||||
aria-disabled={!!why || undefined}
|
aria-disabled={!!why || undefined}
|
||||||
title={why ?? OP_LABEL[op].replace("…", "")}
|
title={why ? `${label} · ${why}` : label}
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (!why) onAction(e, op);
|
if (!why) onAction(e, op);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(ev) => ev.stopPropagation()}
|
onDoubleClick={(ev) => ev.stopPropagation()}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
// Esc / 在第一个按钮上 Shift+Tab:回到列表(焦点行不变)
|
||||||
|
const first = ev.currentTarget.parentElement?.firstElementChild === ev.currentTarget;
|
||||||
|
if (ev.key === "Escape" || (ev.key === "Tab" && ev.shiftKey && first)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.currentTarget.closest<HTMLElement>("[role=grid]")?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 items-center gap-1 rounded-full px-2 text-meta whitespace-nowrap",
|
"grid h-7 w-7 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||||
why ? "cursor-not-allowed text-muted-foreground/50" : "text-foreground hover:bg-foreground/[0.07]",
|
why ? "cursor-not-allowed text-muted-foreground/50" : "text-foreground hover:bg-foreground/[0.07]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
{Icon ? <Icon aria-hidden className="h-3.5 w-3.5" /> : null}
|
||||||
{OP_LABEL[op].replace("…", "")}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { resolveFilesMock } from "~/api/files.ts";
|
import { resolveFilesMock } from "~/api/files.ts";
|
||||||
import {
|
import {
|
||||||
applyIdentifyCompleted,
|
applyIdentifyCompleted,
|
||||||
|
breadcrumbItems,
|
||||||
clickSelect,
|
clickSelect,
|
||||||
collapseCrumbs,
|
collapseCrumbs,
|
||||||
cueTime,
|
cueTime,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
entryStatusView,
|
entryStatusView,
|
||||||
entrySubline,
|
entrySubline,
|
||||||
formatEpisodes,
|
formatEpisodes,
|
||||||
|
hoverOpLabel,
|
||||||
hoverOps,
|
hoverOps,
|
||||||
initialLocation,
|
initialLocation,
|
||||||
menuModel,
|
menuModel,
|
||||||
@@ -220,10 +222,45 @@ describe("v2 菜单与副行", () => {
|
|||||||
expect(m && !("separator" in m) ? m.disabled : null).toMatch(/H&R/);
|
expect(m && !("separator" in m) ? m.disabled : null).toMatch(/H&R/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hover 主操作按状态", () => {
|
it("悬停常用操作按文件 / 目录类型选 2–3 个,都在菜单里能找到", () => {
|
||||||
expect(hoverOps(video)).toEqual(["organize", "identify"]);
|
expect(hoverOps(video)).toEqual(["organize", "identify", "writeNfo"]);
|
||||||
expect(hoverOps(registered)).toEqual(["rename", "openMedia"]);
|
expect(hoverOps({ ...base, type: "dir" as const, fileKind: null })).toEqual(["organize", "identify", "writeNfo"]);
|
||||||
|
expect(hoverOps(registered)).toEqual(["openMedia", "scrape", "rename"]);
|
||||||
|
// 未完成的视频不能整理:退为识别 / 重命名 / 移动
|
||||||
|
expect(hoverOps({ ...video, status: "temp" as const })).toEqual(["identify", "rename", "move"]);
|
||||||
expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]);
|
expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]);
|
||||||
|
expect(hoverOps({ ...base, fileKind: "subtitle" as const })).toEqual(["rename", "move"]);
|
||||||
|
for (const e of [video, registered, { ...base, fileKind: "nfo" as const }]) {
|
||||||
|
const ops = hoverOps(e);
|
||||||
|
expect(ops.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(ops.length).toBeLessThanOrEqual(3);
|
||||||
|
const menu = menuModel([e], lib).flatMap((x) => ("separator" in x ? [] : [x.key]));
|
||||||
|
for (const op of ops) expect(menu).toContain(op);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("悬停图标的 tooltip / aria-label 文字去掉菜单里的省略号", () => {
|
||||||
|
expect(hoverOpLabel("organize")).toBe("整理");
|
||||||
|
expect(hoverOpLabel("writeNfo")).toBe("刮削");
|
||||||
|
expect(hoverOpLabel("move")).toBe("移动到");
|
||||||
|
expect(hoverOpLabel("scrape")).toBe("刷新元数据");
|
||||||
|
expect(hoverOpLabel("openMedia")).toBe("打开作品");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("面包屑:登记目录类型 > 登记目录 > 各级子目录,每段带可跳转的相对路径", () => {
|
||||||
|
expect(breadcrumbItems({ name: "qBittorrent · 下载", kind: "download" }, "")).toEqual({
|
||||||
|
group: "下载目录",
|
||||||
|
crumbs: [{ label: "qBittorrent · 下载", path: "" }],
|
||||||
|
});
|
||||||
|
expect(breadcrumbItems({ name: "剧集库", kind: "library" }, "亚洲剧集/繁花 (2023)/Season 1/")).toEqual({
|
||||||
|
group: "媒体库",
|
||||||
|
crumbs: [
|
||||||
|
{ label: "剧集库", path: "" },
|
||||||
|
{ label: "亚洲剧集", path: "亚洲剧集" },
|
||||||
|
{ label: "繁花 (2023)", path: "亚洲剧集/繁花 (2023)" },
|
||||||
|
{ label: "Season 1", path: "亚洲剧集/繁花 (2023)/Season 1" },
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("副行:已登记作品、待识别解析、目录计数", () => {
|
it("副行:已登记作品、待识别解析、目录计数", () => {
|
||||||
|
|||||||
@@ -429,13 +429,23 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 行内 hover 操作(桌面,§5.1):按状态只露 2 个 */
|
/**
|
||||||
export function hoverOps(e: FileEntry): [MenuKey, MenuKey] {
|
* 行内 hover / 键盘聚焦时浮出的常用操作(桌面,§5.1;照 MP 的悬停图标,但只露 2–3 个,其余留在 ⋯ 菜单):
|
||||||
if (e.asset) return ["rename", "openMedia"];
|
* 已入库 → 打开作品、刷新元数据、重命名;未入库视频 / 目录 → 整理、识别、刮削;
|
||||||
if (opApplies("organize", e)) return ["organize", "identify"];
|
* 其余能识别的(临时文件里的视频等)→ 识别、重命名、移动到;别的文件 → 重命名、移动到。
|
||||||
|
*/
|
||||||
|
export function hoverOps(e: FileEntry): MenuKey[] {
|
||||||
|
if (e.asset) return ["openMedia", "scrape", "rename"];
|
||||||
|
if (opApplies("organize", e)) return ["organize", "identify", "writeNfo"];
|
||||||
|
if (opApplies("identify", e)) return ["identify", "rename", "move"];
|
||||||
return ["rename", "move"];
|
return ["rename", "move"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 行内图标按钮的文字(tooltip / aria-label):菜单里的"…"表示还要再选一步,图标上不带 */
|
||||||
|
export function hoverOpLabel(op: MenuKey): string {
|
||||||
|
return OP_LABEL[op].replace("…", "");
|
||||||
|
}
|
||||||
|
|
||||||
export const OP_LABEL: Record<MenuKey, string> = {
|
export const OP_LABEL: Record<MenuKey, string> = {
|
||||||
open: "打开",
|
open: "打开",
|
||||||
organize: "整理…",
|
organize: "整理…",
|
||||||
@@ -470,6 +480,28 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ROOT_KIND_LABEL: Record<FileRoot["kind"], string> = { download: "下载目录", library: "媒体库" };
|
||||||
|
|
||||||
|
export type Crumb = { label: string; path: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)+ 登记目录名 + 各级子目录。
|
||||||
|
* group 不可点;crumbs 每段可点(path 为登记目录内相对路径,根为 "")。
|
||||||
|
*/
|
||||||
|
export function breadcrumbItems(
|
||||||
|
root: Pick<FileRoot, "name" | "kind">,
|
||||||
|
path: string,
|
||||||
|
): { group: string; crumbs: Crumb[] } {
|
||||||
|
const segs = segments(path);
|
||||||
|
return {
|
||||||
|
group: ROOT_KIND_LABEL[root.kind],
|
||||||
|
crumbs: [
|
||||||
|
{ label: root.name, path: "" },
|
||||||
|
...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Loc = { root: string; path: string };
|
export type Loc = { root: string; path: string };
|
||||||
|
|
||||||
/** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */
|
/** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import { ChevronRight, HardDrive } from "lucide-react";
|
import { ChevronRight, HardDrive } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { FileRoot } from "~/api/files.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { collapseCrumbs, segments } from "./model.ts";
|
import { breadcrumbItems, collapseCrumbs } from "./model.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。
|
* 路径面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)> 登记目录 > 各级子目录。
|
||||||
* 窄屏折叠为"根 / … / 父 / 当前"。
|
* 目录各段可点击跳转,也是拖放目标(拖入 = 移动到该目录)。窄屏去掉类型,折叠为"根 / … / 父 / 当前"。
|
||||||
*/
|
*/
|
||||||
export function PathBreadcrumb({
|
export function PathBreadcrumb({
|
||||||
rootName,
|
root,
|
||||||
path,
|
path,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onDropTo,
|
onDropTo,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
rootName: string;
|
root: Pick<FileRoot, "name" | "kind">;
|
||||||
path: string;
|
path: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
/** 有值时每段可作为拖放目标 */
|
/** 有值时每段可作为拖放目标 */
|
||||||
onDropTo?: (path: string, e: React.DragEvent) => void;
|
onDropTo?: (path: string, e: React.DragEvent) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const segs = segments(path);
|
const { group, crumbs } = breadcrumbItems(root, path);
|
||||||
const crumbs = [
|
|
||||||
{ label: rootName, path: "" },
|
|
||||||
...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })),
|
|
||||||
];
|
|
||||||
const [over, setOver] = useState<string | null>(null);
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
|
||||||
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => (
|
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string, withGroup = false) => (
|
||||||
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
||||||
|
{withGroup ? (
|
||||||
|
<li className="flex shrink-0 items-center gap-0.5 pl-1.5 pr-0.5 text-body text-muted-foreground">
|
||||||
|
{group}
|
||||||
|
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
{items.map((c, i) => {
|
{items.map((c, i) => {
|
||||||
const last = i === items.length - 1;
|
const last = i === items.length - 1;
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +88,7 @@ export function PathBreadcrumb({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
||||||
{render(crumbs, "full", "hidden md:flex")}
|
{render(crumbs, "full", "hidden md:flex", true)}
|
||||||
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
import { OrganizationAction, type PublicOrganizationRecord } from "@umt/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { canReorganize, episodeTag, hasFiles, isRunning, recordFileProgress, recordTitle } from "./record-parts.ts";
|
import { ACTION_LABEL } from "~/api/records.ts";
|
||||||
|
import {
|
||||||
|
canReorganize,
|
||||||
|
episodeRange,
|
||||||
|
episodeTag,
|
||||||
|
GROUPING_STORAGE_KEY,
|
||||||
|
groupIssues,
|
||||||
|
hasFiles,
|
||||||
|
isRunning,
|
||||||
|
readGroupingPref,
|
||||||
|
recordCategory,
|
||||||
|
recordFileProgress,
|
||||||
|
recordTitle,
|
||||||
|
resolveGrouping,
|
||||||
|
writeGroupingPref,
|
||||||
|
} from "./record-parts.ts";
|
||||||
|
|
||||||
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
|
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
|
||||||
|
|
||||||
@@ -65,3 +81,72 @@ describe("文件级进度", () => {
|
|||||||
expect(recordFileProgress({ files })).toEqual({ completed: 3, total: 5, current: "/source/3.mkv" });
|
expect(recordFileProgress({ files })).toEqual({ completed: 3, total: 5, current: "/source/3.mkv" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("整理记录列表:整理方式、分类、分组", () => {
|
||||||
|
it("整理方式文案覆盖全部取值,常用的四种照 glossary", () => {
|
||||||
|
for (const a of OrganizationAction.options) expect(ACTION_LABEL[a]).toBeTruthy();
|
||||||
|
expect([ACTION_LABEL.move, ACTION_LABEL.copy, ACTION_LABEL.hardlink, ACTION_LABEL.in_place]).toEqual([
|
||||||
|
"移动",
|
||||||
|
"复制",
|
||||||
|
"硬链接",
|
||||||
|
"原地入库",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分类:有二级分类用分类,没有退回作品类型,未识别为空", () => {
|
||||||
|
expect(recordCategory({ media: { ...media(), category: "纪录片" } })).toBe("纪录片");
|
||||||
|
expect(recordCategory({ media: { ...media(), category: null } })).toBe("剧集");
|
||||||
|
expect(recordCategory({ media: { ...media(), kind: "movie" as const } })).toBe("电影");
|
||||||
|
expect(recordCategory({ media: null })).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("季集范围:单集、同季连续、同季有缺、跨季、非剧集", () => {
|
||||||
|
const ep = (s: number | null, e: number | null) => media({ seasonNumber: s, episodeNumber: e });
|
||||||
|
expect(episodeRange([ep(1, 5)])).toBe("S01E05");
|
||||||
|
expect(episodeRange([3, 1, 2, 4, 5, 6, 7, 8, 9, 10].map((e) => ep(1, e)))).toBe("S01E01–E10");
|
||||||
|
expect(episodeRange([1, 2, 3, 5, 5].map((e) => ep(1, e)))).toBe("S01E01–E03、E05");
|
||||||
|
expect(episodeRange([ep(1, 9), ep(2, 1)])).toBe("S01–S02");
|
||||||
|
expect(episodeRange([ep(2, null)])).toBe("S02");
|
||||||
|
expect(episodeRange([media(), null])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("组汇总只列非成功的非零项", () => {
|
||||||
|
const st = (...xs: PublicOrganizationRecord["status"][]) => xs.map((status) => ({ status }));
|
||||||
|
expect(groupIssues(st("succeeded", "succeeded"))).toEqual([]);
|
||||||
|
expect(groupIssues(st("succeeded", "failed", "partial", "running", "queued"))).toEqual([
|
||||||
|
{ label: "失败", n: 1, tone: "destructive" },
|
||||||
|
{ label: "部分成功", n: 1, tone: "warning" },
|
||||||
|
{ label: "进行中", n: 2, tone: "neutral" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分组缺省按作品;URL 优先,其次本浏览器记住的选择", () => {
|
||||||
|
expect(resolveGrouping(null, null)).toBe("entity");
|
||||||
|
expect(resolveGrouping("", "none")).toBe("none");
|
||||||
|
expect(resolveGrouping("entity", "none")).toBe("entity");
|
||||||
|
expect(resolveGrouping("none", null)).toBe("none");
|
||||||
|
expect(resolveGrouping("bogus", "bogus")).toBe("entity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("记住分组选择;存储不可用时不报错", () => {
|
||||||
|
const mem = new Map<string, string>();
|
||||||
|
const store = () => ({
|
||||||
|
getItem: (k: string) => mem.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void mem.set(k, v),
|
||||||
|
});
|
||||||
|
expect(readGroupingPref(store)).toBeNull();
|
||||||
|
writeGroupingPref("none", store);
|
||||||
|
expect(mem.get(GROUPING_STORAGE_KEY)).toBe("none");
|
||||||
|
expect(resolveGrouping(null, readGroupingPref(store))).toBe("none");
|
||||||
|
const broken = () => ({
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("denied");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("denied");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readGroupingPref(broken)).toBeNull();
|
||||||
|
expect(() => writeGroupingPref("entity", broken)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,3 +47,85 @@ export function recordFileProgress(
|
|||||||
current: current?.source.path ?? current?.target.path ?? null,
|
current: current?.source.path ?? current?.target.path ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一组记录的季集范围(组头用):单集 S01E05;同季连续 S01E01–E10;同季有缺 S01E01–E03、E05;
|
||||||
|
* 跨季 S01–S02;只有季 S01;都不是剧集返回空串。
|
||||||
|
*/
|
||||||
|
export function episodeRange(media: ReadonlyArray<PublicOrganizationRecord["media"]>): string {
|
||||||
|
const pairs = media.filter((m): m is NonNullable<typeof m> => m?.seasonNumber != null);
|
||||||
|
if (!pairs.length) return "";
|
||||||
|
const seasons = [...new Set(pairs.map((m) => m.seasonNumber as number))].sort((a, b) => a - b);
|
||||||
|
if (seasons.length > 1) return `S${pad(seasons[0] as number)}–S${pad(seasons[seasons.length - 1] as number)}`;
|
||||||
|
const s = `S${pad(seasons[0] as number)}`;
|
||||||
|
const eps = [...new Set(pairs.map((m) => m.episodeNumber).filter((e): e is number => e != null))].sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
if (!eps.length) return s;
|
||||||
|
// 连续段:[1,2,3,5] → 1–3、5
|
||||||
|
const runs: [number, number][] = [];
|
||||||
|
for (const e of eps) {
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (last && e === last[1] + 1) last[1] = e;
|
||||||
|
else runs.push([e, e]);
|
||||||
|
}
|
||||||
|
return `${s}${runs.map(([a, b]) => (a === b ? `E${pad(a)}` : `E${pad(a)}–E${pad(b)}`)).join("、")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组汇总里要提示的状态(成功是常态不列,只列非零项) */
|
||||||
|
export function groupIssues(
|
||||||
|
rows: ReadonlyArray<Pick<PublicOrganizationRecord, "status">>,
|
||||||
|
): { label: string; n: number; tone: "destructive" | "warning" | "neutral" }[] {
|
||||||
|
const n = (f: (s: PublicOrganizationRecord["status"]) => boolean) => rows.filter((r) => f(r.status)).length;
|
||||||
|
return [
|
||||||
|
{ label: "失败", n: n((s) => s === "failed"), tone: "destructive" as const },
|
||||||
|
{ label: "部分成功", n: n((s) => s === "partial"), tone: "warning" as const },
|
||||||
|
{ label: "进行中", n: n((s) => s === "queued" || s === "running"), tone: "neutral" as const },
|
||||||
|
{ label: "已取消", n: n((s) => s === "cancelled"), tone: "neutral" as const },
|
||||||
|
].filter((x) => x.n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_KIND_LABEL: Record<NonNullable<PublicOrganizationRecord["media"]>["kind"], string> = {
|
||||||
|
movie: "电影",
|
||||||
|
series: "剧集",
|
||||||
|
variety: "综艺",
|
||||||
|
collection: "合集",
|
||||||
|
other: "其他",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 分类(照 MP 历史记录的「纪录片 / 欧美电影」):整理时的二级分类,没有时退回作品类型;未识别为空串 */
|
||||||
|
export function recordCategory(r: Pick<PublicOrganizationRecord, "media">): string {
|
||||||
|
if (!r.media) return "";
|
||||||
|
return r.media.category || MEDIA_KIND_LABEL[r.media.kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 列表分组偏好:URL ?group= 优先(entity / none),没有时用本浏览器记住的选择,缺省按作品分组 ──
|
||||||
|
|
||||||
|
export type RecordGrouping = "entity" | "none";
|
||||||
|
export const GROUPING_STORAGE_KEY = "umt.organize.records-group";
|
||||||
|
|
||||||
|
export function resolveGrouping(param: string | null | undefined, stored: string | null | undefined): RecordGrouping {
|
||||||
|
if (param === "entity" || param === "none") return param;
|
||||||
|
return stored === "none" ? "none" : "entity";
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefStore = Pick<Storage, "getItem" | "setItem">;
|
||||||
|
const prefStore = (): PrefStore | undefined => globalThis.localStorage;
|
||||||
|
|
||||||
|
export function readGroupingPref(store = prefStore): string | null {
|
||||||
|
try {
|
||||||
|
return store()?.getItem(GROUPING_STORAGE_KEY) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeGroupingPref(v: RecordGrouping, store = prefStore): void {
|
||||||
|
try {
|
||||||
|
store()?.setItem(GROUPING_STORAGE_KEY, v);
|
||||||
|
} catch {
|
||||||
|
// 私密窗口 / 配额满:只是不记住
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -994,7 +994,7 @@ export default function FilesPage() {
|
|||||||
<div className="min-w-0 flex-1">{searchBox}</div>
|
<div className="min-w-0 flex-1">{searchBox}</div>
|
||||||
) : root ? (
|
) : root ? (
|
||||||
<PathBreadcrumb
|
<PathBreadcrumb
|
||||||
rootName={root.name}
|
root={root}
|
||||||
path={path}
|
path={path}
|
||||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
@@ -1039,7 +1039,7 @@ export default function FilesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{root ? (
|
{root ? (
|
||||||
<PathBreadcrumb
|
<PathBreadcrumb
|
||||||
rootName={root.name}
|
root={root}
|
||||||
path={path}
|
path={path}
|
||||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||||
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
|
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
|
||||||
|
|||||||
@@ -36,7 +36,19 @@ import { Checkbox } from "~/components/files/file-list.tsx";
|
|||||||
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
||||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||||
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
|
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
|
||||||
import { canReorganize, isRunning, recordFileProgress, recordTitle } from "~/components/organize/record-parts.ts";
|
import {
|
||||||
|
canReorganize,
|
||||||
|
episodeRange,
|
||||||
|
groupIssues,
|
||||||
|
isRunning,
|
||||||
|
type RecordGrouping,
|
||||||
|
readGroupingPref,
|
||||||
|
recordCategory,
|
||||||
|
recordFileProgress,
|
||||||
|
recordTitle,
|
||||||
|
resolveGrouping,
|
||||||
|
writeGroupingPref,
|
||||||
|
} from "~/components/organize/record-parts.ts";
|
||||||
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
||||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
@@ -46,8 +58,9 @@ import { reasonLine } from "~/lib/reasons.ts";
|
|||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集、源 → 目标路径、整理方式与大小、状态与原因、来源(下载器)、时间;
|
// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集 + 分类、源 → 目标路径、整理方式标签与大小、状态与原因、来源(下载器)、时间;
|
||||||
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;按作品分组;
|
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;
|
||||||
|
// 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住;
|
||||||
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
||||||
|
|
||||||
type Rec = PublicOrganizationRecord;
|
type Rec = PublicOrganizationRecord;
|
||||||
@@ -90,6 +103,52 @@ function StatusCell({ r }: { r: Rec }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 整理方式(移动 / 复制 / 硬链接 / 原地入库…):中性小标签,不用状态色 */
|
||||||
|
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">
|
||||||
|
{ACTION_LABEL[action]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组头:海报 + 作品 (年) + 季集范围 */
|
||||||
|
function GroupLabel({ rows }: { rows: Rec[] }) {
|
||||||
|
const m = rows[0]?.media;
|
||||||
|
if (!m) return <>未识别</>;
|
||||||
|
const range = episodeRange(rows.map((r) => r.media));
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2.5 align-middle">
|
||||||
|
<CoverPlaceholder small title={m.title} kind={m.kind} posterPath={m.posterPath} className="h-9 w-6 rounded-md" />
|
||||||
|
<span className="truncate">
|
||||||
|
{m.title}
|
||||||
|
{m.year ? ` (${m.year})` : ""}
|
||||||
|
{range ? <span className="ml-2 font-normal tabular-nums">{range}</span> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组头右侧:分类 · n 条 · 非成功状态(只列非零项,成功是常态不写) */
|
||||||
|
function GroupMeta({ rows }: { rows: Rec[] }) {
|
||||||
|
const cat = rows[0] ? recordCategory(rows[0]) : "";
|
||||||
|
const issues = groupIssues(rows);
|
||||||
|
return (
|
||||||
|
<span className="flex shrink-0 items-center gap-2 tabular-nums">
|
||||||
|
{cat ? <span className="hidden sm:inline">{cat}</span> : null}
|
||||||
|
<span>{rows.length} 条</span>
|
||||||
|
{issues.map((x) => (
|
||||||
|
<span
|
||||||
|
key={x.label}
|
||||||
|
className={cn(x.tone === "destructive" && "text-destructive", x.tone === "warning" && "text-warning")}
|
||||||
|
>
|
||||||
|
{x.label} {x.n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */
|
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */
|
||||||
function shortPath(p: string | null): string | null {
|
function shortPath(p: string | null): string | null {
|
||||||
if (!p) return p;
|
if (!p) return p;
|
||||||
@@ -324,7 +383,15 @@ export default function OrganizePage() {
|
|||||||
const [choosingSource, setChoosingSource] = useState(false);
|
const [choosingSource, setChoosingSource] = useState(false);
|
||||||
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
|
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
|
||||||
const [toDelete, setToDelete] = useState<Rec[] | null>(null);
|
const [toDelete, setToDelete] = useState<Rec[] | null>(null);
|
||||||
const grouped = v.group === "entity";
|
// 分组:URL ?group= 优先,否则用本浏览器记住的选择,缺省按作品分组(挂载后读,服务端渲染时一律按缺省)
|
||||||
|
const [storedGrouping, setStoredGrouping] = useState<string | null>(null);
|
||||||
|
useEffect(() => setStoredGrouping(readGroupingPref()), []);
|
||||||
|
const grouped = resolveGrouping(v.group, storedGrouping) === "entity";
|
||||||
|
const setGrouping = (next: RecordGrouping) => {
|
||||||
|
writeGroupingPref(next);
|
||||||
|
setStoredGrouping(next);
|
||||||
|
setFilter("group", next);
|
||||||
|
};
|
||||||
|
|
||||||
const selRows = (allRows ?? []).filter((r) => sel.has(r.id));
|
const selRows = (allRows ?? []).filter((r) => sel.has(r.id));
|
||||||
const allPicked = !!rows?.length && rows.every((r) => sel.has(r.id));
|
const allPicked = !!rows?.length && rows.every((r) => sel.has(r.id));
|
||||||
@@ -397,8 +464,16 @@ export default function OrganizePage() {
|
|||||||
<p className="truncate text-foreground" title={recordTitle(r)}>
|
<p className="truncate text-foreground" title={recordTitle(r)}>
|
||||||
{r.media ? recordTitle(r) : <span className="text-muted-foreground">未识别 · {recordTitle(r)}</span>}
|
{r.media ? recordTitle(r) : <span className="text-muted-foreground">未识别 · {recordTitle(r)}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
{recordCategory(r) ? (
|
||||||
|
<p className="hidden truncate text-meta text-muted-foreground sm:block">{recordCategory(r)}</p>
|
||||||
|
) : null}
|
||||||
<p className="truncate text-meta text-muted-foreground sm:hidden">
|
<p className="truncate text-meta text-muted-foreground sm:hidden">
|
||||||
{[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))]
|
{[
|
||||||
|
recordCategory(r),
|
||||||
|
ACTION_LABEL[r.action],
|
||||||
|
r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null,
|
||||||
|
relTime(when(r)),
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
@@ -414,13 +489,13 @@ export default function OrganizePage() {
|
|||||||
{ key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => <PathLines r={r} /> },
|
{ key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => <PathLines r={r} /> },
|
||||||
{
|
{
|
||||||
key: "action",
|
key: "action",
|
||||||
header: "方式",
|
header: "整理方式",
|
||||||
hideBelow: "sm",
|
hideBelow: "sm",
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="whitespace-nowrap">
|
<div className="flex flex-col items-start gap-1 whitespace-nowrap">
|
||||||
<p>{ACTION_LABEL[r.action]}</p>
|
<ActionTag action={r.action} />
|
||||||
{r.sizeBytes ? (
|
{r.sizeBytes ? (
|
||||||
<p className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</p>
|
<span className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -517,7 +592,7 @@ export default function OrganizePage() {
|
|||||||
variant={grouped ? "secondary" : "ghost"}
|
variant={grouped ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-pressed={grouped}
|
aria-pressed={grouped}
|
||||||
onClick={() => setFilter("group", grouped ? "" : "entity")}
|
onClick={() => setGrouping(grouped ? "none" : "entity")}
|
||||||
>
|
>
|
||||||
<ListTree />
|
<ListTree />
|
||||||
按作品分组
|
按作品分组
|
||||||
@@ -605,11 +680,13 @@ export default function OrganizePage() {
|
|||||||
groupBy={
|
groupBy={
|
||||||
grouped
|
grouped
|
||||||
? {
|
? {
|
||||||
key: (r) => r.media?.entityId ?? "",
|
// 未识别的记录互不相干,各自一行
|
||||||
label: (k, g) => {
|
key: (r) => r.media?.entityId ?? `none:${r.id}`,
|
||||||
const m = g[0]?.media;
|
label: (_k, g) => <GroupLabel rows={g} />,
|
||||||
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
|
meta: (_k, g) => <GroupMeta rows={g} />,
|
||||||
},
|
flat: (_k, g) => g.length === 1,
|
||||||
|
defaultCollapsed: true,
|
||||||
|
storageKey: "organize-records-entity",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,9 @@
|
|||||||
|
|
||||||
### 5.1 行内 hover 操作(桌面)
|
### 5.1 行内 hover 操作(桌面)
|
||||||
|
|
||||||
按行状态只露 2 个 + ⋯:待识别视频/目录 → `整理` `识别`;已登记 → `重命名` `打开作品`;其他 → `重命名` `移动到`。
|
鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。
|
||||||
|
|
||||||
|
面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。
|
||||||
|
|
||||||
### 5.2 右键 / ⋯ 菜单(单项,最终顺序)
|
### 5.2 右键 / ⋯ 菜单(单项,最终顺序)
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ export const PublicOrganizationRecord = z.object({
|
|||||||
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
|
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
|
||||||
/** HIS-1:作品海报(TMDB poster_path,如 /abc.jpg);没有为 null */
|
/** HIS-1:作品海报(TMDB poster_path,如 /abc.jpg);没有为 null */
|
||||||
posterPath: z.string().nullable().optional(),
|
posterPath: z.string().nullable().optional(),
|
||||||
|
/** 整理时按二级分类策略得到的分类(如「纪录片」「欧美电影」,取自冻结计划);没有为 null */
|
||||||
|
category: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
trigger: OrganizationTrigger,
|
trigger: OrganizationTrigger,
|
||||||
|
|||||||
Reference in new issue
Block a user