From f0d4935fa7cecefaa6a86137a7c7ff949777870f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 20:42:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=8A=80=E6=9C=AF=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=8A=98=E5=8F=A0=E3=80=81=E5=8E=9F=E5=9B=A0=E7=A0=81?= =?UTF-8?q?=E4=BA=BA=E8=AF=9D=E5=8C=96=E3=80=81=E5=BE=85=E5=A4=84=E7=90=86?= =?UTF-8?q?=E3=80=8C=E6=8C=87=E5=AE=9A=E4=BD=9C=E5=93=81=E3=80=8D(DEAI-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../acquisition-organize/services/organize.ts | 13 +- apps/web/app/api/tasks.ts | 23 ++- apps/web/app/components/console/tech-info.tsx | 53 ++++++ .../downloads/business-timeline.tsx | 9 +- .../components/downloads/download-detail.tsx | 51 ++++-- apps/web/app/components/library/labels.ts | 2 +- .../components/library/work-assign-dialog.tsx | 157 ++++++++++++++++++ .../web/app/components/settings/interface.tsx | 28 ++++ apps/web/app/components/settings/sections.tsx | 16 +- apps/web/app/lib/labels.ts | 2 +- apps/web/app/lib/reasons.ts | 35 ++++ apps/web/app/lib/ui-prefs.ts | 72 ++++++++ apps/web/app/routes/console/inbox.tsx | 118 +++++++++++-- apps/web/app/routes/console/organize.$id.tsx | 69 ++++---- apps/web/app/routes/console/organize.tsx | 4 +- apps/web/app/routes/console/settings.tsx | 2 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/reasons.ts | 73 ++++++++ packages/contracts/src/settings.ts | 10 ++ packages/contracts/test/reasons.test.ts | 26 +++ .../contracts/test/settings-sections.test.ts | 2 +- 21 files changed, 688 insertions(+), 78 deletions(-) create mode 100644 apps/web/app/components/console/tech-info.tsx create mode 100644 apps/web/app/components/library/work-assign-dialog.tsx create mode 100644 apps/web/app/components/settings/interface.tsx create mode 100644 apps/web/app/lib/reasons.ts create mode 100644 apps/web/app/lib/ui-prefs.ts create mode 100644 packages/contracts/src/reasons.ts create mode 100644 packages/contracts/test/reasons.test.ts diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index c8a9f1c4..903395e3 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -15,6 +15,7 @@ import { statSafe, } from "@umt/adapters"; import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts"; +import { reasonSentence, reasonText } from "@umt/contracts"; import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db"; import { sql } from "@umt/db"; import { @@ -710,9 +711,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown"); await upsertDecision(d, trx, { kind: hnrConflict ? "approval_required" : "path_conflict", - title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`, + title: hnrConflict + ? `做种义务未满足,已阻止移动:${entity.title}` + : `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`, summary: blocking - .map((c) => c.detail ?? c.kind) + .map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind) .join(";") .slice(0, 500), subjectType: "organization_record", @@ -1654,7 +1657,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise code - ? { code: code.split(":")[0]!, message: conflictText(err) || code } + ? { code: code.split(":")[0]!, message: conflictText(err) || reasonSentence(code, code) } : err ? { code: (err as { code?: string }).code ?? "error", diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index c9fe7970..040a1661 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -11,7 +11,8 @@ import { type TaskStatus, } from "@umt/contracts"; import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts"; -import { PLAN_STATUS_LABEL } from "./downloads.ts"; +import { humanizeReason, reasonLine } from "~/lib/reasons.ts"; +import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts"; import { apiParsed } from "./parse.ts"; // 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity) @@ -173,7 +174,7 @@ export function buildTimeline(input: { stage: "plan", at: plan.createdAt, title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`, - detail: plan.statusReason, + detail: humanizeReason(plan.statusReason), tone: "neutral", }); if (plan.updatedAt !== plan.createdAt) @@ -182,7 +183,7 @@ export function buildTimeline(input: { stage: "plan", at: plan.updatedAt, title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`, - detail: plan.statusReason, + detail: humanizeReason(plan.statusReason), tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral", }); } @@ -199,7 +200,7 @@ export function buildTimeline(input: { key: "dl-pause", stage: "download", at: task.updatedAt, - title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`, + title: task.pauseReason === "threshold" ? "下载盘超过暂停线,已暂停" : PAUSE_REASON_LABEL[task.pauseReason], tone: "warning", ongoing: task.status === "paused", }); @@ -239,7 +240,15 @@ export function buildTimeline(input: { stage: "organization", at: r.startedAt ?? r.createdAt, title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`, - detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`, + detail: + [ + c.total ? `${c.succeeded}/${c.total} 个文件成功` : null, + c.failed ? `${c.failed} 个失败` : null, + c.skipped ? `${c.skipped} 个跳过` : null, + r.reason ? reasonLine(r.reason) : null, + ] + .filter(Boolean) + .join(" · ") || null, tone: RECORD_TONE[r.status], href: `/console/organize/${r.id}`, ongoing: r.status === "running" || r.status === "queued", @@ -275,7 +284,9 @@ export function buildTimeline(input: { at: t.startedAt ?? t.createdAt, title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`, detail: - [t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null, + [t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, humanizeReason(t.waitReason), humanizeReason(err)] + .filter(Boolean) + .join(" · ") || null, tone: TASK_STATUS_TONE[t.status], href: `/console/tasks/${encodeURIComponent(t.id)}`, ongoing: ACTIVE_TASK.includes(t.status), diff --git a/apps/web/app/components/console/tech-info.tsx b/apps/web/app/components/console/tech-info.tsx new file mode 100644 index 00000000..9e380c6d --- /dev/null +++ b/apps/web/app/components/console/tech-info.tsx @@ -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(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 ( +
+ + +
+ {rows.length ? ({ mono: true, ...r }))} columns={columns} /> : null} + {children} +
+
+
+ ); +} diff --git a/apps/web/app/components/downloads/business-timeline.tsx b/apps/web/app/components/downloads/business-timeline.tsx index d132455a..eacca1ca 100644 --- a/apps/web/app/components/downloads/business-timeline.tsx +++ b/apps/web/app/components/downloads/business-timeline.tsx @@ -9,7 +9,7 @@ import { Timeline } from "~/components/console/timeline.tsx"; // 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。 export const STAGE_LABEL: Record = { - observation: "观察", + observation: "发现", plan: "计划", download: "下载", organization: "整理", @@ -26,12 +26,11 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[]; return (
- {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 ( - - {STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"} + + {STAGE_LABEL[s]} · {counts.get(s)} ); })} diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index d7c4066c..035ce17c 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -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 }) { {PAUSE_REASON_LABEL[t.pauseReason]} ) : null} {!t.managed ? 外部任务 : null} - {t.origin === "adopted" ? 接管 : null} ); } @@ -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 (
@@ -132,7 +139,15 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { {task.pauseReason === "threshold" ? (

- 缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和智能体都不能绕过阈值。 + 下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。 +

+ ) : null} + {importFailed ? ( +

+ 入库失败:{reasonLine(importFailed.reason)}。 + + 去待处理 +

) : 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 }, ]} /> + + {task.lastError ? : null} + - + @@ -175,7 +199,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { { label: "说明", value: planReasonText(plan.data.statusReason), wide: true }, ]} /> - + + + ) : null} @@ -195,7 +221,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { ) : null} - {task.lastError ? : null} + {task.lastError ? ( +

+ 下载器报错: + {humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || "原文见技术信息"} +

+ ) : null} {organizing ? ( = { identity_unresolved: "未识别", download_stalled: "下载无进展", download_failed: "下载失败", - path_conflict: "路径冲突", + path_conflict: "整理冲突", space_insufficient: "空间不足", approval_required: "需要审批", metadata_conflict: "元数据冲突", diff --git a/apps/web/app/components/library/work-assign-dialog.tsx b/apps/web/app/components/library/work-assign-dialog.tsx new file mode 100644 index 00000000..9a408229 --- /dev/null +++ b/apps/web/app/components/library/work-assign-dialog.tsx @@ -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; +}) { + const [q, setQ] = useState(initialQuery); + const [debounced, setDebounced] = useState(initialQuery); + const [picked, setPicked] = useState(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 ( + (pending ? undefined : onOpenChange(o))}> + + + 指定作品 + + {subject ? `「${subject}」属于哪部作品?` : "选这个资源属于哪部作品。"} + 选中后加人工锁,自动识别不再改它。 + + +
+
+ + { + setQ(e.target.value); + setPicked(null); + }} + placeholder="作品名,中英文都行" + className="pl-9" + aria-label="搜索作品" + /> +
+ {debounced.trim() ? ( +
+ {search.isPending ? ( + + ) : search.isError ? ( +

{(search.error as Error).message}

+ ) : works.length === 0 ? ( +

+ 没有找到{tmdbOff ? "本地作品;TMDB 未配置,只搜了媒体库" : "匹配的作品"} +

+ ) : ( +
    + {works.map((w) => { + const active = picked?.key === w.key; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/components/settings/interface.tsx b/apps/web/app/components/settings/interface.tsx new file mode 100644 index 00000000..d78cdb61 --- /dev/null +++ b/apps/web/app/components/settings/interface.tsx @@ -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 ( + +
+
+

+ 默认展开技术信息 +

+

+ ID、hash、token 数、耗时和原始错误码平时收在「技术信息」里。开启后各页默认展开。 +

+
+ setUiPref("techInfoExpanded", v)} + /> +
+
+ ); +} diff --git a/apps/web/app/components/settings/sections.tsx b/apps/web/app/components/settings/sections.tsx index bf67821a..6cb5d110 100644 --- a/apps/web/app/components/settings/sections.tsx +++ b/apps/web/app/components/settings/sections.tsx @@ -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 ReactNode metadata: { icon: Tags, render: () => }, agent: { icon: Bot, render: () => }, "api-keys": { icon: KeyRound, render: () => }, + interface: { icon: Monitor, render: () => }, account: { icon: Shield, render: () => }, }; diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts index c308c9c1..b9631c32 100644 --- a/apps/web/app/lib/labels.ts +++ b/apps/web/app/lib/labels.ts @@ -41,7 +41,7 @@ const DECISION_KIND: Record = { identity_unresolved: "未识别", download_stalled: "下载无进展", download_failed: "下载失败", - path_conflict: "路径冲突", + path_conflict: "整理冲突", space_insufficient: "空间不足", approval_required: "待审批", metadata_conflict: "元数据冲突", diff --git a/apps/web/app/lib/reasons.ts b/apps/web/app/lib/reasons.ts new file mode 100644 index 00000000..1d95ed71 --- /dev/null +++ b/apps/web/app/lib/reasons.ts @@ -0,0 +1,35 @@ +import { looksLikeCode, reasonText } from "@umt/contracts"; + +// 原因串 → 一句人话(DEAI-2)。映射表在 @umt/contracts 的 reasons.ts,服务端与前端共用; +// 原码交给 显示,不直接给人看。 + +/** `{ code, message }`(整理记录 / 文件结果)或裸原因串 → 一句话;不认识的码返回 fallback */ +export function reasonLine( + r: { code?: string | null; message?: string | null } | string | null | undefined, + fallback = "处理失败", +): string | null { + if (!r) return null; + const code = typeof r === "string" ? r : (r.code ?? null); + const message = typeof r === "string" ? null : (r.message ?? null); + // 服务端给的中文说明(冲突明细等)优先 + if (message && !looksLikeCode(message) && message !== code) return message; + const t = reasonText(code) ?? reasonText(message); + if (t) return t.action ? `${t.text}。${t.action}` : t.text; + if (code && !looksLikeCode(code)) return code; + return fallback; +} + +/** 原码(给技术信息用):只有是内部码时才返回 */ +export function reasonCode(r: { code?: string | null; message?: string | null } | string | null | undefined) { + if (!r) return null; + if (typeof r === "string") return looksLikeCode(r) ? r : null; + const c = r.code ?? null; + const m = r.message ?? null; + return [c, m && looksLikeCode(m) && m !== c ? m : null].filter(Boolean).join(" · ") || null; +} + +/** 原因串里的内部码替换成人话(如下载计划 statusReason、任务 waitReason) */ +export function humanizeReason(v: string | null | undefined): string | null { + if (!v) return null; + return looksLikeCode(v) ? reasonLine(v, v) : v; +} diff --git a/apps/web/app/lib/ui-prefs.ts b/apps/web/app/lib/ui-prefs.ts new file mode 100644 index 00000000..8d34f972 --- /dev/null +++ b/apps/web/app/lib/ui-prefs.ts @@ -0,0 +1,72 @@ +import { useSyncExternalStore } from "react"; + +// 本机界面偏好(设置 → 界面):只影响当前浏览器,存 localStorage;读写失败(隐私模式、禁用存储)时用默认值。 + +export type UiPrefs = { + /** 技术信息(ID、hash、token、耗时、原始字段、内部状态码)默认展开 */ + techInfoExpanded: boolean; +}; + +const KEY = "umt.ui-prefs"; +const DEFAULTS: UiPrefs = { techInfoExpanded: false }; +const listeners = new Set<() => void>(); +let cache: UiPrefs | null = null; + +function read(): UiPrefs { + if (cache) return cache; + let v: UiPrefs = DEFAULTS; + try { + const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(KEY); + if (raw) v = { ...DEFAULTS, ...(JSON.parse(raw) as Partial) }; + } catch { + v = DEFAULTS; + } + cache = v; + return v; +} + +export function setUiPref(key: K, value: UiPrefs[K]) { + cache = { ...read(), [key]: value }; + try { + localStorage.setItem(KEY, JSON.stringify(cache)); + } catch { + // 存不下就只在本次会话生效 + } + for (const l of listeners) l(); +} + +function subscribe(l: () => void) { + listeners.add(l); + const onStorage = (e: StorageEvent) => { + if (e.key !== KEY) return; + cache = null; + l(); + }; + if (typeof window !== "undefined") window.addEventListener("storage", onStorage); + return () => { + listeners.delete(l); + if (typeof window !== "undefined") window.removeEventListener("storage", onStorage); + }; +} + +export function useUiPrefs(): UiPrefs { + return useSyncExternalStore(subscribe, read, () => DEFAULTS); +} + +/** 本地记住的折叠状态(计划页分组等):key → 是否展开;读写失败时返回 null */ +export function readCollapseState(key: string): Record | null { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as Record) : null; + } catch { + return null; + } +} + +export function writeCollapseState(key: string, v: Record) { + try { + localStorage.setItem(key, JSON.stringify(v)); + } catch { + // 忽略 + } +} diff --git a/apps/web/app/routes/console/inbox.tsx b/apps/web/app/routes/console/inbox.tsx index b155fbe6..299d478e 100644 --- a/apps/web/app/routes/console/inbox.tsx +++ b/apps/web/app/routes/console/inbox.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts"; -import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react"; +import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react"; import { type ReactNode, useId, useMemo, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; @@ -14,9 +14,12 @@ import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatTile } from "~/components/console/stat-tile.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { TechInfo } from "~/components/console/tech-info.tsx"; import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx"; import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts"; import { Chip, relTime, Tone } from "~/components/library/parts.tsx"; +import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx"; +import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; @@ -78,7 +81,7 @@ function GroupReleases({ d }: { d: Decision }) { ))} {count > list.length ? (
  • - 另有 {count - list.length} 条,可在媒体概览 → 未识别按标题检索 + 另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
  • ) : null} @@ -147,7 +150,14 @@ function Evidence({ d }: { d: Decision }) { ))}
    ) : null} - + + +
    ); } @@ -246,6 +256,82 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) { ); } +/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */ +function subjectName(d: Decision): string | null { + const e = d.evidence; + const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined; + return ( + str(first?.title) ?? + str(e.title) ?? + str(e.name) ?? + (d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null) + ); +} + +/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */ +function queryOf(name: string | null): string { + if (!name) return ""; + const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " "); + const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i); + return (m?.[1]?.trim() || base) + .replace(/[[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 60); +} + +const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"]; + +/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */ +function AssignWorkButton({ d }: { d: Decision }) { + const resolve = useResolveDecision(); + const [open, setOpen] = useState(false); + const [wizard, setWizard] = useState(false); + const local = d.options.find((o) => o.input?.requires === "mediaEntityId"); + const tmdb = d.options.find((o) => o.input?.requires === "tmdb"); + const recordId = d.subjectType === "organization_record" ? d.subjectId : null; + if (!local && !tmdb && !recordId) return null; + const name = subjectName(d); + const assign = (w: AssignedWork) => { + const option = "mediaEntityId" in w ? local : tmdb; + if (!option) { + toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品"); + return Promise.reject(new Error("unsupported")); + } + return resolve + .mutateAsync({ + id: d.id, + optionKey: option.key, + identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb }, + }) + .then( + () => toast.success(`已指定为「${w.title}」`), + (err) => { + toast.error(errorMessage(err, "指定失败")); + throw err; + }, + ); + }; + return ( + <> + + + {wizard && recordId ? ( + setWizard(false)} /> + ) : null} + + ); +} + function DismissButton({ d }: { d: Decision }) { const resolve = useResolveDecision(); return ( @@ -274,13 +360,18 @@ function DismissButton({ d }: { d: Decision }) { function DecisionCard({ d }: { d: Decision }) { const open = OPEN.includes(d.status); - const options = d.options.filter((o) => o.key !== "dismiss"); + const identity = IDENTITY_KINDS.includes(d.kind); + // 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」 + const options = d.options.filter( + (o) => + o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")), + ); const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null; return (
    - - + {d.severity === "critical" ? : null} + {d.status === "investigating" ? : null} {relTime(d.createdAt)} {d.attempts ? ` · 调查 ${d.attempts} 次` : ""} @@ -294,6 +385,7 @@ function DecisionCard({ d }: { d: Decision }) {
    {open ? (
    + {identity ? : null} {options.map((o) => ( ))} @@ -404,13 +496,15 @@ export default function InboxPage() { <> - - - {live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"} - + {live === "reconnecting" ? ( + + + 重连中 + + ) : null}
    {reason ? (

    - {f.failure ? "失败" : "跳过"}原因:{reasonText(reason)} + {f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}

    ) : null}

    {ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)} - {duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}

    +
    ) : null} @@ -153,7 +152,7 @@ export default function OrganizeRecordPage() { size="sm" onClick={() => openAgentDrawer({ - label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`, + label: `整理记录:${r.media?.title ?? "未关联作品"}`, recordId: r.id, entityId: r.media?.entityId, goal: "排查这次整理为什么没有全部成功,给出恢复办法", @@ -176,15 +175,17 @@ export default function OrganizeRecordPage() { ) : (
    -
    +
    {RECORD_STATUS_LABEL[r.status]} - {ACTION_LABEL[r.action]} - - {TRIGGER_LABEL[r.trigger]} - {r.trigger === "agent" ? "(计为自动)" : ""} - - {r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped} + {[ + `${TRIGGER_LABEL[r.trigger]}${ACTION_LABEL[r.action]}`, + r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null, + r.counts.failed ? `${r.counts.failed} 个失败` : null, + r.counts.skipped ? `${r.counts.skipped} 个跳过` : null, + ] + .filter(Boolean) + .join(" · ")}
    {r.reason ? ( @@ -196,7 +197,7 @@ export default function OrganizeRecordPage() { : "border-warning/40 bg-warning/10 text-warning", )} > - {reasonText(r.reason)} + {reasonLine(r.reason)}

    ) : null}
    @@ -208,19 +209,21 @@ export default function OrganizeRecordPage() { items={[ { label: "开始", value: formatDateTime(r.startedAt) }, { label: "完成", value: formatDateTime(r.finishedAt) }, - { label: "用时", value: duration(r.startedAt, r.finishedAt) }, + { label: "操作者", value: actorTypeLabel(r.actor.type) }, + ]} + /> + `${e.provider}:${e.entityType}:${e.externalId}`).join(", "), - mono: true, }, - { label: "变更游标", value: r.changeSeq, mono: true }, - { label: "记录 ID", value: r.id, mono: true, wide: true }, - { label: "任务 ID", value: r.taskId, mono: true, wide: true }, + { label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null }, + { label: "变更序号", value: r.changeSeq }, + { label: "记录 ID", value: r.id, wide: true }, + { label: "任务 ID", value: r.taskId, wide: true }, ]} /> {r.media ? ( diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index e37620c7..5417395b 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -12,6 +12,7 @@ import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; +import { reasonLine } from "~/lib/reasons.ts"; import { useSse } from "~/lib/sse.ts"; const ACTION_LABEL = { @@ -63,7 +64,7 @@ const COLUMNS: DataColumn[] = [
    {RECORD_STATUS_LABEL[r.status]} {r.reason ? ( - {r.reason.message} + {reasonLine(r.reason)} ) : null}
    ), @@ -120,7 +121,6 @@ export default function OrganizePage() { <> navigate("/console/files")}> diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx index 6cd2fb05..508dee79 100644 --- a/apps/web/app/routes/console/settings.tsx +++ b/apps/web/app/routes/console/settings.tsx @@ -59,7 +59,7 @@ export default function SettingsPage() { return ( <> - +
    {/* 窄屏:顶部下拉 */}
    diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0fccabdb..8f6a39d9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,7 @@ export * from "./ops.ts"; export * from "./organization.ts"; export * from "./pi.ts"; export * from "./policy.ts"; +export * from "./reasons.ts"; export * from "./redact.ts"; export * from "./settings.ts"; export * from "./sources.ts"; diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts new file mode 100644 index 00000000..79fe46c7 --- /dev/null +++ b/packages/contracts/src/reasons.ts @@ -0,0 +1,73 @@ +// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、待处理、下载时间线、任务都用这张表; +// 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。 + +export type ReasonText = { + /** 发生了什么(一句,不带句号) */ + text: string; + /** 下一步能做什么(一句,不带句号);没有可做的就不给 */ + action?: string; +}; + +const REASONS: Record = { + identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" }, + identity_ambiguous: { text: "没识别出作品", action: "指定作品" }, + identity_unresolved: { text: "没识别出作品", action: "指定作品" }, + target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" }, + duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" }, + replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" }, + hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" }, + hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" }, + orphan_sidecar: { text: "字幕等附属文件找不到对应视频" }, + no_main_video: { text: "没有可入库的主视频" }, + below_min_size: { text: "文件小于最小体积,没有识别", action: "需要时在设置里调低最小体积" }, + too_small: { text: "文件小于最小体积" }, + source_path_unknown: { text: "下载器没有报告保存路径", action: "在下载器里确认任务还在" }, + path_mapping_missing: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" }, + file_list_unavailable: { text: "读不到下载器里的文件列表", action: "稍后重试" }, + source_missing: { text: "找不到源文件", action: "检查下载目录是否挂载" }, + source_size_mismatch: { text: "源文件大小与下载器记录不一致", action: "等下载完成后重试" }, + too_many_files: { text: "文件太多,超出单次整理上限", action: "分批整理" }, + no_source: { text: "没有要整理的文件" }, + entity_not_found: { text: "作品已不存在" }, + no_library_root: { text: "没有可用的媒体库目录", action: "在设置 → 媒体库与目录里添加" }, + library_root_offline: { text: "媒体库目录不可访问或未挂载", action: "检查挂载后重试" }, + target_dir_outside_root: { text: "目标目录不在媒体库目录里" }, + slot_entity_mismatch: { text: "收录版本和作品对不上" }, + binding_on_other_root: { text: "作品已放在另一个媒体库目录", action: "在作品详情里调整目录" }, + move_downgraded_to_copy: { text: "做种未完成,已改为复制" }, + already_in_library: { text: "已在媒体库里" }, + main_not_transferred: { text: "主文件没有整理,附属文件一起跳过" }, + timeout: { text: "操作超时", action: "稍后重试" }, +}; + +/** + * 解析原因串:`identity_unknown`、`blocked:target_exists,hnr_unknown`、`move_downgraded_to_copy:hnr_unknown`。 + * 多个码时合并成一句(用";"分隔)。 + */ +export function reasonText(raw: string | null | undefined): ReasonText | null { + if (!raw) return null; + const s = raw.trim(); + const [head, rest] = s.includes(":") ? [s.slice(0, s.indexOf(":")), s.slice(s.indexOf(":") + 1)] : [s, ""]; + if (head === "blocked" && rest) { + const parts = rest + .split(",") + .map((c) => REASONS[c.trim()]) + .filter((x): x is ReasonText => !!x); + if (!parts.length) return null; + return { + text: [...new Set(parts.map((p) => p.text))].join(";"), + ...(parts[0]?.action ? { action: parts[0].action } : {}), + }; + } + return REASONS[s] ?? REASONS[head] ?? null; +} + +/** 只要一句话:"没识别出作品。指定作品后重新整理";不认识的码返回 fallback */ +export function reasonSentence(raw: string | null | undefined, fallback = "处理失败"): string { + const r = reasonText(raw); + if (!r) return raw && !/^[a-z_:.,]+$/.test(raw) ? raw : fallback; + return r.action ? `${r.text}。${r.action}` : r.text; +} + +/** 看起来像内部码(小写下划线 / 冒号)而不是给人看的句子 */ +export const looksLikeCode = (v: string | null | undefined) => !!v && /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(v); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2cea6f33..9afeda4d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -780,6 +780,7 @@ export const SettingsSectionId = z.enum([ "metadata", "agent", "api-keys", + "interface", "account", ]); export type SettingsSectionId = z.infer; @@ -968,6 +969,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ fields: [], resources: ["api-keys"], }, + { + id: "interface", + label: "界面", + description: "只影响当前浏览器", + legacyTabs: [], + anchors: [{ id: "display", label: "显示" }], + fields: [], + resources: ["ui-preferences"], + }, { id: "account", label: "账户与安全", diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts new file mode 100644 index 00000000..aaedff5c --- /dev/null +++ b/packages/contracts/test/reasons.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeCode, reasonSentence, reasonText } from "../src/reasons.ts"; + +describe("reasonText(DEAI-2 原因码人话化)", () => { + it("裸码、blocked:多码、带后缀的码都能翻译", () => { + expect(reasonText("identity_unknown")).toEqual({ text: "没识别出作品", action: "指定作品后重新整理" }); + expect(reasonText("blocked:target_exists")?.text).toBe("媒体库里已有同名文件"); + expect(reasonText("blocked:target_exists,hnr_unknown")?.text).toBe("媒体库里已有同名文件;不确定做种要求是否满足"); + expect(reasonText("move_downgraded_to_copy:hnr_unknown")?.text).toBe("做种未完成,已改为复制"); + expect(reasonText("below_min_size")?.text).toContain("最小体积"); + }); + + it("不认识的码返回 null;句子兜底不把码原样给人看", () => { + expect(reasonText("some_new_code")).toBeNull(); + expect(reasonSentence("some_new_code")).toBe("处理失败"); + expect(reasonSentence("磁盘已满")).toBe("磁盘已满"); + expect(reasonSentence("identity_unknown")).toBe("没识别出作品。指定作品后重新整理"); + }); + + it("looksLikeCode 只认内部码", () => { + expect(looksLikeCode("blocked:target_exists")).toBe(true); + expect(looksLikeCode("identity_unknown")).toBe(true); + expect(looksLikeCode("目标已存在")).toBe(false); + expect(looksLikeCode("Connection refused")).toBe(false); + }); +}); diff --git a/packages/contracts/test/settings-sections.test.ts b/packages/contracts/test/settings-sections.test.ts index 21b54a1e..e8ccc109 100644 --- a/packages/contracts/test/settings-sections.test.ts +++ b/packages/contracts/test/settings-sections.test.ts @@ -8,7 +8,7 @@ import { } from "../src/settings.ts"; describe("SETTINGS_SECTIONS", () => { - it("9 个分区,顺序与 SettingsSectionId 一致", () => { + it("10 个分区,顺序与 SettingsSectionId 一致", () => { expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options); });