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:
1 parent
3add4bab41
commit
289e8b297a
20 files changed
+1294
-159
No files matched your search
@@ -2,6 +2,7 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClie
|
||||
import {
|
||||
cursorPage,
|
||||
type FileEntry,
|
||||
FilePreview,
|
||||
type FileRef,
|
||||
FileRootList,
|
||||
FileStat,
|
||||
@@ -241,57 +242,17 @@ export function fileDownloadUrl(root: string, path: string, inline = false): str
|
||||
return `/api/v1/files/download?${qs}`;
|
||||
}
|
||||
|
||||
/** 文本预览最多读前 256 KB(规格 §9) */
|
||||
export const TEXT_PREVIEW_BYTES = 256 * 1024;
|
||||
|
||||
export type TextPreview = { text: string; truncated: boolean };
|
||||
|
||||
export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
|
||||
/** 结构化预览(FM-2):文本/NFO 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;服务端只读文件头 */
|
||||
export function useFilePreview(root: string | null, path: string | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "preview", root, path],
|
||||
enabled: enabled && !!root && !!path,
|
||||
retry: false,
|
||||
queryFn: async ({ signal }): Promise<TextPreview> => {
|
||||
if (filesMockEnabled())
|
||||
return { text: await (await loadMock()).mockPreview(root ?? "", path ?? ""), truncated: false };
|
||||
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
|
||||
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
|
||||
return readTextHead(res, TEXT_PREVIEW_BYTES);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
queryFn: ({ signal }) => call(FilePreview, "/files/preview", { params: { root, path }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 只读响应体前 max 字节(流式,读够就取消),按 UTF-8 解码 */
|
||||
async function readTextHead(res: Response, max: number): Promise<TextPreview> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
const t = await res.text();
|
||||
return { text: t.slice(0, max), truncated: t.length > max };
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let n = 0;
|
||||
let truncated = false;
|
||||
while (n < max) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || !value) break;
|
||||
chunks.push(value);
|
||||
n += value.length;
|
||||
}
|
||||
if (n >= max) {
|
||||
truncated = n > max || !(await reader.read()).done;
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
const buf = new Uint8Array(Math.min(n, max));
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
const part = c.subarray(0, Math.min(c.length, buf.length - off));
|
||||
buf.set(part, off);
|
||||
off += part.length;
|
||||
if (off >= buf.length) break;
|
||||
}
|
||||
return { text: new TextDecoder("utf-8", { fatal: false }).decode(buf), truncated };
|
||||
}
|
||||
|
||||
/** 整理记录回查:ids 或 planIds(执行后映射到记录);执行中每 3 秒刷新 */
|
||||
export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) {
|
||||
const ids = q.ids ?? [];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
useTrash,
|
||||
} from "~/api/files.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||
import {
|
||||
@@ -88,7 +87,7 @@ import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx";
|
||||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
@@ -113,7 +112,13 @@ type Dialog =
|
||||
| { kind: "wizard"; source: OrganizeSource }
|
||||
| { kind: "shortcuts" };
|
||||
|
||||
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||||
export const handle = { layout: "wide" };
|
||||
|
||||
const TREE_W_KEY = "umt-files-tree-w";
|
||||
const TREE_W_DEFAULT = 280;
|
||||
const TREE_W_MIN = 200;
|
||||
const TREE_W_MAX = 520;
|
||||
const TREE_OPEN_KEY = "umt-files-tree-open";
|
||||
const LAST_LOC_KEY = "umt-files-last";
|
||||
const FILES_CHANGED = "files.changed";
|
||||
@@ -150,13 +155,13 @@ function writeStore(key: string, v: string) {
|
||||
}
|
||||
|
||||
function useTreeWidth() {
|
||||
const [w, setW] = useState(260);
|
||||
const [w, setW] = useState(TREE_W_DEFAULT);
|
||||
useEffect(() => {
|
||||
const v = Number(readStore(TREE_W_KEY));
|
||||
if (v >= 200 && v <= 440) setW(v);
|
||||
if (v >= TREE_W_MIN && v <= TREE_W_MAX) setW(v);
|
||||
}, []);
|
||||
const save = (v: number) => {
|
||||
const c = Math.max(200, Math.min(440, Math.round(v)));
|
||||
const c = Math.max(TREE_W_MIN, Math.min(TREE_W_MAX, Math.round(v)));
|
||||
setW(c);
|
||||
writeStore(TREE_W_KEY, String(c));
|
||||
};
|
||||
@@ -876,45 +881,47 @@ export default function FilesPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="文件管理"
|
||||
actions={
|
||||
mobile ? undefined : (
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<Trash2 />
|
||||
回收站{trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */}
|
||||
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
|
||||
<h1 className="sr-only">文件管理</h1>
|
||||
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0", mobile && "flex-col p-3")}>
|
||||
{/* 桌面左栏:贴着侧边栏的整高目录树(可折叠、可拖宽,宽度记在本机) */}
|
||||
{!mobile ? (
|
||||
<aside
|
||||
className="pg-chrome relative shrink-0 self-start overflow-hidden rounded-2xl"
|
||||
className="pg-chrome pg-chrome-side relative flex shrink-0 flex-col border-r"
|
||||
style={{ width: treeOpen ? treeW : 44 }}
|
||||
aria-label="登记目录与目录树"
|
||||
>
|
||||
{treeOpen ? (
|
||||
<>
|
||||
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
|
||||
{/* 左侧留出全站侧栏折叠钮的位置 */}
|
||||
<div className="flex h-11 shrink-0 items-center border-b border-[var(--shell-border)] pr-2 pl-6">
|
||||
<span className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
登记目录
|
||||
</span>
|
||||
{filesMockEnabled() ? (
|
||||
<StatusBadge tone="warning" className="ml-auto">
|
||||
演示数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tree}</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
title="拖动调整宽度,双击恢复默认"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={200}
|
||||
aria-valuemax={440}
|
||||
aria-valuemin={TREE_W_MIN}
|
||||
aria-valuemax={TREE_W_MAX}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
@@ -925,25 +932,28 @@ export default function FilesPage() {
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TreeRail
|
||||
roots={rootList}
|
||||
current={rootId}
|
||||
onSelect={go}
|
||||
onExpand={() => setTreeOpenStored(true)}
|
||||
trashCount={trashCount}
|
||||
/>
|
||||
<>
|
||||
<div aria-hidden className="h-11 shrink-0 border-b border-[var(--shell-border)]" />
|
||||
<TreeRail
|
||||
roots={rootList}
|
||||
current={rootId}
|
||||
onSelect={go}
|
||||
onExpand={() => setTreeOpenStored(true)}
|
||||
trashCount={trashCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
"pg-slab relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
||||
mobile ? "h-[calc(100dvh-9.5rem)] min-h-[24rem]" : "h-[calc(100dvh-11rem)] min-h-[28rem]",
|
||||
"pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
||||
!mobile && "m-3",
|
||||
)}
|
||||
aria-label="目录内容"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SessionInfo } from "@umt/contracts";
|
||||
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, Outlet, useLocation } from "react-router";
|
||||
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||
@@ -176,6 +176,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
||||
// 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版
|
||||
const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide");
|
||||
|
||||
return (
|
||||
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
|
||||
@@ -245,8 +247,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
</header>
|
||||
|
||||
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
||||
<main className="relative flex-1 overflow-x-clip px-4 pt-6 pb-24 md:px-6 md:pt-8">
|
||||
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
|
||||
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
|
||||
<div key={location.pathname} className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in new issue
Block a user