From 66f2390078c17dda93cea6b19bc86e26aa9e03bb Mon Sep 17 00:00:00 2001 From: JIANG Date: Wed, 11 Feb 2026 18:58:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_refine_context.tsx | 9 +- src/app/api/auth/[...nextauth]/options.ts | 23 ++- src/components/header/index.tsx | 6 +- .../OptimizationParameters.tsx | 7 +- src/components/olmap/SCADADataPanel.tsx | 6 +- src/components/olmap/SCADADeviceList.tsx | 6 +- src/components/project/ProjectSelector.tsx | 155 +++++++++++++----- src/contexts/ProjectContext.tsx | 22 ++- src/lib/api.ts | 24 ++- src/lib/apiFetch.ts | 24 ++- src/lib/authToken.ts | 51 ++++++ src/store/authStore.ts | 11 ++ src/types/next-auth.d.ts | 25 +++ 13 files changed, 307 insertions(+), 62 deletions(-) create mode 100644 src/lib/authToken.ts create mode 100644 src/store/authStore.ts create mode 100644 src/types/next-auth.d.ts diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index af98169..e88653d 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -8,13 +8,14 @@ import { } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; -import React from "react"; +import React, { useEffect } from "react"; import routerProvider from "@refinedev/nextjs-router"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; +import { useAuthStore } from "@/store/authStore"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit } from "react-icons/tb"; @@ -47,6 +48,11 @@ type AppProps = { const App = (props: React.PropsWithChildren) => { const { data, status } = useSession(); const to = usePathname(); + const setAccessToken = useAuthStore((state) => state.setAccessToken); + + useEffect(() => { + setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); + }, [data?.accessToken, setAccessToken]); if (status === "loading") { return loading...; @@ -103,6 +109,7 @@ const App = (props: React.PropsWithChildren) => { if (data?.user) { const { user } = data; return { + id: user.id, name: user.name, avatar: user.image, }; diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 4e74744..0fb478e 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -1,7 +1,8 @@ +import { NextAuthOptions } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; import Avatar from "@assets/avatar/avatar-small.jpeg"; -const authOptions = { +const authOptions: NextAuthOptions = { // Configure one or more authentication providers providers: [ KeycloakProvider({ @@ -19,6 +20,26 @@ const authOptions = { }), ], secret: process.env.NEXTAUTH_SECRET, + callbacks: { + jwt: async ({ token, profile, account }) => { + if (profile?.sub) { + token.sub = profile.sub; + } + if (account?.access_token) { + token.accessToken = account.access_token; + } + return token; + }, + session: async ({ session, token }) => { + if (session.user && token.sub) { + session.user.id = token.sub; + } + if (token.accessToken) { + session.accessToken = token.accessToken; + } + return session; + }, + }, }; export default authOptions; diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index ee70181..dfb23f9 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -25,9 +25,9 @@ import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; type IUser = { - id: number; - name: string; - avatar: string; + id?: string; + name?: string; + avatar?: string; }; export const Header: React.FC = ({ diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 4c1cb67..3e75c1a 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -16,8 +16,8 @@ import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; type IUser = { - id: number; - name: string; + id: string; + name?: string; }; const OptimizationParameters: React.FC = () => { @@ -83,7 +83,7 @@ const OptimizationParameters: React.FC = () => { setAnalyzing(true); - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效", @@ -104,6 +104,7 @@ const OptimizationParameters: React.FC = () => { method: method, sensor_count: sensorCount, min_diameter: minDiameter, + user_id: user.id, user_name: user.name, }, } diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx index a6c53dc..a788247 100644 --- a/src/components/olmap/SCADADataPanel.tsx +++ b/src/components/olmap/SCADADataPanel.tsx @@ -44,8 +44,8 @@ dayjs.extend(utc); dayjs.extend(timezone); type IUser = { - id: number; - name: string; + id: string; + name?: string; }; export interface TimeSeriesPoint { @@ -459,7 +459,7 @@ const SCADADataPanel: React.FC = ({ return; } - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效,请重新登录", diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index 2c6f200..c8fdd1e 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -104,8 +104,8 @@ interface SCADADeviceListProps { } type IUser = { - id: number; - name: string; + id: string; + name?: string; }; const SCADADeviceList: React.FC = ({ @@ -601,7 +601,7 @@ const SCADADeviceList: React.FC = ({ return; } - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效,请重新登录", diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index b5fe3ec..00d3b8a 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -15,7 +15,9 @@ import { IconButton, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { apiFetch } from "@/lib/apiFetch"; +import { config, NETWORK_NAME } from "@/config/config"; interface ProjectSelectorProps { open: boolean; @@ -28,45 +30,96 @@ interface ProjectSelectorProps { onClose?: () => void; } -const PROJECTS = [ - { - id: "tjwater", - label: "默认", - workspace: "tjwater", - networkName: "tjwater", - extent: [13508802, 3608164, 13555651, 3633686], - }, - // { - // label: "苏州河", - // workspace: "szh", - // networkName: "szh", - // extent: [13490131, 3630016, 13525879, 3666969], - // }, - { - id: "test", - label: "测试项目", - workspace: "test", - networkName: "test", - extent: [13508849, 3608036, 13555781, 3633813], - }, -]; +type ProjectOption = { + id: string; + label: string; + workspace: string; + networkName: string; + extent: number[]; + description?: string | null; + status?: string | null; + projectRole?: string | null; +}; export const ProjectSelector: React.FC = ({ open, onSelect, onClose, }) => { - const [projectId, setProjectId] = useState(PROJECTS[0].id); - const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); - const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); - const [extent, setExtent] = useState( - PROJECTS[0].extent, - ); + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [projectId, setProjectId] = useState(""); + const [projectIdError, setProjectIdError] = useState(null); + const [workspace, setWorkspace] = useState(config.MAP_WORKSPACE); + const [networkName, setNetworkName] = useState(NETWORK_NAME || "tjwater"); + const [extent, setExtent] = useState(config.MAP_EXTENT); const [customMode, setCustomMode] = useState(false); + useEffect(() => { + const fetchProjects = async () => { + setIsLoading(true); + setLoadError(null); + try { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/meta/projects`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const mapped: ProjectOption[] = Array.isArray(data) + ? data.map((item) => { + const bbox = Array.isArray(item.map_extent?.bbox) + ? item.map_extent.bbox.map((value: number) => Number(value)) + : null; + return { + id: item.project_id, + label: item.name || item.code || item.project_id, + workspace: item.gs_workspace || config.MAP_WORKSPACE, + networkName: item.code || NETWORK_NAME || config.MAP_WORKSPACE, + extent: + bbox && bbox.length === 4 ? bbox : config.MAP_EXTENT, + description: item.description, + status: item.status, + projectRole: item.project_role, + }; + }) + : []; + setProjects(mapped); + const savedProjectId = localStorage.getItem("active_project"); + const initial = + (savedProjectId && + mapped.find((project) => project.id === savedProjectId)) || + mapped[0]; + if (initial) { + setProjectId(initial.id); + setWorkspace(initial.workspace); + setNetworkName(initial.networkName); + setExtent(initial.extent); + setCustomMode(false); + } else { + setCustomMode(true); + } + } catch (error) { + console.error("Failed to load projects:", error); + setLoadError("项目列表加载失败,请使用自定义配置"); + setCustomMode(true); + } finally { + setIsLoading(false); + } + }; + + fetchProjects(); + }, []); + const handleConfirm = () => { - const resolvedProjectId = projectId.trim() || workspace || networkName; - onSelect(resolvedProjectId, workspace, networkName, extent); + if (!projectId.trim()) { + setProjectIdError("项目 ID 不能为空"); + return; + } + setProjectIdError(null); + onSelect(projectId.trim(), workspace, networkName, extent); }; return ( @@ -126,31 +179,46 @@ export const ProjectSelector: React.FC = ({ 项目 + {loadError && ( + + {loadError} + + )} ) : ( setProjectId(e.target.value)} + onChange={(e) => { + setProjectId(e.target.value); + setProjectIdError(null); + }} fullWidth - helperText="例如: tjwater" + helperText={ + projectIdError || "例如: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + error={Boolean(projectIdError)} /> = ({ setIsConfigured(true); try { - await apiFetch(`${config.BACKEND_URL}/openproject/?network=${net}`, { - method: "POST", - }); + const response = await apiFetch( + `${config.BACKEND_URL}/openproject/?network=${net}`, + { + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const bbox = Array.isArray(data?.map_extent?.bbox) + ? data.map_extent.bbox.map((value: number) => Number(value)) + : null; + if (bbox && bbox.length === 4) { + setMapExtent(bbox); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); + localStorage.removeItem(`${ws}_map_view`); + setCurrentProject((prev) => ({ ...prev, extent: bbox })); + } } catch (error) { console.error("Failed to open project:", error); } diff --git a/src/lib/api.ts b/src/lib/api.ts index cc7aea8..c2f7a33 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { config } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; +import { getAccessToken } from "@/lib/authToken"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; @@ -8,11 +9,26 @@ export const api = axios.create({ baseURL: API_URL, }); -api.interceptors.request.use((request) => { - const projectId = useProjectStore.getState().currentProjectId; - if (projectId) { +const isMetaProjectsRequest = (request: { + baseURL?: string; + url?: string; +}) => { + const url = `${request.baseURL ?? ""}${request.url ?? ""}`; + return url.includes("/api/v1/meta/projects"); +}; + +api.interceptors.request.use(async (request) => { + const accessToken = await getAccessToken(); + if (accessToken) { request.headers = request.headers ?? {}; - request.headers["X-Project-ID"] = projectId; + request.headers.Authorization = `Bearer ${accessToken}`; } + + const projectId = useProjectStore.getState().currentProjectId; + if (projectId && !isMetaProjectsRequest(request)) { + request.headers = request.headers ?? {}; + request.headers["X-Project-Id"] = projectId; + } + return request; }); diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index 1c8c531..c303987 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,10 +1,28 @@ import { useProjectStore } from "@/store/projectStore"; +import { getAccessToken } from "@/lib/authToken"; -export const apiFetch = (input: RequestInfo | URL, init: RequestInit = {}) => { +const resolveUrl = (input: RequestInfo | URL) => { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + return ""; +}; + +const isMetaProjectsRequest = (input: RequestInfo | URL) => + resolveUrl(input).includes("/api/v1/meta/projects"); + +export const apiFetch = async ( + input: RequestInfo | URL, + init: RequestInit = {}, +) => { const projectId = useProjectStore.getState().currentProjectId; const headers = new Headers(init.headers ?? {}); - if (projectId) { - headers.set("X-Project-ID", projectId); + const accessToken = await getAccessToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + if (projectId && !isMetaProjectsRequest(input)) { + headers.set("X-Project-Id", projectId); } return fetch(input, { ...init, headers }); }; diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts new file mode 100644 index 0000000..ee0f15c --- /dev/null +++ b/src/lib/authToken.ts @@ -0,0 +1,51 @@ +import { getSession } from "next-auth/react"; +import { useAuthStore } from "@/store/authStore"; + +const decodeJwtPayload = (token: string) => { + const parts = token.split("."); + if (parts.length < 2) { + console.warn("Invalid JWT format."); + return null; + } + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + try { + const json = + typeof window !== "undefined" + ? window.atob(padded) + : Buffer.from(padded, "base64").toString("utf-8"); + return JSON.parse(json); + } catch (error) { + console.warn("Failed to decode JWT payload.", error); + return null; + } +}; + +const isTokenExpired = (token: string) => { + const payload = decodeJwtPayload(token); + if (!payload) { + return true; + } + if (typeof payload.exp !== "number") { + return false; + } + const now = Date.now(); + return now >= payload.exp * 1000 - 30_000; +}; + +export const getAccessToken = async () => { + const { accessToken, setAccessToken } = useAuthStore.getState(); + if (accessToken && !isTokenExpired(accessToken)) { + return accessToken; + } + if (accessToken) { + setAccessToken(null); + } + const session = await getSession(); + const token = typeof session?.accessToken === "string" ? session.accessToken : null; + if (token && !isTokenExpired(token)) { + setAccessToken(token); + return token; + } + return null; +}; diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..3a6af58 --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface AuthState { + accessToken: string | null; + setAccessToken: (token: string | null) => void; +} + +export const useAuthStore = create((set) => ({ + accessToken: null, + setAccessToken: (token) => set({ accessToken: token }), +})); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..233d00a --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,25 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + accessToken?: string; + user?: { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; + }; + } + + interface User { + id?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + sub?: string; + accessToken?: string; + } +}