Files
UHD_Media_Tool/apps/web/app/routes/console/settings.tsx
T
rootandClaude Opus 5.5 f0d4935fa7 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]>
2026-09-29 20:42:27 +08:00

139 lines
5.9 KiB
TypeScript

import { settingsSectionForLegacyTab } from "@umt/contracts";
import { useEffect } from "react";
import { Link, redirect, useLocation, useNavigate } from "react-router";
import { PageHeader } from "~/components/console/page-header.tsx";
import { parseSettingsHash, SECTIONS, settingsHash } from "~/components/settings/sections.tsx";
import { anchorId } from "~/components/settings/settings-block.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
/** 旧链接 `?tab=xxx` → `#分区[/节]`(console-ia.md §3.3) */
export function clientLoader({ request }: { request: Request }) {
const url = new URL(request.url);
const tab = url.searchParams.get("tab");
if (!tab) return null;
const hit = settingsSectionForLegacyTab(tab);
url.searchParams.delete("tab");
const rest = url.searchParams.toString();
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
}
export default function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
const { section, anchor } = parseSettingsHash(location.hash);
// 切分区回到顶部;带节锚点时对齐到该节。节内容多是异步加载的(上游列表、能力清单),
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
useEffect(() => {
if (!anchor) {
window.scrollTo({ top: 0 });
return;
}
const started = performance.now();
let stopped = false;
let timer = 0;
const stop = () => {
stopped = true;
};
const events = ["wheel", "touchstart", "keydown", "pointerdown"] as const;
for (const e of events) window.addEventListener(e, stop, { passive: true });
const align = () => {
if (stopped) return;
const el = document.getElementById(anchorId(section.id, anchor));
if (el) {
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
}
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
};
timer = window.setTimeout(align, 0);
return () => {
window.clearTimeout(timer);
for (const e of events) window.removeEventListener(e, stop);
};
}, [section.id, anchor, location.key]);
return (
<>
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
<SelectTrigger className="w-full" aria-label="设置分区">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SECTIONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon />
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-2 text-caption text-muted-foreground">{section.description}</p>
</div>
{/* 宽屏:左侧分区导航 */}
<nav aria-label="设置分区" className="hidden lg:block">
<ul className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-0.5 overflow-y-auto pb-2">
{SECTIONS.map((s) => {
const active = s.id === section.id;
return (
<li key={s.id}>
<Link
to={{ hash: settingsHash(s.id) }}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
active
? "bg-foreground/[0.07] font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
<div className="min-w-0">
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
<div className="lg:mb-8">
<h2 className="sr-only text-title font-semibold text-foreground lg:not-sr-only">{section.label}</h2>
<p className="mt-1 hidden text-caption text-muted-foreground lg:block">{section.description}</p>
</div>
<div key={section.id} className="grid grid-cols-1 gap-8">
{section.render()}
</div>
</div>
</div>
</>
);
}