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
@@ -0,0 +1,53 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
|
||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 技术信息折叠区:ID、hash、trace、token 数、耗时、模型 ID、原始字段、内部状态码都放这里,默认收起
|
||||
* (设置 → 界面可改为默认展开)。值为空的项不显示;全部为空时整块不渲染。
|
||||
*/
|
||||
export function TechInfo({
|
||||
items,
|
||||
children,
|
||||
title = "技术信息",
|
||||
className,
|
||||
columns = 2,
|
||||
}: {
|
||||
items?: (KvItem | null | false | undefined)[];
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
columns?: 1 | 2;
|
||||
}) {
|
||||
const prefs = useUiPrefs();
|
||||
const [override, setOverride] = useState<boolean | null>(null);
|
||||
const open = override ?? prefs.techInfoExpanded;
|
||||
const id = useId();
|
||||
const rows = (items ?? []).filter(
|
||||
(it): it is KvItem => !!it && it.value !== null && it.value !== undefined && it.value !== "",
|
||||
);
|
||||
if (!rows.length && !children) return null;
|
||||
return (
|
||||
<div className={cn("min-w-0", className)} data-slot="tech-info">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOverride(!open)}
|
||||
className="inline-flex items-center gap-1 rounded-md py-1 text-meta text-muted-foreground transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{title}
|
||||
<ChevronDown className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className="grid gap-3 pt-2 pb-1">
|
||||
{rows.length ? <KvList items={rows.map((r) => ({ mono: true, ...r }))} columns={columns} /> : null}
|
||||
{children}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Timeline } from "~/components/console/timeline.tsx";
|
||||
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
|
||||
|
||||
export const STAGE_LABEL: Record<TimelineStage, string> = {
|
||||
observation: "观察",
|
||||
observation: "发现",
|
||||
plan: "计划",
|
||||
download: "下载",
|
||||
organization: "整理",
|
||||
@@ -26,12 +26,11 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ORDER.map((s) => {
|
||||
const n = counts.get(s) ?? 0;
|
||||
{ORDER.filter((s) => counts.get(s)).map((s) => {
|
||||
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
|
||||
return (
|
||||
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
|
||||
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
|
||||
<StatusBadge key={s} tone={bad ? "destructive" : "neutral"}>
|
||||
{STAGE_LABEL[s]} · {counts.get(s)}
|
||||
</StatusBadge>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DownloadTask } from "@umt/contracts";
|
||||
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DOWNLOAD_STATUS_LABEL,
|
||||
@@ -17,11 +18,13 @@ import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { planReasonText } from "~/lib/labels.ts";
|
||||
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
|
||||
@@ -52,7 +55,6 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
|
||||
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
|
||||
) : null}
|
||||
{!t.managed ? <StatusBadge>外部任务</StatusBadge> : null}
|
||||
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +97,11 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
const trace = useDownloadTrace(task, plan.data);
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
||||
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明入库失败
|
||||
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
|
||||
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
|
||||
)[0];
|
||||
const importFailed = task.status === "completed" && lastRecord?.status === "failed" ? lastRecord : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -132,7 +139,15 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
{task.pauseReason === "threshold" ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和智能体都不能绕过阈值。
|
||||
下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。
|
||||
</p>
|
||||
) : null}
|
||||
{importFailed ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
入库失败:{reasonLine(importFailed.reason)}。
|
||||
<Link to="/console/inbox" className="ml-1 underline underline-offset-4">
|
||||
去待处理
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{task.noProgressSince ? (
|
||||
@@ -151,17 +166,26 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
|
||||
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
|
||||
{ label: "最近进展", value: relTime(task.lastProgressAt) },
|
||||
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
|
||||
{ label: "标签", value: task.tags.join(", ") },
|
||||
{ label: "分类", value: task.category },
|
||||
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
|
||||
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
|
||||
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
<TechInfo
|
||||
className="mt-3"
|
||||
items={[
|
||||
{ label: "最近同步", value: relTime(task.lastSyncedAt), mono: false },
|
||||
{ label: "标签", value: task.tags.join(", ") },
|
||||
{ label: "分类", value: task.category },
|
||||
{ label: "下载器状态", value: task.rawState },
|
||||
{ label: "内容路径", value: task.contentPath, wide: true },
|
||||
{ label: "torrent hash", value: task.torrentHash, wide: true },
|
||||
{ label: "任务 ID", value: task.id, wide: true },
|
||||
]}
|
||||
>
|
||||
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
|
||||
</TechInfo>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="业务时间线">
|
||||
<DetailSection title="经过">
|
||||
<BusinessTimeline events={trace.events} loading={trace.loading} />
|
||||
</DetailSection>
|
||||
|
||||
@@ -175,7 +199,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
|
||||
]}
|
||||
/>
|
||||
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
|
||||
<TechInfo className="mt-2">
|
||||
<JsonView label="评估依据" value={plan.data.evaluation} />
|
||||
</TechInfo>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
@@ -195,7 +221,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
|
||||
{task.lastError ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
下载器报错:
|
||||
{humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || "原文见技术信息"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{organizing ? (
|
||||
<OrganizeWizard
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 界面:本机显示偏好(存浏览器,不进 settings 表)
|
||||
|
||||
export function InterfaceSection() {
|
||||
const prefs = useUiPrefs();
|
||||
return (
|
||||
<SettingsBlock section="interface" anchor="display" title="显示">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground" id="ui-techinfo-label">
|
||||
默认展开技术信息
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-muted-foreground">
|
||||
ID、hash、token 数、耗时和原始错误码平时收在「技术信息」里。开启后各页默认展开。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby="ui-techinfo-label"
|
||||
checked={prefs.techInfoExpanded}
|
||||
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
|
||||
import { Bot, FolderTree, KeyRound, type LucideIcon, Server, Shield, Tags, Tv, UserCog, Zap } from "lucide-react";
|
||||
import {
|
||||
Bot,
|
||||
FolderTree,
|
||||
KeyRound,
|
||||
type LucideIcon,
|
||||
Monitor,
|
||||
Server,
|
||||
Shield,
|
||||
Tags,
|
||||
Tv,
|
||||
UserCog,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AccountSection } from "./account.tsx";
|
||||
import { AgentSection } from "./agent.tsx";
|
||||
import { ApiKeysSection } from "./api-keys.tsx";
|
||||
import { AutomationSection } from "./automation.tsx";
|
||||
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
|
||||
import { InterfaceSection } from "./interface.tsx";
|
||||
import { LibrarySection } from "./library.tsx";
|
||||
import { MetadataSection } from "./metadata.tsx";
|
||||
import { SitesSection } from "./sites.tsx";
|
||||
@@ -21,6 +34,7 @@ const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode
|
||||
metadata: { icon: Tags, render: () => <MetadataSection /> },
|
||||
agent: { icon: Bot, render: () => <AgentSection /> },
|
||||
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
|
||||
interface: { icon: Monitor, render: () => <InterfaceSection /> },
|
||||
account: { icon: Shield, render: () => <AccountSection /> },
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user