feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊 - 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦 - 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置 - 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数) - 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面 - 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示 - 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入 - 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
93a6c8a910
commit
8071ad33f9
23 files changed
+1499
-642
No files matched your search
@@ -2,9 +2,18 @@ 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 { parseSettingsHash, SECTION_GROUPS, 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 { SettingsSearch } from "~/components/settings/settings-search.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
|
||||
@@ -20,21 +29,32 @@ export function clientLoader({ request }: { request: Request }) {
|
||||
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
|
||||
}
|
||||
|
||||
/** 搜索选中字段时经 location.state 带过来,对齐到节后聚焦这个字段 */
|
||||
type NavState = { field?: string } | null;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { section, anchor } = parseSettingsHash(location.hash);
|
||||
const { section, anchor, moved } = parseSettingsHash(location.hash);
|
||||
const field = (location.state as NavState)?.field ?? null;
|
||||
|
||||
// 切分区回到顶部;带节锚点时对齐到该节。节内容多是异步加载的(上游列表、能力清单),
|
||||
// 旧锚点(重排前的 #automation/cache 等)换成新位置,不留历史记录
|
||||
useEffect(() => {
|
||||
if (moved) navigate({ hash: settingsHash(section.id, anchor) }, { replace: true, state: location.state });
|
||||
}, [moved, section.id, anchor, navigate, location.state]);
|
||||
|
||||
// 切分区回到顶部;带节锚点时对齐到该节(有字段时对齐到字段并聚焦)。节内容多是异步加载的(上游列表、能力清单),
|
||||
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
|
||||
useEffect(() => {
|
||||
if (moved) return;
|
||||
if (!anchor) {
|
||||
window.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
const started = performance.now();
|
||||
let stopped = false;
|
||||
let focused = false;
|
||||
let timer = 0;
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
@@ -43,10 +63,20 @@ export default function SettingsPage() {
|
||||
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" });
|
||||
const fieldEl = field ? document.getElementById(`f-${field}`) : null;
|
||||
if (fieldEl) {
|
||||
const r = fieldEl.getBoundingClientRect();
|
||||
if (r.top < 80 || r.bottom > window.innerHeight - 40) fieldEl.scrollIntoView({ block: "center" });
|
||||
if (!focused) {
|
||||
focused = true;
|
||||
fieldEl.focus({ preventScroll: true });
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
};
|
||||
@@ -55,72 +85,111 @@ export default function SettingsPage() {
|
||||
window.clearTimeout(timer);
|
||||
for (const e of events) window.removeEventListener(e, stop);
|
||||
};
|
||||
}, [section.id, anchor, location.key]);
|
||||
}, [section.id, anchor, field, moved, 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">
|
||||
<PageHeader title="设置" description="按接入对象和业务环节分组。密钥保存后不再显示。" />
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8">
|
||||
{/* 窄屏:搜索 + 分区下拉 + 节横排 */}
|
||||
<div className="grid gap-3 lg:hidden">
|
||||
<SettingsSearch />
|
||||
<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>
|
||||
{SECTION_GROUPS.map((g) => (
|
||||
<SelectGroup key={g.id}>
|
||||
<SelectLabel>{g.label}</SelectLabel>
|
||||
{g.sections.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<s.icon />
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-2 text-caption text-muted-foreground">{section.description}</p>
|
||||
<p className="text-caption text-muted-foreground">{section.description}</p>
|
||||
{section.anchors.length > 1 ? (
|
||||
<nav aria-label="本分区的节" className="scroll-bleed -mx-1 overflow-x-auto px-1 pb-1">
|
||||
<ul className="flex w-max gap-1.5">
|
||||
{section.anchors.map((a) => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
to={{ hash: settingsHash(section.id, a.id) }}
|
||||
className={cn(
|
||||
"block rounded-full border px-3 py-1 text-caption whitespace-nowrap transition-colors duration-micro",
|
||||
anchor === a.id
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border/70 text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{a.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
</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}>
|
||||
{/* 宽屏:搜索 + 左侧分组导航 */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-3 overflow-y-auto pb-2">
|
||||
<SettingsSearch />
|
||||
<nav aria-label="设置分区" className="grid gap-4">
|
||||
{SECTION_GROUPS.map((g) => (
|
||||
<div key={g.id}>
|
||||
<p className="mb-1 px-3 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
|
||||
{g.label}
|
||||
</p>
|
||||
<ul className="grid gap-0.5">
|
||||
{g.sections.map((s) => {
|
||||
const active = s.id === section.id;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to={{ hash: settingsHash(s.id, a.id) }}
|
||||
to={{ hash: settingsHash(s.id) }}
|
||||
aria-current={active ? "page" : undefined}
|
||||
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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{a.label}
|
||||
<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>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
|
||||
|
||||
Reference in new issue
Block a user