- 永久删除的说明改成实际开关 FILES_ALLOW_PERMANENT_DELETE,去掉「在设置里开启」的错误说法 - 去掉「待处理」「仅 TMDB」「目录根」等旧称,收录版本退回状态写「需处理」 - 删除、移动/复制、重命名、刮削、刷新元数据对话框去掉复述界面的说明 - 加载与进行中文字统一带省略号;Toast 只写结果 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
237 lines
8.5 KiB
TypeScript
237 lines
8.5 KiB
TypeScript
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>
|
||
);
|
||
}
|