feat(files): wide file manager layout and basic previews (FM-2)

- Files page uses a wide layout (route handle layout:"wide"): no max width,
  full-height directory tree docked next to the sidebar, resizable
  (200-520px, remembered, double-click resets) and collapsible; mobile keeps
  the tree in a drawer.
- GET /files/preview (admin console session only, same path guards as
  download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5),
  subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file
  list/trackers (passkey masked)/infohash, image dimensions. Reads at most
  1 MiB of text; .torrent capped at 4 MiB.
- Download also allows text files, .torrent and avif/gif.
- Research notes in docs/design/file-preview-research.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 21:44:13 +08:00
1 parent 3add4bab41
commit 289e8b297a
20 files changed
+1294 -159

No files matched your search

+9 -66
View File
@@ -1,17 +1,8 @@
import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
import { LoaderCircle, ScanSearch } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileRoot,
fileDownloadUrl,
filesErrorText,
filesMockEnabled,
useFileStat,
useProbe,
useTextPreview,
} from "~/api/files.ts";
import { type FileEntry, type FileRoot, filesErrorText, useFileStat, useProbe } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
@@ -19,14 +10,13 @@ import { KvList } from "~/components/console/kv-list.tsx";
import { Timeline } from "~/components/console/timeline.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { Button } from "~/components/ui/button.tsx";
import { downloadLabel } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { EntryStatus } from "./file-list.tsx";
import { FilePreviewSection } from "./file-preview.tsx";
import { IdentifyPanel } from "./identify-panel.tsx";
import { baseName } from "./model.ts";
const PREVIEW_MAX = 20 * 1024 * 1024;
import { baseName, previewKindOf } from "./model.ts";
const ACTION_LABEL: Record<string, string> = {
copy: "复制",
@@ -78,7 +68,7 @@ function RecordsSection({ entityId }: { entityId: string }) {
);
}
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与预览 */
export function FileDetail({
root,
entry,
@@ -99,11 +89,7 @@ export function FileDetail({
const stat = useFileStat(root.id, entry.path);
const probe = useProbe();
const e = stat.data?.entry ?? entry;
const previewable =
e.type === "file" &&
e.size <= PREVIEW_MAX &&
(e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image");
const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image");
const pk = previewKindOf(e);
const media = probe.data?.items[0]?.summary ?? e.media;
return (
@@ -113,6 +99,7 @@ export function FileDetail({
title={e.name}
description={<EntryStatus e={e} />}
footer={actions}
width={pk && pk !== "image" ? "lg" : "md"}
>
<DetailSection title="文件">
<KvList
@@ -292,51 +279,7 @@ export function FileDetail({
</DetailSection>
) : null}
{previewable ? (
<DetailSection
title="预览"
actions={
filesMockEnabled() ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, e.path)}
download={e.name}
>
<Download />
下载
</a>
)
}
>
{e.fileKind === "image" ? (
filesMockEnabled() ? (
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
) : (
<a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
<img
src={fileDownloadUrl(root.id, e.path, true)}
alt={e.name}
className="max-h-80 rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图
</span>
</a>
)
) : text.isPending ? (
<LoadingState rows={3} />
) : text.error ? (
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
) : (
<>
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
{text.data?.text}
</pre>
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
</>
)}
</DetailSection>
) : null}
<FilePreviewSection root={root} entry={e} />
</DetailSheet>
);
}
@@ -0,0 +1,236 @@
import type { FilePreview } from "@umt/contracts";
import { Download, ExternalLink } from "lucide-react";
import {
type FileEntry,
type FileRoot,
fileDownloadUrl,
filesErrorText,
filesMockEnabled,
useFilePreview,
} from "~/api/files.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { buttonVariants } from "~/components/ui/button.tsx";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { cueTime, prettyJson, previewKindOf } from "./model.ts";
/** 图片原图经 /files/download 内联,上限与下载一致 */
const IMAGE_INLINE_MAX = 20 * 1024 * 1024;
const ENCODING_LABEL: Record<string, string> = {
"utf-8": "UTF-8",
"utf-16le": "UTF-16 LE",
"utf-16be": "UTF-16 BE",
gb18030: "GBK / GB18030",
big5: "Big5",
"windows-1252": "Latin-1",
};
const encLabel = (e: string | null) => (e ? (ENCODING_LABEL[e] ?? e) : null);
const preBox =
"max-h-[28rem] overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap break-words";
function Meta({ items }: { items: (string | null | false | undefined)[] }) {
return <p className="mb-2 text-meta text-muted-foreground">{items.filter(Boolean).join(" · ")}</p>;
}
function TextView({ p }: { p: Extract<FilePreview, { type: "text" }> }) {
const body = p.format === "json" ? prettyJson(p.text, p.truncated) : p.text;
return (
<>
{body.trim() ? (
<pre className={preBox}>{body}</pre>
) : (
<p className="text-caption text-muted-foreground">空文件。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function SubtitleView({ p }: { p: Extract<FilePreview, { type: "subtitle" }> }) {
const graphic = p.format === "sup";
const count =
p.parsed > p.cues.length
? `共 ${p.parsed}${p.truncated ? "+" : ""} 条,显示前 ${p.cues.length} 条`
: `${p.cues.length} 条`;
return (
<>
<Meta items={[p.title, count]} />
{graphic ? <p className="mb-2 text-caption text-muted-foreground">PGS 图形字幕没有文字,只列时间轴。</p> : null}
{p.cues.length ? (
<ol className="max-h-[28rem] divide-y divide-border overflow-auto rounded-xl border border-border">
{p.cues.map((c, i) => (
<li key={i} className="flex gap-3 px-3 py-1.5 text-caption">
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
{cueTime(c.start)}
{graphic ? ` → ${cueTime(c.end)}` : ""}
</span>
{graphic ? null : <span className="min-w-0 whitespace-pre-wrap break-words">{c.text}</span>}
</li>
))}
</ol>
) : (
<p className="text-caption text-muted-foreground">没有读到字幕条目。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只读了文件前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function TorrentView({ p }: { p: Extract<FilePreview, { type: "torrent" }> }) {
const prefix = `${p.name}/`;
return (
<div className="space-y-4">
<KvList
items={[
{ label: "名称", value: p.name, wide: true },
{ label: "总大小", value: `${formatBytes(p.totalSize, 2)} · ${p.fileCount} 个文件` },
{ label: "私有种子", value: p.private ? <StatusBadge tone="warning">私有</StatusBadge> : "否" },
...(p.comment ? [{ label: "注释", value: p.comment, wide: true }] : []),
]}
/>
<div>
<p className="mb-1 text-meta text-muted-foreground">
文件 {p.fileCount}
{p.fileCount > p.files.length ? `,显示前 ${p.files.length} 个` : ""}
</p>
<ul className="max-h-72 divide-y divide-border overflow-auto rounded-xl border border-border">
{p.files.map((f) => (
<li key={f.path} className="flex items-baseline gap-3 px-3 py-1.5 text-caption">
<span className="min-w-0 flex-1 font-mono text-meta break-all">
{f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path}
</span>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">{formatBytes(f.length, 1)}</span>
</li>
))}
</ul>
</div>
<TechInfo
items={[
{ label: "infohash", value: p.infohashV1 ?? p.hash, wide: true },
p.infohashV2 ? { label: "infohash v2", value: p.infohashV2, wide: true } : null,
{ label: "分块", value: p.pieceLength ? formatBytes(p.pieceLength, 0) : null },
{ label: "创建时间", value: p.createdAt ? formatDateTime(p.createdAt) : null },
{ label: "创建工具", value: p.createdBy },
{ label: ".torrent 大小", value: formatBytes(p.size, 1) },
]}
>
<div>
<p className="mb-1 text-meta text-muted-foreground">Tracker {p.trackers.length}(passkey 已隐藏)</p>
{p.trackers.length ? (
<ul className="space-y-0.5 font-mono text-meta break-all">
{p.trackers.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有 tracker,只能走 DHT。</p>
)}
</div>
</TechInfo>
</div>
);
}
function ImageView({
root,
entry,
p,
}: {
root: FileRoot;
entry: FileEntry;
p: Extract<FilePreview, { type: "image" }>;
}) {
const dims = p.width && p.height ? `${p.width} × ${p.height}` : null;
const meta = <Meta items={[dims, formatBytes(p.size, 1)]} />;
if (filesMockEnabled())
return (
<>
{meta}
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
</>
);
if (p.size > IMAGE_INLINE_MAX)
return (
<>
{meta}
<p className="text-caption text-muted-foreground">图片超过 20 MB,不在这里显示。</p>
</>
);
const src = fileDownloadUrl(root.id, entry.path, true);
return (
<>
{meta}
<a href={src} target="_blank" rel="noreferrer" className="block">
<img
src={src}
alt={entry.name}
loading="lazy"
decoding="async"
className="max-h-[28rem] rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图
</span>
</a>
</>
);
}
/** 详情抽屉的「预览」节:文本/NFO、字幕、.torrent、图片;其它类型不渲染 */
export function FilePreviewSection({ root, entry }: { root: FileRoot; entry: FileEntry }) {
const kind = previewKindOf(entry);
const q = useFilePreview(root.id, entry.path, !!kind && !entry.escape);
if (!kind || entry.escape) return null;
return (
<DetailSection
title="预览"
actions={
filesMockEnabled() || entry.size > IMAGE_INLINE_MAX ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, entry.path)}
download={entry.name}
>
<Download />
下载
</a>
)
}
>
{q.isPending ? (
<LoadingState rows={3} />
) : q.error ? (
<p className="text-caption text-destructive">{filesErrorText(q.error, "预览失败")}</p>
) : q.data.type === "text" ? (
<TextView p={q.data} />
) : q.data.type === "subtitle" ? (
<SubtitleView p={q.data} />
) : q.data.type === "torrent" ? (
<TorrentView p={q.data} />
) : (
<ImageView root={root} entry={entry} p={q.data} />
)}
</DetailSection>
);
}
+82 -7
View File
@@ -5,6 +5,7 @@ import type {
FileEntry,
FileIdentifyItem,
FileKindTag,
FilePreview,
FileRef,
FileRoot,
FileStat,
@@ -238,6 +239,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
}),
f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
f("README.txt", 1_024, 700),
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR.torrent", 186_000, 30),
f("escape-link", 0, 700, { escape: true }),
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
f(".hidden-note", 64, 700),
@@ -922,6 +924,8 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
return list(p);
case "/files/stat":
return stat(p);
case "/files/preview":
return mockFilePreview(p);
case "/files/mkdir": {
const { node } = find(b.root, b.path);
if ((node.children ?? []).some((c) => c.name === b.name))
@@ -1167,11 +1171,82 @@ function mockRecord(id: string) {
};
}
export async function mockPreview(root: string, path: string): Promise<string> {
await delay(100);
if (path.endsWith(".nfo"))
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
if (/\.(srt|ass)$/.test(path))
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
return `(${root}:${path})`;
function mockFilePreview(p: Record<string, unknown>): FilePreview {
const path = String(p.path ?? "");
const { node } = find(String(p.root ?? ""), path);
const size = node.size;
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? "";
if (ext === "nfo")
return {
type: "text",
size,
format: "nfo",
encoding: "utf-8",
truncated: false,
text: `<?xml version="1.0" encoding="utf-8"?>\n<movie>\n <title>沙丘 2</title>\n <originaltitle>Dune: Part Two</originaltitle>\n <year>2024</year>\n <uniqueid type="tmdb" default="true">693134</uniqueid>\n <genre>科幻</genre>\n <runtime>166</runtime>\n</movie>`,
};
if (ext === "txt")
return {
type: "text",
size,
format: "txt",
encoding: "gb18030",
truncated: false,
text: ["本目录是 qBittorrent 的默认保存路径。", "做种中的文件不要改名或移动。"].join("\n"),
};
if (ext === "srt" || ext === "ass") {
const lines = [
"厄拉科斯。",
"一切都从这里开始。",
"香料必须流动。",
"我看见了未来。",
"沙虫来了!",
"保罗·厄崔迪。",
];
const cues = Array.from({ length: 24 }, (_, i) => ({
start: 62_000 + i * 3_400,
end: 64_500 + i * 3_400,
text: lines[i % lines.length] as string,
}));
return {
type: "subtitle",
size,
format: ext,
encoding: ext === "ass" ? "utf-16le" : "utf-8",
cues,
parsed: 1_342,
truncated: false,
title: ext === "ass" ? "Severance S02E01 简中" : null,
};
}
if (ext === "torrent")
return {
type: "torrent",
size,
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
hash: "3f786850e387550fdab836ed7e6dc881de23001b",
infohashV1: "3f786850e387550fdab836ed7e6dc881de23001b",
infohashV2: null,
private: true,
totalSize: Math.round(71.2 * GB + 96_000),
fileCount: 2,
files: [
{
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
length: Math.round(71.2 * GB),
},
{
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/chs.srt",
length: 96_000,
},
],
trackers: ["https://tracker.example.org/announce.php?passkey=…"],
comment: null,
createdBy: "mktorrent 1.1",
createdAt: ago(40),
pieceLength: 16 * MB,
};
if (IMG.has(ext))
return { type: "image", size, mime: `image/${ext === "jpg" ? "jpeg" : ext}`, width: 2000, height: 3000 };
throw new ApiRequestError("forbidden", `forbidden:不支持预览 .${ext} 文件`);
}
@@ -4,6 +4,7 @@ import {
applyIdentifyCompleted,
clickSelect,
collapseCrumbs,
cueTime,
deleteBlockText,
emptySelection,
entryStatusView,
@@ -18,6 +19,8 @@ import {
opApplies,
opDisabledReason,
parseEpisodes,
prettyJson,
previewKindOf,
pruneSelection,
selectAll,
shiftEpisodes,
@@ -329,3 +332,20 @@ describe("识别远端回填(S-7)", () => {
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
});
});
describe("预览(FM-2)", () => {
it("按扩展名分预览类型,目录与未知扩展名不预览", () => {
expect(previewKindOf({ type: "file", ext: "NFO" })).toBe("text");
expect(previewKindOf({ type: "file", ext: "ass" })).toBe("subtitle");
expect(previewKindOf({ type: "file", ext: "torrent" })).toBe("torrent");
expect(previewKindOf({ type: "file", ext: "avif" })).toBe("image");
expect(previewKindOf({ type: "file", ext: "mkv" })).toBeNull();
expect(previewKindOf({ type: "dir", ext: null })).toBeNull();
});
it("时间轴与 JSON 美化", () => {
expect(cueTime(3_723_045)).toBe("01:02:03.045");
expect(prettyJson('{"a":1}', false)).toBe('{\n "a": 1\n}');
expect(prettyJson('{"a":', false)).toBe('{"a":');
expect(prettyJson('{"a":1}', true)).toBe('{"a":1}');
});
});
+36
View File
@@ -519,3 +519,39 @@ export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyComple
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
};
}
// ── 预览(FM-2)──
export type PreviewKind = "text" | "subtitle" | "torrent" | "image";
const PREVIEW_EXT: Record<string, PreviewKind> = {};
for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" "))
PREVIEW_EXT[e] = "text";
for (const e of "srt vtt ass ssa sup".split(" ")) PREVIEW_EXT[e] = "subtitle";
for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) PREVIEW_EXT[e] = "image";
PREVIEW_EXT.torrent = "torrent";
/** 与服务端 preview-parse.ts 同一张扩展名表;目录、链接、无扩展名返回 null */
export function previewKindOf(e: Pick<FileEntry, "type" | "ext">): PreviewKind | null {
if (e.type !== "file" || !e.ext) return null;
return PREVIEW_EXT[e.ext.toLowerCase()] ?? null;
}
/** 毫秒 → 00:01:02.345 */
export function cueTime(ms: number): string {
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
const f = ms % 1000;
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(h)}:${p(m)}:${p(s)}.${p(f, 3)}`;
}
/** JSON 文本美化;解析失败或已截断时原样返回 */
export function prettyJson(text: string, truncated: boolean): string {
if (truncated) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}