Files
UHD_Media_Tool/apps/web/app/components/files/dir-tree.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

376 lines
11 KiB
TypeScript

import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { healthLabel } from "~/lib/labels.ts";
import { cn } from "~/lib/utils.ts";
import { groupRoots, joinRel, segments } from "./model.ts";
type Loc = { root: string; path: string };
type DropHandler = (target: Loc, e: React.DragEvent) => void;
const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
/**
* 登记目录 + 懒加载目录树。下载目录在前、媒体库在后,每个登记目录带健康点与剩余空间;
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
*/
export function DirTree({
roots,
current,
onSelect,
onDropTo,
pickOnly = false,
paginate = false,
defaultOpen = [],
className,
}: {
roots: FileRoot[];
current: Loc | null;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly?: boolean;
/** 目录选择小窗按页展示子目录;主文件树保持懒展开。 */
paginate?: boolean;
/** 初始展开的根 id(目标选择器里默认展开可写根) */
defaultOpen?: string[];
className?: string;
}) {
const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen.map((id) => nodeKey(id, ""))));
// 当前路径的各级祖先自动展开
useEffect(() => {
if (!current) return;
setOpen((prev) => {
const next = new Set(prev);
const segs = segments(current.path);
next.add(nodeKey(current.root, ""));
for (let i = 1; i < segs.length; i++) next.add(nodeKey(current.root, segs.slice(0, i).join("/")));
return next.size === prev.size ? prev : next;
});
}, [current]);
const toggle = (k: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
const groups = groupRoots(roots);
return (
<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/75 uppercase">
{g.title}
</p>
<div className="space-y-0.5">
{g.items.map((r) => (
<RootNode
key={r.id}
root={r}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
))}
</div>
</section>
))}
</nav>
);
}
function RootNode({
root,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: boolean;
}) {
const k = nodeKey(root.id, "");
const offline = root.health === "offline";
const disabled = pickOnly && (!root.writable || offline);
const isOpen = open.has(k) && !offline;
const active = current?.root === root.id && current.path === "";
const h = healthLabel(root.health);
const Icon = root.kind === "download" ? Download : HardDrive;
return (
<div>
<TreeRow
depth={0}
active={active}
disabled={disabled}
expandable={!offline}
expanded={isOpen}
onToggle={() => toggle(k)}
onClick={() => {
if (disabled) return;
onSelect({ root: root.id, path: "" });
if (!open.has(k)) toggle(k);
}}
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body text-foreground">{root.name}</span>
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
{root.health !== "healthy" ? (
<span
className={cn(
"inline-flex items-center gap-1",
h.tone === "destructive" && "text-destructive",
h.tone === "warning" && "text-warning",
)}
>
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
{h.label}
</span>
) : null}
{root.pendingCount ? (
<span className="shrink-0 text-warning">
未识别 {root.pendingCount}
{root.pendingPartial ? "+" : ""}
</span>
) : null}
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
</span>
</span>
</TreeRow>
{isOpen ? (
<Children
root={root}
path=""
depth={1}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
) : null}
</div>
);
}
function Children({
root,
path,
depth,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
path: string;
depth: number;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: boolean;
}) {
const q = useDirChildren(root.id, path, true);
const dirs = q.data?.dirs ?? [];
const paged = useListPagination(dirs, { resetKey: JSON.stringify({ root: root.id, path }) });
const selectedIndex =
current?.root === root.id
? dirs.findIndex((dir) => {
const childPath = dir.path || joinRel(path, dir.name);
return current.path === childPath || current.path.startsWith(`${childPath}/`);
})
: -1;
// biome-ignore lint/correctness/useExhaustiveDependencies: 选中目录改变时定位;手动翻页不改变已有选择
useEffect(() => {
if (paginate && selectedIndex >= 0)
paged.pagination.onPageChange(Math.floor(selectedIndex / paged.pagination.pageSize) + 1);
}, [paginate, root.id, path, selectedIndex, paged.pagination.pageSize]);
if (q.isPending)
return (
<div
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
style={{ paddingLeft: depth * 14 + 28 }}
>
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
</div>
);
if (q.error)
return (
<p className="py-1 text-meta text-destructive" style={{ paddingLeft: depth * 14 + 28 }}>
读取失败
</p>
);
if (!dirs.length)
return (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
无子目录
</p>
);
return (
<div>
{(paginate ? paged.items : dirs)?.map((d) => {
const p = d.path || joinRel(path, d.name);
const k = nodeKey(root.id, p);
const isOpen = open.has(k);
const active = current?.root === root.id && current.path === p;
const disabled = pickOnly && !root.writable;
return (
<div key={k}>
<TreeRow
depth={depth}
active={active}
disabled={disabled}
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}
title={d.name}
>
{isOpen ? (
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
</TreeRow>
{isOpen && d.hasChildren ? (
<Children
root={root}
path={p}
depth={depth + 1}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
) : null}
</div>
);
})}
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
子目录过多,只列出一部分
</p>
) : null}
</div>
);
}
function TreeRow({
depth,
active,
disabled,
expandable,
expanded,
onToggle,
onClick,
onDrop,
title,
children,
}: {
depth: number;
active: boolean;
disabled?: boolean;
expandable: boolean;
expanded: boolean;
onToggle: () => void;
onClick: () => void;
onDrop?: (e: React.DragEvent) => void;
title?: string;
children: React.ReactNode;
}) {
const [over, setOver] = useState(false);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: 整行是拖放目标,交互本体是里面的两个按钮
<div
className={cn(
"group flex items-center gap-1 rounded-lg pr-2 transition-colors duration-micro",
active
? "pill-selected text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
disabled && "opacity-50",
over && "bg-foreground/[0.08] ring-1 ring-ring",
)}
style={{ paddingLeft: depth * 14 }}
onDragOver={
onDrop
? (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
setOver(true);
}
: undefined
}
onDragLeave={onDrop ? () => setOver(false) : undefined}
onDrop={
onDrop
? (e) => {
e.preventDefault();
setOver(false);
onDrop(e);
}
: undefined
}
>
<button
type="button"
tabIndex={-1}
aria-label={expanded ? "收起" : "展开"}
aria-expanded={expanded}
onClick={onToggle}
className={cn("grid h-7 w-6 shrink-0 place-items-center rounded-md", !expandable && "invisible")}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", expanded && "rotate-90")} />
</button>
<button
type="button"
onClick={onClick}
title={title}
aria-current={active ? "location" : undefined}
aria-disabled={disabled || undefined}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left outline-none focus-visible:underline",
disabled && "cursor-not-allowed",
)}
>
{children}
</button>
</div>
);
}