feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/ download links, breadcrumb, sort, glob search, hidden toggle, filter chips, paging and truncation notice; multi-select (Shift/Ctrl/all), action bar, context menu, drag-to-move with dryRun preview, keyboard navigation, offline (503) and empty/error states, mobile layout with tree drawer. Rename/move/copy/delete dialogs with dryRun previews and reference checks, 4-step organize wizard, trash view (restore/restore-to/purge, expiry). DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -0,0 +1,278 @@
|
||||
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 { 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 { 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 { baseName } from "./model.ts";
|
||||
|
||||
const PREVIEW_MAX = 20 * 1024 * 1024;
|
||||
|
||||
/** 选中单项的详情抽屉:文件信息、作品/Slot/下载链接、sidecars、探测摘要、路径绑定与小文件预览 */
|
||||
export function FileDetail({
|
||||
root,
|
||||
entry,
|
||||
onClose,
|
||||
actions,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
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: "Slot",
|
||||
value: e.asset.slotId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
|
||||
{e.asset.slotStatus ?? "查看"}
|
||||
</Link>
|
||||
) : (
|
||||
"未满足任何 Slot"
|
||||
),
|
||||
},
|
||||
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
className="mt-2 inline-block text-meta text-muted-foreground underline"
|
||||
to={`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`}
|
||||
>
|
||||
在整理记录中查看
|
||||
</Link>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{e.download ? (
|
||||
<DetailSection title="下载任务">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "任务",
|
||||
value: (
|
||||
<Link className="underline underline-offset-2" to={`/console/tasks/${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)} target="_blank" rel="noreferrer" className="block">
|
||||
<img
|
||||
src={fileDownloadUrl(root.id, e.path)}
|
||||
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}
|
||||
</pre>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user