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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /> },
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "元数据冲突",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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: "账户与安全",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user