feat(web): 技术信息折叠、原因码人话化、待处理「指定作品」(DEAI-2)
- 新增 TechInfo 折叠组件(默认收起,标题「技术信息」);设置新增「界面」分区, 「默认展开技术信息」开关存本机浏览器 - 原因码表 @umt/contracts reasons.ts(identity_unknown、blocked:target_exists、below_min_size… → 一句人话 + 下一步),服务端整理记录 message 与冲突事项标题/摘要改用它 - 整理记录列表/详情、下载详情与时间线:原码、ID、hash、用时、变更序号进技术信息; 下载完成但入库失败时单独提示;时间线只列有记录的阶段,标题改「经过」 - 待处理:没识别出作品类事项主按钮「指定作品」(搜媒体库 + TMDB,走已有 resolve; 整理类事项打开重新整理),忽略为次要;去掉正常态徽章与「实时」指示;原始证据改「详细信息」进技术信息 - 整理成功后同时关闭该下载的「没识别出作品」事项 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4480fc346d
commit
f0d4935fa7
21 files changed
+688
-78
No files matched your search
@@ -136,7 +136,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
identity_unresolved: "未识别",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
path_conflict: "整理冲突",
|
||||
space_insufficient: "空间不足",
|
||||
approval_required: "需要审批",
|
||||
metadata_conflict: "元数据冲突",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { SearchWork } from "@umt/contracts";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { MEDIA_KIND } from "./labels.ts";
|
||||
|
||||
/** 选中的作品:本地作品给 mediaEntityId,只在 TMDB 上的给 tmdb */
|
||||
export type AssignedWork =
|
||||
| { mediaEntityId: string; title: string }
|
||||
| { tmdb: { type: "movie" | "tv"; id: string }; title: string };
|
||||
|
||||
const workTitle = (w: SearchWork) => `${w.title}${w.year ? ` (${w.year})` : ""}`;
|
||||
|
||||
/**
|
||||
* 「指定作品」:搜本地媒体库与 TMDB(复用全局搜索 /search?scope=media),选一个交给 onAssign。
|
||||
* onAssign 返回 Promise:执行中按钮禁用,resolve 后关闭,reject 时保持打开(错误由调用方提示)。
|
||||
*/
|
||||
export function WorkAssignDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialQuery = "",
|
||||
subject,
|
||||
onAssign,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialQuery?: string;
|
||||
/** 在说明里点名要指定的是什么(文件名 / 资源标题) */
|
||||
subject?: string | null;
|
||||
onAssign: (w: AssignedWork) => Promise<unknown>;
|
||||
}) {
|
||||
const [q, setQ] = useState(initialQuery);
|
||||
const [debounced, setDebounced] = useState(initialQuery);
|
||||
const [picked, setPicked] = useState<SearchWork | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(q), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQ(initialQuery);
|
||||
setDebounced(initialQuery);
|
||||
setPicked(null);
|
||||
}
|
||||
}, [open, initialQuery]);
|
||||
const search = useSearch(debounced, "media", 12, open);
|
||||
const works = search.data?.media?.works ?? [];
|
||||
const tmdbOff = search.data?.media && !search.data.media.tmdb.configured;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!picked) return;
|
||||
const target: AssignedWork | null = picked.localEntityId
|
||||
? { mediaEntityId: picked.localEntityId, title: workTitle(picked) }
|
||||
: picked.tmdb
|
||||
? { tmdb: { type: picked.tmdb.entityType, id: picked.tmdb.externalId }, title: workTitle(picked) }
|
||||
: null;
|
||||
if (!target) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await onAssign(target);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// 调用方已提示,保持打开
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (pending ? undefined : onOpenChange(o))}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>指定作品</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{subject ? `「${subject}」属于哪部作品?` : "选这个资源属于哪部作品。"}
|
||||
选中后加人工锁,自动识别不再改它。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setPicked(null);
|
||||
}}
|
||||
placeholder="作品名,中英文都行"
|
||||
className="pl-9"
|
||||
aria-label="搜索作品"
|
||||
/>
|
||||
</div>
|
||||
{debounced.trim() ? (
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
|
||||
{search.isPending ? (
|
||||
<LoadingState rows={3} className="p-2" />
|
||||
) : search.isError ? (
|
||||
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
|
||||
) : works.length === 0 ? (
|
||||
<p className="p-3 text-meta text-muted-foreground">
|
||||
没有找到{tmdbOff ? "本地作品;TMDB 未配置,只搜了媒体库" : "匹配的作品"}
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{works.map((w) => {
|
||||
const active = picked?.key === w.key;
|
||||
return (
|
||||
<li key={w.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(w)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
|
||||
active && "bg-foreground/[0.07]",
|
||||
)}
|
||||
>
|
||||
{active ? <Check className="h-4 w-4 shrink-0 text-success" /> : null}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{workTitle(w)}</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">
|
||||
{MEDIA_KIND[w.kind]} · {w.localEntityId ? "在媒体库" : "TMDB"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()} disabled={!picked || pending}>
|
||||
{pending ? "处理中…" : "指定"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user