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

165 lines
7.3 KiB
TypeScript

import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
import { useEffect } from "react";
import { type FileEntry, type FileRoot, filesErrorText } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { RecordLink } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizard/entity-picker.tsx";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { contentUnitLabel } from "./model.ts";
import { useIdentifyLive } from "./use-identify-live.ts";
const KIND: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺" };
/**
* 详情抽屉"识别"节(§7.1):解析字段 + Top5 候选(等级、理由),每个候选 `按此整理` 打开向导并预填作品。
* 识别只读,不写库。autoRun:从菜单"识别"进入时自动跑一次。
*/
export function IdentifyPanel({
root,
entry,
autoRun,
onOrganize,
}: {
root: FileRoot;
entry: FileEntry;
autoRun?: boolean;
onOrganize?: (preset: WizardEntity | null) => void;
}) {
const identify = useIdentifyLive();
const mutate = identify.mutate;
// biome-ignore lint/correctness/useExhaustiveDependencies: 换文件或 autoRun 时重跑一次
useEffect(() => {
identify.reset();
if (autoRun) mutate({ items: [{ root: root.id, path: entry.path }], minSizeBytes: 0 });
}, [autoRun, root.id, entry.path]);
const run = () => mutate({ items: [{ root: root.id, path: entry.path }], minSizeBytes: 0 });
const it = identify.data?.items[0];
const isDir = entry.type === "dir";
return (
<DetailSection
title="识别"
actions={
<Button size="xs" variant="outline" onClick={run} disabled={identify.isPending}>
{identify.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
{identify.data ? "重新识别" : "识别"}
</Button>
}
>
{identify.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品和 TMDB…
</p>
) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : !identify.data ? (
<p className="text-caption text-muted-foreground">按文件名匹配本地作品和 TMDB,不改文件。</p>
) : !it ? (
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
) : (
<div className="space-y-3">
{isDir && (identify.data.items.length > 1 || identify.data.truncated) ? (
<p className="text-meta text-muted-foreground">
目录里有 {identify.data.items.length}
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
</p>
) : null}
<KvList
items={[
{ label: "标题", value: it.parsed.title },
{ label: "年份", value: it.parsed.year },
{
label: "季集",
value: it.parsed.season != null ? contentUnitLabel(it.parsed.season, it.parsed.episodes) : null,
},
{ label: "类型", value: it.parsed.mediaKind ? (KIND[it.parsed.mediaKind] ?? it.parsed.mediaKind) : null },
{ label: "分辨率", value: it.parsed.resolution },
{ label: "来源", value: it.parsed.source },
{ label: "发布组", value: it.parsed.releaseGroup },
]}
/>
{it.records?.count ? (
<p className="flex flex-wrap items-center gap-1.5 text-caption">
这个源文件有 {it.records.count} 条整理记录
{it.records.lastStatus ? (
<StatusBadge tone={RECORD_STATUS_TONE[it.records.lastStatus]}>
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
</StatusBadge>
) : null}
{it.records.lastRecordId ? (
<RecordLink className="underline underline-offset-2" page="organize" id={it.records.lastRecordId}>
查看
</RecordLink>
) : null}
</p>
) : null}
{it.skipped === "below_min_size" ? (
<p className="text-caption text-muted-foreground">文件小于最小体积,没有查找候选。</p>
) : null}
{it.warnings.map((w) => (
<p key={w} className="text-caption text-warning">
{w}
</p>
))}
{it.candidates.length ? (
<ul className="divide-y divide-border/60 rounded-xl border border-border">
{it.candidates.map((c) => {
const e = candidateEntity(c);
return (
<li
key={`${c.provider}-${c.mediaEntityId ?? c.externalId}`}
className="flex items-start gap-2 px-3 py-2"
>
{c.posterUrl ? (
<img
src={c.posterUrl}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-12 w-8 shrink-0 rounded-sm bg-muted object-cover"
/>
) : (
<span aria-hidden className="h-12 w-8 shrink-0 rounded-sm bg-muted" />
)}
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-caption text-foreground">
<span className="truncate">
{c.title}
{c.year ? ` (${c.year})` : ""}
</span>
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
{c.mediaEntityId ? null : <StatusBadge tone="warning">不在媒体库</StatusBadge>}
</p>
<p className="text-meta text-muted-foreground">
{[
KIND[c.kind] ?? c.kind,
c.externalId ? `${c.provider.toUpperCase()} ${c.externalId}` : null,
reasonsText(c.reasons),
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{onOrganize && e ? (
<Button size="xs" variant="outline" onClick={() => onOrganize(e)}>
<Wand2 />
按此整理
</Button>
) : null}
</li>
);
})}
</ul>
) : it.skipped ? null : (
<p className="text-caption text-muted-foreground">没有可信的候选作品。整理时可以手动搜索作品。</p>
)}
</div>
)}
</DetailSection>
);
}