refactor(admin): 控制台改为路由表驱动的动态路由 (react-router)

控制台从 useState 切 tab + 硬编码条件渲染,改为路由注册表驱动 + 真实 URL 路由,
加页面只需在 routes.tsx 加一条,不动外壳。

- 依赖 react-router-dom v7;App=HashRouter(静态托管/桌面内嵌都能深链)
- routes.tsx:路由注册表(单一事实源,导航+内容都派生);real 页面 lazy 懒加载(代码分割)
- shell/AppShell:NavLink 分组导航(配置/平台) + Routes + Suspense + 健康灯,全从注册表派生
- 页面归入 pages/(ModelsPage 移入),components/Soon 占位复用
- 验证:npm build✓(ModelsPage 独立 chunk=懒加载生效);真实浏览器——默认重定向 #/models、
  nav 切换改 URL hash、深链 #/guardrails 直达、浏览器后退回 #/datasources、active 高亮

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 16:09:07 +08:00
parent 6f5b98f186
commit f6a669070d
7 changed files with 208 additions and 78 deletions
+59 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
@@ -1470,6 +1471,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2215,6 +2229,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"license": "MIT",
"dependencies": {
"react-router": "7.17.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2356,6 +2408,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+2 -1
View File
@@ -10,7 +10,8 @@
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
+6 -75
View File
@@ -1,80 +1,11 @@
import { useEffect, useState } from "react";
import { ModelsPage } from "./ModelsPage";
import { gatewayOnline } from "./api";
type Tab = "models" | "datasources" | "tenants" | "guardrails";
const NAV: Array<{ key: Tab; label: string; ready?: boolean }> = [
{ key: "models", label: "模型", ready: true },
{ key: "datasources", label: "数据源" },
{ key: "tenants", label: "租户" },
{ key: "guardrails", label: "护栏" },
];
function Soon({ title, desc }: { title: string; desc: string }) {
return (
<div className="rounded-lg border border-dashed bg-gray-50 p-6">
<div className="mb-1 text-sm font-semibold text-gray-600">{title}</div>
<p className="text-xs leading-relaxed text-gray-400">{desc}</p>
<span className="mt-3 inline-block rounded bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500"></span>
</div>
);
}
import { HashRouter } from "react-router-dom";
import { AppShell } from "./shell/AppShell";
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。
export default function App() {
const [tab, setTab] = useState<Tab>("models");
const [online, setOnline] = useState(false);
useEffect(() => {
const ping = () => gatewayOnline().then(setOnline);
ping();
const id = setInterval(ping, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="flex h-screen w-screen text-gray-900">
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
<div className="border-b p-4">
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<nav className="flex flex-col p-2">
{NAV.map((n) => (
<button
key={n.key}
onClick={() => setTab(n.key)}
className={`flex items-center justify-between rounded px-3 py-2 text-left text-sm ${
tab === n.key ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
}`}
>
{n.label}
{!n.ready && <span className="text-[9px] text-gray-300"></span>}
</button>
))}
</nav>
<div className="mt-auto flex items-center gap-2 border-t p-4 text-[11px] text-gray-500">
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
Gateway {online ? "在线" : "离线"}
</div>
</aside>
<main className="flex-1 overflow-auto p-6">
<h1 className="mb-4 text-lg font-semibold text-gray-800">
{NAV.find((n) => n.key === tab)?.label}
</h1>
{tab === "models" && <ModelsPage />}
{tab === "datasources" && (
<Soon
title="数据源(向量库 / 图库 / 全文)"
desc="配置 Milvus(:19530) / Neo4j(:7687) / Bleve 连接 + 测试连接 + 状态。后端 search.Hybrid 接真后接通(RAG 核心链)。"
/>
)}
{tab === "tenants" && (
<Soon title="租户 / 工作区" desc="多租户隔离、配额、用户与计费。垂直行业平台级复制的基座。" />
)}
{tab === "guardrails" && (
<Soon title="护栏" desc="输入/输出 Guardrail 规则(脱敏 / 免责 / 强制引用)。受监管垂直必备。" />
)}
</main>
</div>
<HashRouter>
<AppShell />
</HashRouter>
);
}
+10
View File
@@ -0,0 +1,10 @@
// 规划中页面占位(路由目标,后续替换为真实页面即可)。
export function Soon({ title, desc }: { title: string; desc: string }) {
return (
<div className="rounded-lg border border-dashed bg-gray-50 p-6">
<div className="mb-1 text-sm font-semibold text-gray-600">{title}</div>
<p className="text-xs leading-relaxed text-gray-400">{desc}</p>
<span className="mt-3 inline-block rounded bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500"></span>
</div>
);
}
@@ -7,7 +7,7 @@ import {
testModel,
type Model,
type ModelInput,
} from "./api";
} from "../api";
const EMPTY: ModelInput = {
provider: "openai-compatible",
+63
View File
@@ -0,0 +1,63 @@
import { lazy, type ReactNode } from "react";
import { Soon } from "./components/Soon";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
// 新增页面 = 在此加一条;real 页面用 lazy 懒加载(代码分割)。
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
export interface RouteDef {
path: string;
label: string;
group: string;
ready?: boolean;
element: ReactNode;
}
export const routes: RouteDef[] = [
{
path: "/models",
label: "模型",
group: "配置",
ready: true,
element: <ModelsPage />,
},
{
path: "/datasources",
label: "数据源",
group: "配置",
element: (
<Soon
title="数据源(向量库 / 图库 / 全文)"
desc="配置 Milvus(:19530) / Neo4j(:7687) / Bleve 连接 + 测试连接 + 状态。复用模型控制面同套路(配置→NATS 下发→mcp-go 热更新)。RAG 核心链。"
/>
),
},
{
path: "/tenants",
label: "租户",
group: "平台",
element: <Soon title="租户 / 工作区" desc="多租户隔离、配额、用户与计费。垂直行业平台级复制的基座。" />,
},
{
path: "/guardrails",
label: "护栏",
group: "平台",
element: <Soon title="护栏" desc="输入/输出 Guardrail 规则(脱敏 / 免责 / 强制引用)。受监管垂直必备。" />,
},
];
export const defaultPath = "/models";
// 派生分组导航(保持注册顺序)。
export function navGroups(): Array<{ group: string; items: RouteDef[] }> {
const out: Array<{ group: string; items: RouteDef[] }> = [];
for (const r of routes) {
let g = out.find((x) => x.group === r.group);
if (!g) {
g = { group: r.group, items: [] };
out.push(g);
}
g.items.push(r);
}
return out;
}
+67
View File
@@ -0,0 +1,67 @@
import { Suspense, useEffect, useState } from "react";
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { routes, navGroups, defaultPath } from "../routes";
import { gatewayOnline } from "../api";
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
export function AppShell() {
const [online, setOnline] = useState(false);
const loc = useLocation();
const current = routes.find((r) => r.path === loc.pathname);
useEffect(() => {
const ping = () => gatewayOnline().then(setOnline);
ping();
const id = setInterval(ping, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="flex h-screen w-screen text-gray-900">
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
<div className="border-b p-4">
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<nav className="flex flex-col gap-1 p-2">
{navGroups().map((g) => (
<div key={g.group}>
<div className="mt-2 px-3 text-[9px] font-semibold tracking-wider text-gray-300">{g.group}</div>
{g.items.map((r) => (
<NavLink
key={r.path}
to={r.path}
className={({ isActive }) =>
`flex items-center justify-between rounded px-3 py-2 text-sm ${
isActive ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
}`
}
>
{r.label}
{!r.ready && <span className="text-[9px] text-gray-300"></span>}
</NavLink>
))}
</div>
))}
</nav>
<div className="mt-auto flex items-center gap-2 border-t p-4 text-[11px] text-gray-500">
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
Gateway {online ? "在线" : "离线"}
</div>
</aside>
<main className="flex-1 overflow-auto p-6">
<h1 className="mb-4 text-lg font-semibold text-gray-800">{current?.label ?? ""}</h1>
<Suspense fallback={<div className="text-sm text-gray-400"></div>}>
<Routes>
{routes.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
))}
<Route path="*" element={<Navigate to={defaultPath} replace />} />
</Routes>
</Suspense>
</main>
</div>
);
}