From 030dcda9b41be79dc8ac1baf0e8dac78ed8b2c6c Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 19 Jun 2026 11:09:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E7=99=BB=E5=BD=95=20+=20Bearer=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=20+=20=E4=BF=AE=E9=9B=AA=E8=8A=B1ID/=E6=8E=A2=E6=B4=BB?= =?UTF-8?q?=EF=BC=88=E9=80=82=E9=85=8D=E7=A1=AC=E5=8C=96=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E7=BD=91=E5=85=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin 加 RequireAdmin 后管理端原来无鉴权会 401。本次打通: - api.ts:JWT token 存 localStorage + 所有 /admin 调用带 Bearer + 401 清令牌广播登出; login/me;gatewayOnline 改用公开 /healthz(原 /billing 已转受保护会误判离线)。 - 修类型:Model.id number → string(模型 id 早已迁雪花字符串)。 - Login 登录门 + App 鉴权门(启动校验 me,无则登录页)+ AppShell 显示用户/登出。 实测(硬化网关):无 token /admin/models → 401;登录拿 token → 200 返回模型(string id)。 dev 未配 ADMIN_USER_IDS 时任意登录账号放行;生产须在白名单。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/App.tsx | 30 +++++++- sundynix-admin/src/Login.tsx | 62 +++++++++++++++ sundynix-admin/src/api.ts | 105 ++++++++++++++++++++------ sundynix-admin/src/shell/AppShell.tsx | 16 ++-- 4 files changed, 185 insertions(+), 28 deletions(-) create mode 100644 sundynix-admin/src/Login.tsx diff --git a/sundynix-admin/src/App.tsx b/sundynix-admin/src/App.tsx index 5bc802e..dfff3f9 100644 --- a/sundynix-admin/src/App.tsx +++ b/sundynix-admin/src/App.tsx @@ -1,11 +1,39 @@ +import { useEffect, useState } from "react"; import { HashRouter } from "react-router-dom"; import { AppShell } from "./shell/AppShell"; +import { Login } from "./Login"; +import { me, clearToken, type AuthUser } from "./api"; // 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。 export default function App() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + me() + .then(setUser) + .catch(() => setUser(null)) + .finally(() => setLoading(false)); + const onLogout = () => setUser(null); + window.addEventListener("sdx:logout", onLogout); + return () => window.removeEventListener("sdx:logout", onLogout); + }, []); + + if (loading) { + return
加载中…
; + } + if (!user) { + return ; + } return ( - + { + clearToken(); + setUser(null); + }} + /> ); } diff --git a/sundynix-admin/src/Login.tsx b/sundynix-admin/src/Login.tsx new file mode 100644 index 0000000..f5d075e --- /dev/null +++ b/sundynix-admin/src/Login.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { login, type AuthUser } from "./api"; + +// 管理端登录门:登录成功回调 onAuthed。需管理员账号(生产须在 ADMIN_USER_IDS 白名单)。 +export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const submit = async () => { + if (busy || !email.trim() || !password) return; + setErr(""); + setBusy(true); + try { + onAuthed(await login(email.trim(), password)); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
sundynix-agentix
+
运维控制台 · 管理员登录
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + autoFocus + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + + setPassword(e.target.value)} + placeholder="••••••••" + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + {err &&
{err}
} + +

+ 需管理员账号。开发期任意已注册账号即可;生产期账号须在网关 ADMIN_USER_IDS 白名单内。 +

+
+
+ ); +} diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 2362800..cced335 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -1,12 +1,80 @@ -// 运维控制台 → Gateway 控制面 API。 +// 运维控制台 → Gateway 控制面 API(带 JWT 鉴权;/admin 受 RequireAdmin 保护)。 export const GATEWAY: string = (import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080"; const ADMIN = `${GATEWAY}/api/v1/admin`; +// ---- 鉴权(JWT,存 localStorage)---- +const TOKEN_KEY = "sdx_admin_token"; +let token = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : ""; + +export function setToken(t: string): void { + token = t; + try { + localStorage.setItem(TOKEN_KEY, t); + } catch { + /* ignore */ + } +} +export function clearToken(): void { + token = ""; + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } +} +export function getToken(): string { + return token; +} + +function authHeaders(json = false): Record { + const h: Record = token ? { Authorization: `Bearer ${token}` } : {}; + if (json) h["Content-Type"] = "application/json"; + return h; +} + +// guard 在 401(未登录) 时清令牌并广播登出(403=已登录但非管理员,照常抛错)。 +function guard(res: Response): Response { + if (res.status === 401) { + clearToken(); + if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout")); + } + return res; +} + +export interface AuthUser { + id: string; + email: string; + name?: string; +} + +export async function login(email: string, password: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string }; + if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`); + setToken(data.token); + return data.user; +} + +export async function me(): Promise { + if (!token) return null; + const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() }); + if (!res.ok) { + clearToken(); + return null; + } + return ((await res.json()) as { user?: AuthUser }).user ?? null; +} + +// ---- 模型配置(id 为雪花字符串)---- export type Kind = "chat" | "embedding"; export interface Model { - id: number; + id: string; kind: Kind; provider: string; base_url: string; @@ -16,7 +84,7 @@ export interface Model { } export interface ModelInput { - id?: number; + id?: string; kind: Kind; provider: string; base_url: string; @@ -25,44 +93,37 @@ export interface ModelInput { } export async function listModels(kind: Kind): Promise { - const res = await fetch(`${ADMIN}/models?kind=${kind}`); + const res = guard(await fetch(`${ADMIN}/models?kind=${kind}`, { headers: authHeaders() })); if (!res.ok) throw new Error(`list failed: ${res.status}`); return ((await res.json()) as { models: Model[] }).models; } -export async function saveModel(m: ModelInput): Promise { - const res = await fetch(`${ADMIN}/models`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(m), - }); - const data = (await res.json()) as { id?: number; error?: string }; +export async function saveModel(m: ModelInput): Promise { + const res = guard(await fetch(`${ADMIN}/models`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) })); + const data = (await res.json()) as { id?: string; error?: string }; if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`); - return data.id ?? 0; + return data.id ?? ""; } -export async function setActive(id: number): Promise { - const res = await fetch(`${ADMIN}/models/${id}/active`, { method: "POST" }); +export async function setActive(id: string): Promise { + const res = guard(await fetch(`${ADMIN}/models/${id}/active`, { method: "POST", headers: authHeaders() })); if (!res.ok) throw new Error(`activate failed: ${res.status}`); } -export async function deleteModel(id: number): Promise { - const res = await fetch(`${ADMIN}/models/${id}`, { method: "DELETE" }); +export async function deleteModel(id: string): Promise { + const res = guard(await fetch(`${ADMIN}/models/${id}`, { method: "DELETE", headers: authHeaders() })); if (!res.ok) throw new Error(`delete failed: ${res.status}`); } export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> { - const res = await fetch(`${ADMIN}/models/test`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(m), - }); + const res = guard(await fetch(`${ADMIN}/models/test`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) })); return (await res.json()) as { ok: boolean; message: string }; } +// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。 export async function gatewayOnline(): Promise { try { - const res = await fetch(`${GATEWAY}/api/v1/billing`); + const res = await fetch(`${GATEWAY}/healthz`); return res.ok; } catch { return false; diff --git a/sundynix-admin/src/shell/AppShell.tsx b/sundynix-admin/src/shell/AppShell.tsx index fe17a94..b612211 100644 --- a/sundynix-admin/src/shell/AppShell.tsx +++ b/sundynix-admin/src/shell/AppShell.tsx @@ -2,10 +2,10 @@ 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"; +import { gatewayOnline, type AuthUser } from "../api"; // 控制台外壳:导航与内容均由路由注册表派生(动态路由)。 -export function AppShell() { +export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) { const [online, setOnline] = useState(false); const loc = useLocation(); const current = routes.find((r) => r.path === loc.pathname); @@ -45,9 +45,15 @@ export function AppShell() { ))} -
- - Gateway {online ? "在线" : "离线"} +
+
+ {user.name || user.email} + +
+
+ + Gateway {online ? "在线" : "离线"} +