Files
UHD_Media_Tool/apps/web/app/components/files/file-preview.tsx
T
rootandClaude Opus 5.5 32bf49a148 文案(文件管理):按 human-copy 重写对话框、状态、菜单与提示
- 永久删除的说明改成实际开关 FILES_ALLOW_PERMANENT_DELETE,去掉「在设置里开启」的错误说法
- 去掉「待处理」「仅 TMDB」「目录根」等旧称,收录版本退回状态写「需处理」
- 删除、移动/复制、重命名、刮削、刷新元数据对话框去掉复述界面的说明
- 加载与进行中文字统一带省略号;Toast 只写结果

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:20 +08:00

237 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}