diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 449e8c2..3877f2f 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -6,7 +6,109 @@ const http = axios.create({ timeout: 15000 }); -http.interceptors.request.use((config) => { +const refreshClient = axios.create({ + baseURL: "/", + timeout: 15000 +}); + +const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"]; +const API_SUCCESS_CODE = "200"; +const REFRESH_AHEAD_MS = 60 * 1000; + +let refreshPromise: Promise | null = null; + +function isApiSuccessCode(code: unknown): boolean { + return String(code) === API_SUCCESS_CODE; +} + +function getTokenPayload(token: string): Record | null { + try { + const payload = token.split(".")[1]; + if (!payload) { + return null; + } + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(decodeURIComponent(escape(window.atob(normalized)))); + } catch { + return null; + } +} + +function getTokenExpireAt(token: string): number | null { + const payload = getTokenPayload(token); + if (!payload || typeof payload.exp !== "number") { + return null; + } + return payload.exp * 1000; +} + +function isTokenExpiringSoon(token: string): boolean { + const expireAt = getTokenExpireAt(token); + if (!expireAt) { + return false; + } + return expireAt - Date.now() <= REFRESH_AHEAD_MS; +} + +function clearAuthStorage() { + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + sessionStorage.removeItem("userProfile"); +} + +function persistTokens(data: { accessToken: string; refreshToken: string }) { + localStorage.setItem("accessToken", data.accessToken); + localStorage.setItem("refreshToken", data.refreshToken); + + const payload = getTokenPayload(data.accessToken); + if (payload && payload.tenantId !== undefined && payload.tenantId !== null) { + localStorage.setItem("activeTenantId", String(payload.tenantId)); + } +} + +function isAuthWhitelistRequest(url?: string) { + return AUTH_WHITELIST.some((path) => (url || "").includes(path)); +} + +async function refreshAccessToken(): Promise { + if (refreshPromise) { + return refreshPromise; + } + + const refreshToken = localStorage.getItem("refreshToken"); + if (!refreshToken) { + return null; + } + + refreshPromise = refreshClient + .post("/sys/auth/refresh", { refreshToken }) + .then((resp) => { + const body = resp.data; + if (!body || !isApiSuccessCode(body.code) || !body.data?.accessToken || !body.data?.refreshToken) { + throw new Error(body?.msg || "刷新登录态失败"); + } + persistTokens(body.data); + return body.data.accessToken as string; + }) + .catch(() => { + clearAuthStorage(); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +} + +http.interceptors.request.use(async (config) => { + if (!isAuthWhitelistRequest(config.url)) { + const currentToken = localStorage.getItem("accessToken"); + if (currentToken && isTokenExpiringSoon(currentToken)) { + await refreshAccessToken(); + } + } + const token = localStorage.getItem("accessToken"); if (token) { config.headers = config.headers || {}; @@ -18,10 +120,9 @@ http.interceptors.request.use((config) => { http.interceptors.response.use( (resp) => { const body = resp.data; - // 如果返回的 code 不是 0,表示业务错误 - if (body && body.code !== "0") { + if (body && !isApiSuccessCode(body.code)) { const errorMsg = body.msg || "请求失败"; - message.error(errorMsg); // 自动展示后端错误消息 + message.error(errorMsg); const err = new Error(errorMsg); (err as any).code = body.code; (err as any).msg = body.msg; @@ -29,27 +130,37 @@ http.interceptors.response.use( } return resp; }, - (error) => { - // 处理 HTTP 状态码错误 (4xx, 5xx) + async (error) => { + const originalRequest = error.config || {}; + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isAuthWhitelistRequest(originalRequest.url) + ) { + originalRequest._retry = true; + const newToken = await refreshAccessToken(); + if (newToken) { + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return http(originalRequest); + } + } + if (error.response && (error.response.status === 401 || error.response.status === 403)) { - localStorage.removeItem("accessToken"); - localStorage.removeItem("refreshToken"); - sessionStorage.removeItem("userProfile"); + clearAuthStorage(); window.location.href = "/login?timeout=1"; return Promise.reject(error); } - + const body = error.response?.data; const errorMsg = body?.msg || error.message || "网络异常"; - - // 防止重复弹出相同的提示(可选逻辑,根据需要调整) message.error(errorMsg); if (body && body.msg) { - const err = new Error(body.msg); - (err as any).code = body.code; - (err as any).msg = body.msg; - return Promise.reject(err); + const err = new Error(body.msg); + (err as any).code = body.code; + (err as any).msg = body.msg; + return Promise.reject(err); } return Promise.reject(error); diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index b800705..9f520e1 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -55,7 +55,11 @@ function resolveMenuIcon(icon?: string): ReactNode { type PermissionMenuNode = SysPermission & { children?: PermissionMenuNode[]; }; +type CachedUserProfile = { displayName?: string; username?: string; avatar_url?: string }; +function getAvatarUrl(profile?: CachedUserProfile | null) { + return profile?.avatar_url?.trim() || ""; +} export default function AppLayout() { const { message } = App.useApp(); const { t, i18n } = useTranslation(); @@ -79,7 +83,7 @@ export default function AppLayout() { try { const profileStr = sessionStorage.getItem("userProfile"); if (profileStr) { - const profile = JSON.parse(profileStr) as { displayName?: string; username?: string }; + const profile = JSON.parse(profileStr) as CachedUserProfile; return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); } } catch { @@ -88,7 +92,14 @@ export default function AppLayout() { return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); }, [t]); - + const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState(() => { + try { + const profileStr = sessionStorage.getItem("userProfile"); + return profileStr ? getAvatarUrl(JSON.parse(profileStr) as CachedUserProfile) : ""; + } catch { + return ""; + } + }); const fetchInitialData = useCallback(async () => { try { const storedTenants = localStorage.getItem("availableTenants"); @@ -116,7 +127,20 @@ export default function AppLayout() { useEffect(() => { fetchInitialData(); }, [fetchInitialData]); + useEffect(() => { + const syncUserProfile = () => { + try { + const profileStr = sessionStorage.getItem("userProfile"); + if (!profileStr) return; + const profile = JSON.parse(profileStr) as CachedUserProfile; + setCurrentUserAvatarUrl(getAvatarUrl(profile)); + } catch { + } + }; + window.addEventListener("user-profile-updated", syncUserProfile); + return () => window.removeEventListener("user-profile-updated", syncUserProfile); + }, []); useEffect(() => { const syncPlatformConfig = () => { const configStr = sessionStorage.getItem("platformConfig"); @@ -299,7 +323,7 @@ export default function AppLayout() { - } style={{ backgroundColor: "var(--app-primary-color)" }} /> + } style={{ backgroundColor: "var(--app-primary-color)" }} /> {currentUserDisplayName} @@ -404,9 +428,9 @@ export default function AppLayout() { mode="horizontal" selectedKeys={[location.pathname]} items={menuItems} - style={{ - borderBottom: 0, - lineHeight: '62px', + style={{ + borderBottom: 0, + lineHeight: '62px', background: 'transparent', color: 'var(--app-text-main)' }} diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index f2bb2f4..f9a8a32 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -265,6 +265,9 @@ "botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.", "botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.", "botBindStatus": "Binding Status", + "avatarUrl": "Avatar URL", + "avatarUrlPlaceholder": "Enter avatar image URL", + "uploadAvatar": "Upload Avatar", "botBound": "Bound", "botUnbound": "Not Generated", "botSecretHidden": "Hidden. Generate or reset to get a new secret.", @@ -441,4 +444,4 @@ "layoutSide": "Side Menu", "layoutTop": "Top Menu" } -} \ No newline at end of file +} diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 91b70c3..f12aabb 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -265,6 +265,10 @@ "botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。", "botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。", "botBindStatus": "绑定状态", + "avatarUrl": "\u5934\u50cf URL", + "avatarUrlPlaceholder": "\u8bf7\u8f93\u5165\u5934\u50cf\u56fe\u7247\u5730\u5740", + "uploadAvatar": "\u4e0a\u4f20\u5934\u50cf", + "botBound": "已绑定", "botUnbound": "未生成", "botSecretHidden": "已隐藏。如需查看新的 Secret,请重新生成。", @@ -441,4 +445,4 @@ "layoutSide": "左侧菜单", "layoutTop": "顶部菜单" } -} \ No newline at end of file +} diff --git a/frontend/src/pages/access/users/index.tsx b/frontend/src/pages/access/users/index.tsx index c5cd1cb..e67a789 100644 --- a/frontend/src/pages/access/users/index.tsx +++ b/frontend/src/pages/access/users/index.tsx @@ -1,9 +1,9 @@ -import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, App } from 'antd'; +import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message,App } from "antd"; import type { DefaultOptionType } from "antd/es/select"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons"; -import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser } from "@/api"; +import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons"; +import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser, uploadPlatformAsset } from "@/api"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; import PageHeader from "@/components/shared/PageHeader"; @@ -68,6 +68,7 @@ export default function Users() { const { items: statusDict } = useDict("sys_common_status"); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [avatarUploading, setAvatarUploading] = useState(false); const [data, setData] = useState([]); const [roles, setRoles] = useState([]); const [tenants, setTenants] = useState([]); @@ -229,6 +230,18 @@ export default function Users() { loadUsersData(); }; + const handleAvatarUpload = async (file: File) => { + try { + setAvatarUploading(true); + const url = await uploadPlatformAsset(file); + form.setFieldValue("avatar_url", url); + message.success(t("common.success")); + } finally { + setAvatarUploading(false); + } + return false; + }; + const submit = async () => { const values = await form.validateFields(); setSaving(true); @@ -238,6 +251,7 @@ export default function Users() { displayName: values.displayName, email: values.email, phone: values.phone, + avatar_url: values.avatar_url, status: values.status, isPlatformAdmin: values.isPlatformAdmin }; @@ -280,9 +294,7 @@ export default function Users() { key: "user", render: (_: any, record: SysUser) => ( -
- -
+ } />
{record.displayName}
@@ -403,6 +415,10 @@ export default function Users() { + + + + + + + + + +