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

+5 -44
View File
@@ -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 ?? [];