Files
UHD_Media_Tool/apps/web/app/components/files/file-detail.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

292 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 { RecordLink } from "~/components/console/record-sheet.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: (
<RecordLink className="hover:underline" page="organize" id={r.id}>
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
</RecordLink>
),
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: (
<RecordLink className="underline underline-offset-2" page="downloads" id={e.download.taskId}>
{downloadLabel(e.download.status).label}
</RecordLink>
),
},
{
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>
);
}