控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|