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

+11 -1
View File
@@ -1540,10 +1540,20 @@ export const Api = {
path: "/files/download",
scope: "admin",
auth: "session",
summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;仅管理员控制台会话;审计)",
summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)",
query: files.FilesDownloadQuery,
response: files.FilesDownloadResponse,
}),
preview: ep({
method: "get",
path: "/files/preview",
scope: "admin",
auth: "session",
summary:
"结构化预览(文本/NFO 前 1 MiB 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;仅管理员控制台会话)",
query: files.FilesPreviewQuery,
response: files.FilePreview,
}),
records: ep({
method: "get",
path: "/files/records",
+64
View File
@@ -728,6 +728,70 @@ export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
// 预览(FM-2):GET /files/preview 返回结构化预览,仅管理员会话,路径守卫同下载。
// 文本/字幕最多读前 1 MiB 并探测编码(BOM / UTF-8 / UTF-16 / GB18030 / Big5);字幕解析前 200 条;
// .torrent ≤ 4 MiB 解析文件列表、tracker 与 infohash;图片只回尺寸类型信息,内容走 /files/download?inline=true。
export const FilesPreviewQuery = z.object({ root: FileRootId, path: FileRelPath });
export type FilesPreviewQuery = z.infer<typeof FilesPreviewQuery>;
export const SubtitleCue = z.object({
/** 毫秒 */
start: NonNegInt,
end: NonNegInt,
/** 去掉样式标签后的纯文本;图形字幕(sup)为空串 */
text: z.string(),
});
export type SubtitleCue = z.infer<typeof SubtitleCue>;
const PreviewBase = z.object({ size: NonNegInt });
export const FilePreview = z.discriminatedUnion("type", [
PreviewBase.extend({
type: z.literal("text"),
/** 小写扩展名(nfo/txt/log/md/json/xml/yaml…) */
format: z.string(),
/** 探测出的编码名(utf-8 / utf-16le / utf-16be / gb18030 / big5 / windows-1252) */
encoding: z.string(),
text: z.string(),
truncated: z.boolean(),
}),
PreviewBase.extend({
type: z.literal("subtitle"),
format: z.enum(["srt", "vtt", "ass", "ssa", "sup"]),
encoding: z.string().nullable(),
cues: z.array(SubtitleCue),
/** 读到的范围内共解析出多少条(可能大于 cues.length) */
parsed: NonNegInt,
/** 只读了文件前一部分 */
truncated: z.boolean(),
/** ASS/SSA 的 [Script Info] Title */
title: z.string().nullable(),
}),
PreviewBase.extend({
type: z.literal("torrent"),
name: z.string(),
hash: z.string(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
private: z.boolean(),
totalSize: NonNegInt,
fileCount: NonNegInt,
/** 最多 500 个 */
files: z.array(z.object({ path: z.string(), length: NonNegInt })),
trackers: z.array(z.string()),
comment: z.string().nullable(),
createdBy: z.string().nullable(),
createdAt: Timestamp.nullable(),
pieceLength: NonNegInt.nullable(),
}),
PreviewBase.extend({
type: z.literal("image"),
mime: z.string(),
width: NonNegInt.nullable(),
height: NonNegInt.nullable(),
}),
]);
export type FilePreview = z.infer<typeof FilePreview>;
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
const csvIds = z