feat(web): file manager v2 and shared organize wizard (FE-4)

- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px
- 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多"
- 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB
- 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行),
  TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、
  plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录
- 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…"
- 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:26:41 +08:00
1 parent 09a6b0b5d3
commit c31df8b69a
24 files changed
+4820 -1607

No files matched your search

+10 -13
View File
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { healthLabel } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { joinRel, segments } from "./model.ts";
import { groupRoots, joinRel, segments } from "./model.ts";
type Loc = { root: string; path: string };
type DropHandler = (target: Loc, e: React.DragEvent) => void;
@@ -11,7 +11,7 @@ type DropHandler = (target: Loc, e: React.DragEvent) => void;
const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
/**
* 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间;
* 登记目录 + 懒加载目录树。下载目录在前、媒体库在后,每个登记目录带健康点与剩余空间;
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
*/
@@ -55,13 +55,10 @@ export function DirTree({
return next;
});
const groups = [
{ title: "媒体库", items: roots.filter((r) => r.kind === "library") },
{ title: "下载目录", items: roots.filter((r) => r.kind === "download") },
].filter((g) => g.items.length > 0);
const groups = groupRoots(roots);
return (
<nav aria-label="根与目录" className={cn("space-y-4", className)}>
<nav aria-label="登记目录" className={cn("space-y-4", className)}>
{groups.map((g) => (
<section key={g.title} aria-label={g.title}>
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
@@ -126,7 +123,7 @@ function RootNode({
if (!open.has(k)) toggle(k);
}}
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
title={disabled ? (offline ? "根离线" : "只读根,不能作为目标") : root.hostPath}
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
@@ -150,7 +147,7 @@ function RootNode({
</span>
)}
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable ? <span>只读</span> : null}
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
</span>
</span>
</TreeRow>
@@ -229,8 +226,8 @@ function Children({
depth={depth}
active={active}
disabled={disabled}
expandable
expanded={isOpen}
expandable={d.hasChildren}
expanded={isOpen && d.hasChildren}
onToggle={() => toggle(k)}
onClick={() => !disabled && onSelect({ root: root.id, path: p })}
onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined}
@@ -243,7 +240,7 @@ function Children({
)}
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
</TreeRow>
{isOpen ? (
{isOpen && d.hasChildren ? (
<Children
root={root}
path={p}
@@ -261,7 +258,7 @@ function Children({
})}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
只显示前 1000 个子目录
子目录过多,只列出一部分
</p>
) : null}
</div>