控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。
- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
174 lines
7.4 KiB
TypeScript
174 lines
7.4 KiB
TypeScript
import { settingsSectionForLegacyTab } from "@umt/contracts";
|
|
import { useEffect, useRef } from "react";
|
|
import { Link, redirect, useLocation, useNavigate } from "react-router";
|
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
import { parseSettingsHash, SECTION_GROUPS, settingsHash } from "~/components/settings/sections.tsx";
|
|
import { anchorId } from "~/components/settings/settings-block.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 媒体自动化控制台" }];
|
|
|
|
/** 旧链接 `?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) : ""}`);
|
|
}
|
|
|
|
/** 搜索选中字段时经 location.state 带过来,对齐到节后聚焦这个字段 */
|
|
type NavState = { field?: string } | null;
|
|
|
|
export default function SettingsPage() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
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 秒内反复校正,直到用户自己滚动为止。
|
|
// 打开 / 关闭记录抽屉(?open=,console-ia.md §9)只改 search,不滚动
|
|
const lastSearch = useRef(location.search);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
|
|
useEffect(() => {
|
|
const searchChanged = lastSearch.current !== location.search;
|
|
lastSearch.current = location.search;
|
|
if (moved || searchChanged) 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;
|
|
};
|
|
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 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);
|
|
};
|
|
timer = window.setTimeout(align, 0);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
for (const e of events) window.removeEventListener(e, stop);
|
|
};
|
|
}, [section.id, anchor, field, moved, location.key]);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="设置" description="密钥保存后不再显示。" />
|
|
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8">
|
|
{/* 窄屏:搜索 + 分区下拉。左栏只有一层(console-ia.md §9):节是页面里的小标题,搜索与 #分区/节 深链仍能定位 */}
|
|
<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">
|
|
{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="text-caption text-muted-foreground">{section.description}</p>
|
|
</div>
|
|
|
|
{/* 宽屏:搜索 + 左侧分组导航(只列分区,不展开节) */}
|
|
<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) }}
|
|
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
|
|
? "seg-thumb 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>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
}
|