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:
rootandClaude Opus 5.5 committed 2026-09-29 20:42:27 +08:00
1 parent 4480fc346d
commit f0d4935fa7
21 files changed
+688 -78

No files matched your search

@@ -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<PlanRes
const hnrConflict = blocking.find((c) => 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<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
}
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
@@ -1671,7 +1674,7 @@ async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: stri
organizationRecordId: rec.id,
}),
})
.where("dedupe_key", "=", `organize_blocked:${downloadTaskId}`)
.where("dedupe_key", "in", [`organize_blocked:${downloadTaskId}`, `organize_identity:${downloadTaskId}`])
.where("status", "in", ["open", "investigating", "awaiting_user"])
.returning("id")
.execute();
@@ -1780,7 +1783,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
.join(";");
const reason = (code: string | null, err: unknown) =>
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",
+17 -6
View File
@@ -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),
@@ -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
+1 -1
View File
@@ -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>
);
}
+15 -1
View File
@@ -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 /> },
};
+1 -1
View File
@@ -41,7 +41,7 @@ const DECISION_KIND: Record<string, string> = {
identity_unresolved: "未识别",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "路径冲突",
path_conflict: "整理冲突",
space_insufficient: "空间不足",
approval_required: "待审批",
metadata_conflict: "元数据冲突",
+35
View File
@@ -0,0 +1,35 @@
import { looksLikeCode, reasonText } from "@umt/contracts";
// 原因串 → 一句人话(DEAI-2)。映射表在 @umt/contracts 的 reasons.ts,服务端与前端共用;
// 原码交给 <TechInfo> 显示,不直接给人看。
/** `{ 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;
}
+72
View File
@@ -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<UiPrefs>) };
} catch {
v = DEFAULTS;
}
cache = v;
return v;
}
export function setUiPref<K extends keyof UiPrefs>(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<string, boolean> | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as Record<string, boolean>) : null;
} catch {
return null;
}
}
export function writeCollapseState(key: string, v: Record<string, boolean>) {
try {
localStorage.setItem(key, JSON.stringify(v));
} catch {
// 忽略
}
}
+106 -12
View File
@@ -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 ? (
<li className="text-meta text-muted-foreground">
另有 {count - list.length} 条,可在媒体概览 → 未识别按标题检索
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
</li>
) : null}
</ul>
@@ -147,7 +150,14 @@ function Evidence({ d }: { d: Decision }) {
))}
</div>
) : null}
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
<TechInfo
items={[
{ label: "事项 ID", value: d.id },
{ label: "类型", value: d.kind },
]}
>
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
</TechInfo>
</div>
);
}
@@ -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 (
<>
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
<Search />
指定作品
</Button>
<WorkAssignDialog
open={open}
onOpenChange={setOpen}
subject={name}
initialQuery={queryOf(name)}
onAssign={assign}
/>
{wizard && recordId ? (
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => 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 (
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
<div className="flex flex-wrap items-center gap-2">
<Tone v={SEVERITY[d.severity]} />
<Tone v={DECISION_STATUS[d.status]} />
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
{relTime(d.createdAt)}
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
@@ -294,6 +385,7 @@ function DecisionCard({ d }: { d: Decision }) {
</div>
{open ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{identity ? <AssignWorkButton d={d} /> : null}
{options.map((o) => (
<OptionButton key={o.key} d={d} option={o} />
))}
@@ -404,13 +496,15 @@ export default function InboxPage() {
<>
<PageHeader
title="待处理"
description="智能体调查和重试后仍未解决的事项,按作品和问题类型合并"
description="需要你决定的事"
actions={
<>
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
</span>
{live === "reconnecting" ? (
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
重连中
</span>
) : null}
<Button
variant="outline"
size="sm"
@@ -510,7 +604,7 @@ export default function InboxPage() {
</span>
) : (
<span className="text-body font-semibold text-muted-foreground">
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
+36 -33
View File
@@ -8,9 +8,11 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } 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 { actorTypeLabel } from "~/lib/labels.ts";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
const ACTION_LABEL = {
@@ -23,7 +25,6 @@ const ACTION_LABEL = {
delete: "永久删除",
restore: "从回收站还原",
} as const;
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "媒体库目录相对" } as const;
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队", tone: "neutral" },
running: { label: "执行中", tone: "neutral" },
@@ -42,22 +43,14 @@ const ROLE_LABEL = {
other: "其他",
} as const;
/** 稳定 code 与说明不同时才附上 code */
function reasonText(r: { code: string; message: string }) {
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
}
/** failed:失败且没有目标路径时直接说"未生成目标路径",不再显示视角与"未知或未授权" */
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">
{label}
{noTarget ? "" : ` · ${VIEW_LABEL[p.view]}`}
</p>
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未知或未授权")}
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
@@ -107,13 +100,19 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
{duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
@@ -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() {
) : (
<div className="space-y-4">
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
<StatusBadge>
{TRIGGER_LABEL[r.trigger]}
{r.trigger === "agent" ? "(计为自动)" : ""}
</StatusBadge>
<span className="text-meta text-muted-foreground tabular-nums">
{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(" · ")}
</span>
</div>
{r.reason ? (
@@ -196,7 +197,7 @@ export default function OrganizeRecordPage() {
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonText(r.reason)}
{reasonLine(r.reason)}
</p>
) : null}
<div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -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) },
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "触发者",
value: `${actorTypeLabel(r.actor.type)}${r.actor.id && r.actor.type !== "system" ? ` · ${r.actor.id}` : ""}`,
},
{
label: "外部身份",
label: "外部编号",
value: r.media?.externalIds.map((e) => `${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 ? (
+2 -2
View File
@@ -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<PublicOrganizationRecord>[] = [
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{r.reason ? (
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{r.reason.message}</span>
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
) : null}
</div>
),
@@ -120,7 +121,6 @@ export default function OrganizePage() {
<>
<PageHeader
title="整理记录"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
+1 -1
View File
@@ -59,7 +59,7 @@ export default function SettingsPage() {
return (
<>
<PageHeader title="设置" description="全局参数、实例与凭证。凭证保存后不回显。" />
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
+1
View File
@@ -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";
+73
View File
@@ -0,0 +1,73 @@
// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、待处理、下载时间线、任务都用这张表;
// 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。
export type ReasonText = {
/** 发生了什么(一句,不带句号) */
text: string;
/** 下一步能做什么(一句,不带句号);没有可做的就不给 */
action?: string;
};
const REASONS: Record<string, ReasonText> = {
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);
+10
View File
@@ -780,6 +780,7 @@ export const SettingsSectionId = z.enum([
"metadata",
"agent",
"api-keys",
"interface",
"account",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
@@ -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: "账户与安全",
+26
View File
@@ -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);
});
});
@@ -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);
});