Files
UHD_Media_Tool/apps/web/app/components/files/file-detail.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

291 lines
11 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 { LoaderCircle, ScanSearch } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { type FileEntry, type FileRoot, filesErrorText, useFileStat, useProbe } 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 } 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 { FilePreviewSection } from "./file-preview.tsx";
import { IdentifyPanel } from "./identify-panel.tsx";
import { baseName, displayPath, previewKindOf } from "./model.ts";
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 pk = previewKindOf(e);
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}
width={pk && pk !== "image" ? "lg" : "md"}
>
<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 ? "(硬链接,不能原地改写)" : ""}`
: null,
},
{
label: root.storage ? "Alist 路径" : "本机路径",
value: displayPath(root, e.path),
mono: true,
wide: true,
},
...(stat.data && !root.storage && 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 ? `:${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}
<FilePreviewSection root={root} entry={e} />
</DetailSheet>
);
}