diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19ed853..69353ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { ThemeProvider } from '@/hooks/useTheme'; import Layout from '@/components/Layout'; import LoginPage from '@/pages/LoginPage'; import { ToastContainer } from '@/components/ToastContainer'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { menuIdForPath } from '@/constants/menu'; const Dashboard = lazy(() => import('@/pages/Dashboard')); const InstancePage = lazy(() => import('@/pages/InstancePage')); @@ -37,12 +39,13 @@ function PageFallback() { function RouteGuard() { const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth(); const location = useLocation(); - const path = location.pathname.split('/')[1] || 'dashboard'; - const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record)[path] || path; + const permissionPath = menuIdForPath(location.pathname); const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries'); if (!isLoggedIn) { - return ; + // 携带当前位置,登录后回跳(useLogin 侧做同源校验防开放重定向) + const redirect = encodeURIComponent(location.pathname + location.search); + return ; } // 检查当前路径是否有权限访问 @@ -64,8 +67,9 @@ function LoginGuard() { function AppRoutes() { return ( - }> - + + }> + } /> }> }> @@ -92,8 +96,9 @@ function AppRoutes() { } /> - - + + + ); } diff --git a/frontend/src/api/auth/auth.ts b/frontend/src/api/auth/auth.ts index 6326c8f..b0c2495 100644 --- a/frontend/src/api/auth/auth.ts +++ b/frontend/src/api/auth/auth.ts @@ -1,7 +1,9 @@ +import axios from 'axios'; import request from '../request'; export interface LoginResponse { access_token: string; + refresh_token?: string; token_type: string; user: { id: number; @@ -27,8 +29,13 @@ export interface MeResponse { } export const authApi = { + /** 登录,成功返回 access_token + refresh_token 与用户信息 */ login: (username: string, password: string) => - request.post('/auth/login', { username, password }), - getMe: () => request.get('/auth/me'), - logout: () => request.post('/auth/logout'), + request.post('/auth/login', { username, password }), + /** 登出并撤销服务端 refresh token;token 参数用于本地凭证已清理时的显式撤销 */ + logout: (token?: string) => + token + ? axios.post('/api/v1/auth/logout', {}, { headers: { Authorization: `Bearer ${token}` } }) + : request.post('/auth/logout'), + getMe: () => request.get('/auth/me'), }; diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index c82e2f3..12dd977 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,13 +1,138 @@ -import axios from 'axios'; +/** + * 统一请求层(指南 03 章): + * - 一切 IO 走本实例,组件禁止直接使用 axios/fetch + * - 401 单飞刷新:并发 N 个过期请求只触发一次 /auth/refresh,其余入队等待重放 + * - 刷新失败才清会话并「防抖单次」跳转登录(携带 redirect 回跳参数) + * - 错误分类提示;取消的请求静默透传 + * - 单请求逃生口:config.skipErrorHandler = true 关闭全局错误提示 + */ +import axios, { type AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'; import { toast } from '@/hooks/useToast'; +import { readStorage, writeStorage, removeStorage } from '@/lib/storage'; + +declare module 'axios' { + export interface AxiosRequestConfig { + /** 关闭全局错误提示(登录页、轮询等由调用方自行处理错误时使用) */ + skipErrorHandler?: boolean; + /** 内部标记:已被刷新流程重放过,防止无限循环 */ + __isRetried?: boolean; + } +} + +/** 类型化的 API 错误对象 */ +export interface ApiError { + status?: number; + /** 后端 detail 字符串,或 422 校验错误数组 */ + detail?: string | Array<{ msg?: string; loc?: string[] }>; + code?: string | number; + message: string; +} const request = axios.create({ baseURL: '/api/v1', timeout: 30000, }); +/** 刷新 token 专用裸实例:不能被自己的 401 拦截逻辑拦截 */ +const refreshClient = axios.create({ baseURL: '/api/v1', timeout: 15000 }); + +/* ------------------------------------------------------------------ */ +/* Token 刷新:单飞标志 + 等待队列(并发 401 只刷一次) */ +/* ------------------------------------------------------------------ */ + +let isRefreshing = false; +let refreshQueue: Array<(token: string | null) => void> = []; + +function extractDetail(data: unknown): string | undefined { + if (data && typeof data === 'object' && 'detail' in data) { + const detail = (data as { detail: unknown }).detail; + if (typeof detail === 'string') return detail; + } + return undefined; +} + +/** 登录跳转防抖:并发 N 个 401 失败只产生一次跳转 */ +let redirectingToLogin = false; + +export function redirectToLogin(): void { + if (redirectingToLogin) return; + redirectingToLogin = true; + const current = window.location.pathname + window.location.search; + const target = window.location.pathname.startsWith('/login') + ? '/login' + : `/login?redirect=${encodeURIComponent(current)}`; + removeStorage('token'); + removeStorage('refreshToken'); + window.location.href = target; +} + +async function doRefresh(): Promise { + const refreshToken = readStorage('refreshToken'); + if (!refreshToken) return null; + try { + const res = await refreshClient.post('/auth/refresh', { refresh_token: refreshToken }); + const token = res.data?.access_token; + if (!token) return null; + writeStorage('token', token); + if (res.data?.refresh_token) { + writeStorage('refreshToken', res.data.refresh_token); + } + return token; + } catch { + return null; + } +} + +/** 尝试刷新并重放原请求;失败时 reject 并单次跳登录 */ +async function refreshAndRetry(error: AxiosError): Promise { + const original = error.config as (InternalAxiosRequestConfig & { __isRetried?: boolean }) | undefined; + + // 刷新接口自身的 401、已重放过的请求、或显式免刷新的请求 → 直接失败 + if (!original || original.__isRetried || original.skipErrorHandler) { + redirectToLogin(); + return Promise.reject(error); + } + + if (isRefreshing) { + // 单飞进行中:入队等待新 token 后重放 + return new Promise((resolve, reject) => { + refreshQueue.push((token) => { + if (!token) { + reject(error); + return; + } + original.headers.Authorization = `Bearer ${token}`; + original.__isRetried = true; + resolve(request(original)); + }); + }); + } + + isRefreshing = true; + const newToken = await doRefresh(); + isRefreshing = false; + + // 唤醒队列中所有等待者 + const queue = refreshQueue; + refreshQueue = []; + queue.forEach((cb) => cb(newToken)); + + if (!newToken) { + redirectToLogin(); + return Promise.reject(error); + } + + original.headers.Authorization = `Bearer ${newToken}`; + original.__isRetried = true; + return request(original); +} + +/* ------------------------------------------------------------------ */ +/* 拦截器 */ +/* ------------------------------------------------------------------ */ + request.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); + const token = readStorage('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -18,29 +143,42 @@ request.interceptors.response.use( (response) => { return response.data; }, - (error) => { + async (error: AxiosError) => { + // 被取消的请求静默透传(列表页切换筛选时的旧请求) + if (axios.isCancel(error) || error.code === 'ERR_CANCELED') { + return Promise.reject(error); + } + const status = error.response?.status; - const detail = error.response?.data?.detail; - const msg = detail || error.message || '未知错误'; + const detail = error.response?.data && (error.response.data as { detail?: unknown }).detail; + const config = error.config as AxiosRequestConfig | undefined; + const silent = config?.skipErrorHandler === true; if (status === 401) { - localStorage.removeItem('token'); - window.location.href = '/login'; - } else if (status === 429) { - toast('warning', msg); - } else if (status === 422) { - // 验证错误 — 取第一个错误信息 - const errors = error.response?.data?.detail; - if (Array.isArray(errors)) { - const first = errors[0]; - toast('error', first?.msg || '请求参数错误'); + try { + return await refreshAndRetry(error); + } catch (err) { + // 刷新失败已由 redirectToLogin 统一处理,这里不再弹错误 + return Promise.reject(err); + } + } + + if (!silent) { + if (status === 429) { + toast('warning', extractDetail(error.response?.data) || error.message || '请求过于频繁'); + } else if (status === 422) { + // 校验错误 — 取第一个错误信息 + if (Array.isArray(detail)) { + toast('error', detail[0]?.msg || '请求参数错误'); + } else { + toast('error', typeof detail === 'string' ? detail : '请求参数错误'); + } + } else if (status && status >= 400) { + const msg = extractDetail(error.response?.data) || error.message || '未知错误'; + toast('error', msg); } else { - toast('error', typeof errors === 'string' ? errors : '请求参数错误'); + toast('error', '网络连接失败'); } - } else if (status && status >= 400) { - toast('error', msg); - } else { - toast('error', '网络连接失败'); } return Promise.reject(error); diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 0d247d5..cf50da1 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -13,9 +13,9 @@ interface InstanceData { host: string; port: number; username: string; - database: string; + database: string | null; env: string; - access_mode: string; + access_mode?: string; workflow_id?: number | null; workflow_name?: string | null; excluded_databases?: string[] | null; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..fd36399 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,73 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { STORAGE_KEYS } from '@/lib/storage'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + message: string; +} + +/** 发版后旧 hash chunk 404 等动态 import 失败特征 */ +const CHUNK_ERROR_RE = /dynamically imported module|Importing a module script failed|error loading dynamically imported module/i; + +/** + * 全局错误边界(指南 04/08 章): + * - chunk 加载失败 → 自动 reload 一次(sessionStorage 标记防循环),恢复发版窗口期白屏 + * - 其他渲染错误 → 错误占位 + 重试按钮(不是 console.error 完事) + */ +export class ErrorBoundary extends Component { + state: State = { hasError: false, message: '' }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, message: error?.message || String(error) }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[ErrorBoundary]', error, info.componentStack); + // chunk 失败自动恢复:只 reload 一次,防循环刷新 + if (CHUNK_ERROR_RE.test(error?.message || '')) { + try { + if (sessionStorage.getItem(STORAGE_KEYS.chunkReloaded) !== '1') { + sessionStorage.setItem(STORAGE_KEYS.chunkReloaded, '1'); + window.location.reload(); + } + } catch { /* noop */ } + } + } + + handleReload = (): void => { + try { + sessionStorage.removeItem(STORAGE_KEYS.chunkReloaded); + } catch { /* noop */ } + window.location.reload(); + }; + + render(): ReactNode { + if (!this.state.hasError) return this.props.children; + + const isChunkError = CHUNK_ERROR_RE.test(this.state.message); + + return ( +
+
+ {isChunkError ? '页面资源已更新' : '页面出现错误'} +
+
+ {isChunkError + ? '系统发布了新版本,刷新页面即可继续使用。' + : `渲染时发生异常:${this.state.message}`} +
+ +
+ ); + } +} diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts index 40901b8..553e84d 100644 --- a/frontend/src/constants/menu.ts +++ b/frontend/src/constants/menu.ts @@ -1,3 +1,19 @@ +/** + * 菜单与路由权限的唯一源真相(指南 04 章:meta/权限单源,禁止双份人工同步)。 + * 菜单项 roles 即路由级访问控制依据;useAuth/App 守卫均从这里派生。 + */ + +export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor'; + +export interface MenuItem { + id: string; + label: string; + icon: string; + section: string; + roles: UserRole[]; + permission: string; +} + export const menuSections = [ { id: 'workspace', label: '工作台' }, { id: 'change', label: '数据变更' }, @@ -8,7 +24,7 @@ export const menuSections = [ { id: 'system', label: '系统治理' }, ]; -export const menuItems = [ +export const menuItems: MenuItem[] = [ // 工作台 { id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' }, @@ -37,3 +53,21 @@ export const menuItems = [ { id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' }, { id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' }, ]; + +/** 路由一级路径 → 菜单/权限 id 的映射(详情页等非菜单路由归属其权限域) */ +export const routeToMenuId: Record = { + redis: 'instances', + mongo: 'instances', +}; + +/** 菜单权限别名(历史权限码兼容) */ +export const permissionAliasMap: Record = { + instances: ['datasource'], + settings: ['config-platform'], +}; + +/** 根据路由一级路径解析所属菜单 id */ +export function menuIdForPath(pathname: string): string { + const first = pathname.split('/')[1] || 'queries'; + return routeToMenuId[first] || first; +} diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 1bdb11d..a8258fb 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { menuItems } from '@/constants/menu'; +import { menuItems, permissionAliasMap, menuIdForPath, type UserRole } from '@/constants/menu'; import { instanceApi } from '@/api/instance'; +import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage'; const defaultUser = { id: 0, @@ -12,7 +13,7 @@ const defaultUser = { }; import { authApi } from '@/api/auth'; -export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor'; +export type { UserRole }; /** 细粒度权限类型 */ export type PermissionAction = @@ -110,28 +111,6 @@ const roleWeightMap: Record = { auditor: 40, }; -const routePermissionMap: Record = { - dashboard: ['admin', 'dba', 'developer', 'auditor'], - instances: ['admin', 'dba'], - 'instance-groups': ['admin'], - tickets: ['admin', 'dba', 'developer'], - queries: ['admin', 'dba', 'developer'], - roles: ['admin'], - users: ['admin'], - 'audit-rules': ['admin', 'dba', 'auditor'], - 'audit-logs': ['admin', 'dba', 'auditor'], - workflow: ['admin', 'dba', 'developer', 'auditor'], - 'auto-task': ['admin'], - notifications: ['admin'], - announcement: ['admin'], - settings: ['admin'], -}; - -const permissionAliasMap: Record = { - instances: ['datasource'], - settings: ['config-platform'], -}; - const dataChangePermissionIds = new Set(['tickets', 'workflow']); export const actionPermissionMap: Record = { @@ -177,7 +156,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // 从 token 恢复基本用户信息(页面刷新时) const [isLoggedIn, setIsLoggedIn] = useState(() => { - const token = localStorage.getItem('token'); + const token = readStorage('token'); if (!token) return false; try { const payload = JSON.parse(atob(token.split('.')[1])); @@ -238,7 +217,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { /** 注册权限变更监听器 */ useEffect(() => { const handleStorageChange = (e: StorageEvent) => { - if (e.key === 'token') { + if (e.key === STORAGE_KEYS.token) { if (!e.newValue) { setIsLoggedIn(false); clearPermissionCache(); @@ -289,7 +268,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback(async (username: string, password: string): Promise => { try { const res = await authApi.login(username, password); - localStorage.setItem('token', res.access_token); + writeStorage('token', res.access_token); + if (res.refresh_token) { + writeStorage('refreshToken', res.refresh_token); + } const u = res.user; const role = u.role as UserRole; @@ -326,7 +308,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [clearPermissionCache, emitPermissionChange]); const logout = useCallback(() => { - localStorage.removeItem('token'); + // 先取 token 再清理:服务端撤销 refresh token 需要它 + const token = readStorage('token'); + removeStorage('token'); + removeStorage('refreshToken'); + if (token) { + authApi.logout(token).catch(() => { /* 服务端撤销失败不阻塞本地登出 */ }); + } setIsLoggedIn(false); clearPermissionCache(); setUser({ @@ -369,7 +357,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId); - const allowedRoles = menuItem?.roles || routePermissionMap[menuId]; + const allowedRoles = menuItem?.roles; if (!allowedRoles) { cache.menu.set(menuId, false); return false; diff --git a/frontend/src/hooks/useFetchList.ts b/frontend/src/hooks/useFetchList.ts index 4862fc6..df956d9 100644 --- a/frontend/src/hooks/useFetchList.ts +++ b/frontend/src/hooks/useFetchList.ts @@ -1,24 +1,76 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -export function useFetchList(fetchFn: () => Promise, deps: unknown[] = []) { +export interface UseFetchListResult { + data: T[]; + loading: boolean; + /** 非空时表示最近一次加载失败(取消不算错误);页面必须渲染错误占位 */ + error: string | null; + refresh: () => void; +} + +/** + * 列表数据获取标准 hook(指南 03 章 5.2): + * - AbortController 取消旧请求 + 版本号令牌双重防护,杜绝筛选/翻页快速切换时的过期响应错序覆盖 + * - 三态齐全:loading / 空 data / error(页面须渲染错误占位与重试) + * + * @param fetchFn 数据源函数,应把 signal 透传给请求层(axios config.signal) + * @param deps 触发重新加载的依赖(筛选/分页等状态) + */ +export function useFetchList( + fetchFn: (signal: AbortSignal) => Promise, + deps: unknown[] = [] +): UseFetchListResult { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const fetchFnRef = useRef(fetchFn); fetchFnRef.current = fetchFn; - const refresh = useCallback(() => { + const abortRef = useRef(null); + const versionRef = useRef(0); + + const load = useCallback(() => { + // 取消上一个在途请求,并让在先的响应即使到达也会因版本过期被丢弃 + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const currentVersion = ++versionRef.current; + setLoading(true); - fetchFnRef - .current() - .then((res) => setData(res)) - .catch(() => setData([])) - .finally(() => setLoading(false)); + setError(null); + + fetchFnRef.current(controller.signal) + .then((res) => { + if (currentVersion !== versionRef.current || controller.signal.aborted) return; + setData(res); + setLoading(false); + }) + .catch((err: unknown) => { + if (currentVersion !== versionRef.current) return; + if (isAbortError(err)) return; // 被新请求取代,静默 + const detail = + (err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail; + setError(typeof detail === 'string' ? detail : (err as Error)?.message || '加载失败'); + setLoading(false); + }); }, []); useEffect(() => { - refresh(); + load(); + return () => { + abortRef.current?.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); - return { data, loading, refresh }; + return { data, loading, error, refresh: load }; +} + +function isAbortError(err: unknown): boolean { + return ( + (err as { name?: string })?.name === 'CanceledError' || + (err as { name?: string })?.name === 'AbortError' || + (err as { code?: string })?.code === 'ERR_CANCELED' + ); } diff --git a/frontend/src/hooks/useLogin.tsx b/frontend/src/hooks/useLogin.tsx index 70e570b..2a4ab3c 100644 --- a/frontend/src/hooks/useLogin.tsx +++ b/frontend/src/hooks/useLogin.tsx @@ -1,8 +1,9 @@ import { useState, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme'; import api from '@/api/request'; +import { writeStorage } from '@/lib/storage'; export interface LoginState { username: string; @@ -14,8 +15,15 @@ export interface LoginState { handleLogin: (e: React.FormEvent) => Promise; } +/** 只放行同源相对路径(/ 开头且非 // 开头),防开放重定向 */ +function getSafeRedirect(redirect: string | null): string | null { + if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null; + return redirect; +} + export function useLogin(): LoginState { const navigate = useNavigate(); + const location = useLocation(); const { login } = useAuth(); const { setTheme } = useTheme(); const [username, setUsername] = useState(''); @@ -32,7 +40,8 @@ export function useLogin(): LoginState { if (ok) { // Apply user preferences from backend (cross-browser sync) try { - const { data: prefs } = await api.get<{ theme?: string; login_style?: string }>('/user/preferences'); + // 拦截器已解包一层:响应本体即 { theme, login_style } + const prefs = await api.get('/user/preferences'); if (prefs?.theme) { const t = prefs.theme; if (t === 'system') { @@ -43,14 +52,15 @@ export function useLogin(): LoginState { setTheme(t as Theme); } } - if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style); + if (prefs?.login_style) writeStorage('loginStyle', prefs.login_style); } catch { /* ignore */ } - navigate('/queries'); + const redirect = getSafeRedirect(new URLSearchParams(location.search).get('redirect')); + navigate(redirect || '/queries'); } else { setError('用户名或密码错误'); } setLoading(false); - }, [username, password, login, navigate, setTheme]); + }, [username, password, login, navigate, setTheme, location.search]); return { username, password, loading, error, setUsername, setPassword, handleLogin }; } diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 7ad42ad..509dd64 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react'; +import { readStorage, writeStorage } from '@/lib/storage'; export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green'; @@ -38,11 +39,11 @@ const ThemeContext = createContext(null); function getInitialTheme(): Theme { if (typeof window === 'undefined') return DEFAULT_THEME; - const stored = localStorage.getItem('dg-theme'); + const stored = readStorage('theme'); const migrated = migrateLegacyTheme(stored); if (migrated) { // Persist the migrated value so we don't migrate every time - localStorage.setItem('dg-theme', migrated); + writeStorage('theme', migrated); return migrated; } if (stored && THEME_ORDER.includes(stored as Theme)) { @@ -79,13 +80,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { setThemeState(prev => { const idx = THEME_ORDER.indexOf(prev); const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length]; - localStorage.setItem('dg-theme', next); + writeStorage('theme', next); return next; }); }, []); const setTheme = useCallback((t: Theme) => { - localStorage.setItem('dg-theme', t); + writeStorage('theme', t); setThemeState(t); }, []); diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..02568ef --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,79 @@ +/** + * 统一的 localStorage 命名空间与迁移层。 + * + * 所有持久化 key 一律从 STORAGE_KEYS 取值(`.<域>.<名>` 命名空间), + * 禁止在业务代码里手写字符串 key。历史无命名空间 key 仅在此处做 + * 「读旧写新」迁移,迁移期结束后删除 LEGACY_KEYS。 + */ + +const read = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +const write = (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + /* 存储不可用(隐私模式等)时静默降级为内存态 */ + } +}; + +const remove = (key: string): void => { + try { + localStorage.removeItem(key); + } catch { + /* noop */ + } +}; + +export const STORAGE_KEYS = { + token: 'opsxsql.auth.token', + refreshToken: 'opsxsql.auth.refresh', + theme: 'opsxsql.theme.mode', + loginStyle: 'opsxsql.ui.loginStyle', + queryFavorites: 'opsxsql.query.favorites', + querySideWidth: 'opsxsql.query.sideWidth', + chunkReloaded: 'opsxsql.app.chunkReloaded', +} as const; + +/** 历史无命名空间 key → 新 key(仅用于一次性迁移读取) */ +const LEGACY_KEYS: Partial> = { + token: 'token', + theme: 'dg-theme', + loginStyle: 'login-style', + queryFavorites: 'query_favorites', + querySideWidth: 'query-side-width', +}; + +/** 读取(新 key 优先,回退旧 key 并完成迁移写入) */ +export function readStorage(key: keyof typeof STORAGE_KEYS): string | null { + const current = read(STORAGE_KEYS[key]); + if (current !== null) return current; + + const legacyKey = LEGACY_KEYS[key]; + if (legacyKey) { + const legacy = read(legacyKey); + if (legacy !== null) { + write(STORAGE_KEYS[key], legacy); + remove(legacyKey); + return legacy; + } + } + return null; +} + +/** 写入(只写新 key) */ +export function writeStorage(key: keyof typeof STORAGE_KEYS, value: string): void { + write(STORAGE_KEYS[key], value); +} + +/** 删除(新旧 key 一起清理) */ +export function removeStorage(key: keyof typeof STORAGE_KEYS): void { + remove(STORAGE_KEYS[key]); + const legacyKey = LEGACY_KEYS[key]; + if (legacyKey) remove(legacyKey); +} diff --git a/frontend/src/pages/AppearanceSettings.tsx b/frontend/src/pages/AppearanceSettings.tsx index 92ba75c..f2d39fa 100644 --- a/frontend/src/pages/AppearanceSettings.tsx +++ b/frontend/src/pages/AppearanceSettings.tsx @@ -3,6 +3,7 @@ import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme'; import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage'; import { Check, Save, Loader2 } from 'lucide-react'; import api from '@/api/request'; +import { readStorage, writeStorage } from '@/lib/storage'; import { toast } from '@/hooks/useToast'; // Login style preview color themes (independent from app theme) @@ -43,7 +44,7 @@ export default function AppearanceSettings() { if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle); }) .catch(() => { - const stored = localStorage.getItem('login-style') as LoginStyle | null; + const stored = readStorage('loginStyle') as LoginStyle | null; if (stored) setLoginStyle(stored); }) .finally(() => setLoading(false)); @@ -66,7 +67,7 @@ export default function AppearanceSettings() { theme, login_style: loginStyle, }); - localStorage.setItem('login-style', loginStyle); + writeStorage('loginStyle', loginStyle); setDirty(false); toast('success', '外观设置已保存'); } catch { diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index fcfbf24..8504049 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -28,6 +28,7 @@ interface InstanceItem { port: number; username: string; database: string | null; + access_mode?: string; env: string; description: string | null; status: string; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ed14033..5229aa2 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -10,6 +10,7 @@ import InkWashView from '@/components/login/InkWashView'; import NeonCityView from '@/components/login/NeonCityView'; import GradientMeshView from '@/components/login/GradientMeshView'; import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme'; +import { readStorage } from '@/lib/storage'; export type LoginStyle = 'sidepanel' | 'split' | 'centered' | 'fullimage' | 'minimal' | 'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh'; @@ -61,12 +62,12 @@ export default function LoginPage() { if (s && s in LOGIN_STYLES) { setStyle(s); } else { - const stored = localStorage.getItem('login-style') as LoginStyle | null; + const stored = readStorage('loginStyle') as LoginStyle | null; if (stored && stored in LOGIN_STYLES) setStyle(stored); } }) .catch(() => { - const stored = localStorage.getItem('login-style') as LoginStyle | null; + const stored = readStorage('loginStyle') as LoginStyle | null; if (stored && stored in LOGIN_STYLES) setStyle(stored); }) .finally(() => setReady(true)); diff --git a/frontend/src/pages/PlatformConfigPage.tsx b/frontend/src/pages/PlatformConfigPage.tsx index 4e7796a..046f82a 100644 --- a/frontend/src/pages/PlatformConfigPage.tsx +++ b/frontend/src/pages/PlatformConfigPage.tsx @@ -107,8 +107,8 @@ export default function PlatformConfigPage() { const fetchSettings = useCallback(async () => { try { - const res = await api.get('/settings'); - setSettings(res as SettingsMap); + const res = await api.get('/settings'); + setSettings(res); } catch { setToast('加载设置失败'); } finally { diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index b640d7b..806a597 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -26,6 +26,8 @@ import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDraw import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel'; import type { QueryHistoryItem } from '@/features/query/types'; import { downloadCSV, exportToCSV } from '@/features/query/utils'; +import api from '@/api/request'; +import { readStorage, writeStorage } from '@/lib/storage'; interface QueryResult { columns: string[]; @@ -136,13 +138,13 @@ export default function QueriesPage() { const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const resultContainerRef = useRef(null); // -- Sidebar 拖拽宽度 -- - const [sideWidth, setSideWidth] = useState(() => Number(localStorage.getItem('query-side-width')) || 280); + const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280); const sidebarDragRef = useRef(null); // -- 历史 state -- const [history, setHistory] = useState(() => { try { - const saved = localStorage.getItem('query_favorites'); + const saved = readStorage('queryFavorites'); const favIds = saved ? new Set(JSON.parse(saved)) : new Set(); const initialHistory: QueryHistoryItem[] = []; return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) })); @@ -237,7 +239,7 @@ export default function QueriesPage() { }, []); useEffect(() => { - if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return; + if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return; if (/^\d+$/.test(database)) return; instanceDetailApi.getDbSchema(Number(instanceId), database) .then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record })); }) @@ -330,13 +332,11 @@ export default function QueriesPage() { const handleExportResult = useCallback(async () => { if (!sql || !instanceId) return; try { - const resp = await fetch('/api/v1/query/export', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, - body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }), - }); - if (!resp.ok) throw new Error('导出失败'); - const blob = await resp.blob(); + const blob = await api.post('/query/export', { + instance_id: Number(instanceId), + database: database || undefined, + sql, + }, { responseType: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -364,7 +364,7 @@ export default function QueriesPage() { const toggleFavorite = useCallback((id: number) => { setHistory(prev => { const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); - try { localStorage.setItem('query_favorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } + try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } return next; }); }, []); @@ -388,7 +388,7 @@ export default function QueriesPage() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); setSideWidth(w => { - localStorage.setItem('query-side-width', String(w)); + writeStorage('querySideWidth', String(w)); return w; }); }; @@ -634,7 +634,7 @@ export default function QueriesPage() {
+ schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
{dbTypeIcon[dbType] && } diff --git a/frontend/src/pages/RedisDetailPage.tsx b/frontend/src/pages/RedisDetailPage.tsx index ced0bd3..382692e 100644 --- a/frontend/src/pages/RedisDetailPage.tsx +++ b/frontend/src/pages/RedisDetailPage.tsx @@ -191,8 +191,9 @@ export default function RedisDetailPage() { var handleDeleteHashLine = function (field: string) { if (!selectedKey || selectedKey.type !== 'hash') return; + const keySnapshot = selectedKey; setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => { - var nextValue = Object.assign({}, selectedKey.value || {}); + var nextValue = Object.assign({}, keySnapshot.value || {}); delete nextValue[field]; updateHashValue(nextValue, 'Hash 字段已删除'); }}); @@ -407,7 +408,7 @@ export default function RedisDetailPage() {
- +
@@ -456,8 +457,8 @@ export default function RedisDetailPage() {
{inspectValue && (
- 正在查看字段:{inspectValue.title} -
diff --git a/frontend/src/pages/TicketDetailPage.tsx b/frontend/src/pages/TicketDetailPage.tsx index d8256a1..90b1465 100644 --- a/frontend/src/pages/TicketDetailPage.tsx +++ b/frontend/src/pages/TicketDetailPage.tsx @@ -221,20 +221,20 @@ export default function TicketDetailPage() { /* Permission checks */ /* ---------------------------------------------------------------- */ + const { hasActionPermission } = auth; const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => { if (!ticket) { return { canApprove: false, canExecute: false, canRollback: false, canCancel: false }; } - const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba'; - const canApproveRole = adminOrDba || auth.user.role === 'developer'; + // 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态 const applicant = ticket.applicant_id === auth.user.id; return { - canApprove: canApproveRole && (ticket.status === 'pending' || ticket.status === 'reviewing'), - canExecute: adminOrDba && ticket.status === 'approved', - canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'), + canApprove: hasActionPermission('ticket.approve') && (ticket.status === 'pending' || ticket.status === 'reviewing'), + canExecute: hasActionPermission('ticket.execute') && ticket.status === 'approved', + canRollback: hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'), canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'), }; - }, [ticket, auth.user.role, auth.user.id]); + }, [ticket, auth.user.id, hasActionPermission]); /* ---------------------------------------------------------------- */ /* Parse execute result for visualization */ diff --git a/frontend/src/pages/WorkflowPage.tsx b/frontend/src/pages/WorkflowPage.tsx index f098406..1e17ac7 100644 --- a/frontend/src/pages/WorkflowPage.tsx +++ b/frontend/src/pages/WorkflowPage.tsx @@ -42,6 +42,7 @@ interface UserItem { username: string; display_name: string | null; role: string; + is_active?: boolean; } interface StepDraft { @@ -668,7 +669,7 @@ export default function WorkflowPage() { {(wf.steps || []).map((step, i) => { const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield; let uids: number[] = []; - try { const parsed = JSON.parse(step.approver_ids); if (Array.isArray(parsed)) uids = parsed; } catch {} + try { const parsed = step.approver_ids ? JSON.parse(step.approver_ids) : null; if (Array.isArray(parsed)) uids = parsed; } catch {} const stepsArr = wf.steps || []; return (