fix(frontend): harden auth request layer per web guide

- fix double-unwrap bug in useLogin prefs sync (theme sync silently broken)
- request layer: 401 single-flight refresh queue + debounced single redirect
  with return-url, cancel-aware silent passthrough, skipErrorHandler opt-out,
  typed blob download (replace bare fetch export in QueriesPage)
- useFetchList: AbortController + version guard + error state (three states)
- ErrorBoundary with chunk-load auto recovery (reload once)
- permission single source: derive route guard from menu constants, drop
  hand-maintained routePermissionMap; TicketDetailPage uses unified
  hasActionPermission instead of inline role string comparison
- namespaced localStorage keys (opsxsql.*) with read-old-write-new migration
- fix pre-existing tsc errors (InstancePage/WorkflowPage/QueriesPage/
  PlatformConfigPage/RedisDetailPage) - typecheck now passes clean
This commit is contained in:
Logan.Li committed 2026-10-26 17:12:29 +08:00
1 parent 0fcdbc5f6c
commit d5b8b8116c
19 files changed
+502 -110

No files matched your search

+12 -7
View File
@@ -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<string, string>)[path] || path;
const permissionPath = menuIdForPath(location.pathname);
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
// 携带当前位置,登录后回跳(useLogin 侧做同源校验防开放重定向)
const redirect = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
}
// 检查当前路径是否有权限访问
@@ -64,8 +67,9 @@ function LoginGuard() {
function AppRoutes() {
return (
<Suspense fallback={<PageFallback />}>
<Routes>
<ErrorBoundary>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<LoginGuard />} />
<Route element={<RouteGuard />}>
<Route path="/" element={<Layout />}>
@@ -92,8 +96,9 @@ function AppRoutes() {
<Route path="*" element={<Navigate to="/queries" replace />} />
</Route>
</Route>
</Routes>
</Suspense>
</Routes>
</Suspense>
</ErrorBoundary>
);
}
+10 -3
View File
@@ -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<any, LoginResponse>('/auth/login', { username, password }),
getMe: () => request.get<any, MeResponse>('/auth/me'),
logout: () => request.post('/auth/logout'),
request.post<unknown, LoginResponse>('/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<unknown, MeResponse>('/auth/me'),
};
+158 -20
View File
@@ -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<string | null> {
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<unknown> {
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<unknown>((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);
@@ -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;
+73
View File
@@ -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<Props, State> {
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 (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-2xl font-semibold text-[var(--dg-text-primary)]">
{isChunkError ? '页面资源已更新' : '页面出现错误'}
</div>
<div className="max-w-md text-sm text-[var(--dg-text-secondary)]">
{isChunkError
? '系统发布了新版本,刷新页面即可继续使用。'
: `渲染时发生异常:${this.state.message}`}
</div>
<button
type="button"
onClick={this.handleReload}
className="rounded-md border border-[var(--dg-border)] px-4 py-2 text-sm text-[var(--dg-text-primary)] transition-colors hover:bg-[var(--dg-bg-secondary)]"
>
刷新页面
</button>
</div>
);
}
}
+35 -1
View File
@@ -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<string, string> = {
redis: 'instances',
mongo: 'instances',
};
/** 菜单权限别名(历史权限码兼容) */
export const permissionAliasMap: Record<string, string[]> = {
instances: ['datasource'],
settings: ['config-platform'],
};
/** 根据路由一级路径解析所属菜单 id */
export function menuIdForPath(pathname: string): string {
const first = pathname.split('/')[1] || 'queries';
return routeToMenuId[first] || first;
}
+17 -29
View File
@@ -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<UserRole, number> = {
auditor: 40,
};
const routePermissionMap: Record<string, UserRole[]> = {
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<string, string[]> = {
instances: ['datasource'],
settings: ['config-platform'],
};
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
export const actionPermissionMap: Record<string, UserRole[]> = {
@@ -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<boolean> => {
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;
+61 -9
View File
@@ -1,24 +1,76 @@
import { useState, useEffect, useCallback, useRef } from 'react';
export function useFetchList<T>(fetchFn: () => Promise<T[]>, deps: unknown[] = []) {
export interface UseFetchListResult<T> {
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<T>(
fetchFn: (signal: AbortSignal) => Promise<T[]>,
deps: unknown[] = []
): UseFetchListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const refresh = useCallback(() => {
const abortRef = useRef<AbortController | null>(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'
);
}
+15 -5
View File
@@ -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<void>;
}
/** 只放行同源相对路径(/ 开头且非 // 开头),防开放重定向 */
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<unknown, { theme?: string; login_style?: string }>('/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 };
}
+5 -4
View File
@@ -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<ThemeContextType | null>(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);
}, []);
+79
View File
@@ -0,0 +1,79 @@
/**
* 统一的 localStorage 命名空间与迁移层。
*
* 所有持久化 key 一律从 STORAGE_KEYS 取值(`<app>.<域>.<名>` 命名空间),
* 禁止在业务代码里手写字符串 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<Record<keyof typeof STORAGE_KEYS, string>> = {
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);
}
+3 -2
View File
@@ -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 {
+1
View File
@@ -28,6 +28,7 @@ interface InstanceItem {
port: number;
username: string;
database: string | null;
access_mode?: string;
env: string;
description: string | null;
status: string;
+3 -2
View File
@@ -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));
+2 -2
View File
@@ -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<unknown, SettingsMap>('/settings');
setSettings(res);
} catch {
setToast('加载设置失败');
} finally {
+13 -13
View File
@@ -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<HTMLDivElement>(null);
// -- Sidebar 拖拽宽度 --
const [sideWidth, setSideWidth] = useState(() => Number(localStorage.getItem('query-side-width')) || 280);
const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state --
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try {
const saved = localStorage.getItem('query_favorites');
const saved = readStorage('queryFavorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
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<string, string[]> })); })
@@ -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<unknown, Blob>('/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() {
<div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined} />
schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
<span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
+5 -4
View File
@@ -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() {
</div>
<button onClick={handleCopyValue} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]" title="复制值"><Copy className="h-4 w-4" /></button>
<button onClick={handleSaveKey} disabled={savingKey} className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"><Save className="h-4 w-4" />保存</button>
<button onClick={function () { handleDeleteKey(selectedKey.key || selectedKey.name || ''); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20" title="删除 Key"><Trash2 className="h-4 w-4" /></button>
<button onClick={function () { handleDeleteKey(selectedKey?.key || selectedKey?.name || ''); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20" title="删除 Key"><Trash2 className="h-4 w-4" /></button>
</div>
</div>
@@ -456,8 +457,8 @@ export default function RedisDetailPage() {
<div>
{inspectValue && (
<div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">正在查看字段:{inspectValue.title}</span>
<button onClick={function () { copyText(typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value), '已复制字段值'); }} className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">正在查看字段:{inspectValue?.title}</span>
<button onClick={function () { const iv = inspectValue; if (!iv) return; copyText(typeof iv.value === 'string' ? iv.value : JSON.stringify(iv.value), '已复制字段值'); }} className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<Copy className="h-3.5 w-3.5" />复制
</button>
</div>
+6 -6
View File
@@ -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 */
+2 -1
View File
@@ -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 (
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">