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

+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 {
// 忽略
}
}