Files
UHD_Media_Tool/apps/web/app/components/files/identify-panel.tsx
T
rootandClaude Opus 5.5 c31df8b69a feat(web): file manager v2 and shared organize wizard (FE-4)
- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px
- 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多"
- 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB
- 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行),
  TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、
  plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录
- 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…"
- 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:26:41 +08:00

153 lines
6.8 KiB
TypeScript

import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
import { useEffect } from "react";
import { Link } from "react-router";
import { type FileEntry, type FileRoot, filesErrorText, useIdentify } 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 { 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";
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 = useIdentify();
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 ? (
<Link className="underline underline-offset-2" to={`/console/organize/${it.records.lastRecordId}`}>
查看
</Link>
) : 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"
>
<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">仅 TMDB</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>
);
}