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 ?? [];
|
||||
|
||||
Reference in new issue
Block a user