Files
UHD_Media_Tool/apps/web/app/components/files/file-detail.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

343 lines
13 KiB
TypeScript

import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileRoot,
fileDownloadUrl,
filesErrorText,
filesMockEnabled,
useFileStat,
useProbe,
useTextPreview,
} from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { Timeline } from "~/components/console/timeline.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { downloadLabel } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { EntryStatus } from "./file-list.tsx";
import { IdentifyPanel } from "./identify-panel.tsx";
import { baseName } from "./model.ts";
const PREVIEW_MAX = 20 * 1024 * 1024;
const ACTION_LABEL: Record<string, string> = {
copy: "复制",
move: "移动",
rename: "重命名",
relocate: "目录调整",
hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
};
/** 抽屉"整理记录"节:已登记作品最近 5 条整理记录 */
function RecordsSection({ entityId }: { entityId: string }) {
const q = useRecordsForEntity(entityId);
const items = q.data?.items.slice(0, 5) ?? [];
return (
<DetailSection
title="整理记录"
actions={
<Link className="text-meta text-muted-foreground underline" to={`/console/organize?mediaEntityId=${entityId}`}>
查看整理记录
</Link>
}
>
{q.isPending ? (
<LoadingState rows={2} />
) : q.error ? (
<p className="text-caption text-destructive">读取整理记录失败</p>
) : !items.length ? (
<p className="text-caption text-muted-foreground">这个作品还没有整理记录。</p>
) : (
<Timeline
items={items.map((r) => ({
id: r.id,
at: r.finishedAt ?? r.startedAt ?? r.createdAt,
tone: RECORD_STATUS_TONE[r.status],
title: (
<Link className="hover:underline" to={`/console/organize/${r.id}`}>
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
</Link>
),
description: r.target.path ? <span className="font-mono text-meta break-all">{r.target.path}</span> : null,
meta: `${r.counts.succeeded}/${r.counts.total}`,
}))}
/>
)}
</DetailSection>
);
}
/** 选中单项的详情抽屉:文件信息、作品/版本目标/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
export function FileDetail({
root,
entry,
onClose,
actions,
identifyAuto,
onOrganize,
}: {
root: FileRoot;
entry: FileEntry;
onClose: () => void;
actions?: ReactNode;
/** 从菜单"识别"进入:自动识别一次 */
identifyAuto?: boolean;
/** 识别候选"按此整理" */
onOrganize?: (preset: WizardEntity | null) => void;
}) {
const stat = useFileStat(root.id, entry.path);
const probe = useProbe();
const e = stat.data?.entry ?? entry;
const previewable =
e.type === "file" &&
e.size <= PREVIEW_MAX &&
(e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image");
const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image");
const media = probe.data?.items[0]?.summary ?? e.media;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={e.name}
description={<EntryStatus e={e} />}
footer={actions}
>
<DetailSection title="文件">
<KvList
items={[
{ label: "类型", value: e.type === "dir" ? "目录" : e.type === "symlink" ? "符号链接" : (e.ext ?? "文件") },
{
label: "大小",
value:
e.type === "dir"
? e.dirSummary
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已登记`
: "—"
: formatBytes(e.size, 2),
},
{ label: "修改时间", value: formatDateTime(e.mtime) },
{
label: "链接数",
value:
stat.data?.nlink != null
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
: null,
},
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
...(stat.data && stat.data.realPath !== `${root.hostPath}/${e.path}`
? [{ label: "真实路径", value: stat.data.realPath, mono: true, wide: true }]
: []),
]}
/>
{stat.isPending ? <LoadingState rows={1} /> : null}
{stat.error ? (
<p className="mt-2 text-meta text-destructive">{filesErrorText(stat.error, "读取详情失败")}</p>
) : null}
</DetailSection>
{e.asset ? (
<DetailSection title="已登记资产">
<KvList
items={[
{
label: "作品",
value: (
<Link
className="underline underline-offset-2 hover:text-foreground"
to={`/console/library/${e.asset.mediaEntityId}`}
>
{e.asset.title}
{e.asset.year ? ` (${e.asset.year})` : ""}
</Link>
),
},
{ label: "季集", value: e.asset.contentUnit ?? "整部" },
{
label: "版本目标",
value: e.asset.slotId ? (
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
</Link>
) : (
"未满足任何版本目标"
),
},
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
]}
/>
</DetailSection>
) : null}
{e.asset ? <RecordsSection entityId={e.asset.mediaEntityId} /> : null}
{!e.escape && (e.type === "dir" || (e.type === "file" && e.fileKind === "video")) ? (
<IdentifyPanel root={root} entry={e} autoRun={identifyAuto} onOrganize={onOrganize} />
) : null}
{e.download ? (
<DetailSection title="下载任务">
<KvList
items={[
{
label: "任务",
value: (
<Link className="underline underline-offset-2" to={`/console/downloads/${e.download.taskId}`}>
{downloadLabel(e.download.status).label}
</Link>
),
},
{
label: "进度",
value: e.download.progress != null ? `${Math.floor(e.download.progress * 100)}%` : null,
},
{
label: "H&R",
value: e.download.hnr === "satisfied" ? "已满足" : e.download.hnr === "unsatisfied" ? "未满足" : "未知",
},
]}
/>
</DetailSection>
) : null}
{stat.data?.bindings.length ? (
<DetailSection title="路径绑定">
<ul className="space-y-1 text-caption">
{stat.data.bindings.map((b) => (
<li key={b.bindingId}>
<Link className="underline underline-offset-2" to={`/console/library/${b.mediaEntityId}`}>
{b.title}
</Link>
{b.kind === "season" && b.seasonNumber != null ? ` · 第 ${b.seasonNumber} 季` : " · 作品目录"}
</li>
))}
</ul>
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p>
</DetailSection>
) : null}
{stat.data?.sidecars.length ? (
<DetailSection title={`附属文件 ${stat.data.sidecars.length}`}>
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
{stat.data.sidecars.map((s) => (
<li key={s} className="truncate" title={s}>
{baseName(s)}
</li>
))}
</ul>
</DetailSection>
) : null}
{e.type === "file" && e.fileKind === "video" ? (
<DetailSection
title="媒体信息"
actions={
<Button
size="xs"
variant="outline"
disabled={probe.isPending}
onClick={() =>
probe.mutate(
{ items: [{ root: root.id, path: e.path }] },
{
onSuccess: (r) => {
const it = r.items[0];
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
},
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
},
)
}
>
{probe.isPending ? <LoaderCircle className="animate-spin" /> : <ScanSearch />}
探测
</Button>
}
>
{media ? (
<KvList
items={[
{ label: "分辨率", value: media.resolution },
{ label: "HDR", value: media.hdr },
{ label: "视频编码", value: media.videoCodec },
{ label: "音轨", value: media.audioTracks },
{
label: "时长",
value: media.durationSec ? `${Math.floor(media.durationSec / 60)} 分钟` : null,
},
...(probe.data?.items[0]
? [
{ label: "容器", value: probe.data.items[0].container },
{ label: "字幕轨", value: probe.data.items[0].subtitleTracks },
]
: []),
]}
/>
) : (
<p className="text-caption text-muted-foreground">
未登记文件不自动探测。点「探测」读取 ffprobe 摘要,结果缓存 24 小时。
</p>
)}
</DetailSection>
) : null}
{previewable ? (
<DetailSection
title="预览"
actions={
filesMockEnabled() ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, e.path)}
download={e.name}
>
<Download />
下载
</a>
)
}
>
{e.fileKind === "image" ? (
filesMockEnabled() ? (
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
) : (
<a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
<img
src={fileDownloadUrl(root.id, e.path, true)}
alt={e.name}
className="max-h-80 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>
)
) : text.isPending ? (
<LoadingState rows={3} />
) : text.error ? (
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
) : (
<>
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
{text.data?.text}
</pre>
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
</>
)}
</DetailSection>
) : null}
</DetailSheet>
);
}