feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -1,64 +1,119 @@
|
||||
import { Bot, FolderTree, HardDrive, KeyRound, Link2, Server, Tv, UserCog, Zap } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import {
|
||||
DownloadersSection,
|
||||
LibraryRootsSection,
|
||||
MediaServersSection,
|
||||
PathMappingsSection,
|
||||
} from "./settings/instances-tab.tsx";
|
||||
import { ApiKeysSection, SiteCredentialsSection } from "./settings/keys-tab.tsx";
|
||||
import { AutomationTab, PiTab, StorageTab } from "./settings/prefs-tab.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 媒体自动化控制台" }];
|
||||
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const TABS = [
|
||||
{ value: "automation", label: "自动化", icon: Zap, render: () => <AutomationTab /> },
|
||||
{ value: "downloaders", label: "下载器", icon: Server, render: () => <DownloadersSection /> },
|
||||
{ value: "media-servers", label: "媒体服务器", icon: Tv, render: () => <MediaServersSection /> },
|
||||
{ value: "library-roots", label: "库根", icon: FolderTree, render: () => <LibraryRootsSection /> },
|
||||
{ value: "path-mappings", label: "路径映射", icon: Link2, render: () => <PathMappingsSection /> },
|
||||
{ value: "site-credentials", label: "站点凭证", icon: UserCog, render: () => <SiteCredentialsSection /> },
|
||||
{ value: "api-keys", label: "API Key", icon: KeyRound, render: () => <ApiKeysSection /> },
|
||||
{ value: "pi", label: "Pi 上游", icon: Bot, render: () => <PiTab /> },
|
||||
{ value: "storage", label: "缓存与入库", icon: HardDrive, render: () => <StorageTab /> },
|
||||
] as const;
|
||||
/** 旧链接 `?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 [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation";
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { section, anchor } = parseSettingsHash(location.hash);
|
||||
|
||||
// 切分区回到顶部;带节锚点时滚到该节(内容可能还在加载,稍后再试一次)
|
||||
useEffect(() => {
|
||||
if (!anchor) {
|
||||
window.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
let tries = 0;
|
||||
const go = () => {
|
||||
const el = document.getElementById(anchorId(section.id, anchor));
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
else if (tries++ < 10) timer = window.setTimeout(go, 150);
|
||||
};
|
||||
let timer = window.setTimeout(go, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [section.id, anchor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="系统设置" description="实例、路径、凭证、密钥、模型上游与自动化开关。凭证一律只写,不回显。" />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", v);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList aria-label="设置分节">
|
||||
{TABS.map((t) => (
|
||||
<TabsTrigger key={t.value} value={t.value}>
|
||||
<t.icon />
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TABS.map((t) => (
|
||||
<TabsContent key={t.value} value={t.value}>
|
||||
{tab === t.value ? t.render() : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<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 gap-0.5">
|
||||
{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="mb-6 hidden lg:block">
|
||||
<h2 className="flex items-center gap-2 text-title font-semibold text-foreground">{section.label}</h2>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">{section.description}</p>
|
||||
</div>
|
||||
<div key={section.id}>{section.render()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user