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:
1 parent
0fcdbc5f6c
commit
d5b8b8116c
19 files changed
+493
-101
No files matched your search
@@ -5,6 +5,8 @@ import { ThemeProvider } from '@/hooks/useTheme';
|
|||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import { ToastContainer } from '@/components/ToastContainer';
|
import { ToastContainer } from '@/components/ToastContainer';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import { menuIdForPath } from '@/constants/menu';
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('@/pages/Dashboard'));
|
const Dashboard = lazy(() => import('@/pages/Dashboard'));
|
||||||
const InstancePage = lazy(() => import('@/pages/InstancePage'));
|
const InstancePage = lazy(() => import('@/pages/InstancePage'));
|
||||||
@@ -37,12 +39,13 @@ function PageFallback() {
|
|||||||
function RouteGuard() {
|
function RouteGuard() {
|
||||||
const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth();
|
const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
const permissionPath = menuIdForPath(location.pathname);
|
||||||
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
|
|
||||||
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
|
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to="/login" replace />;
|
// 携带当前位置,登录后回跳(useLogin 侧做同源校验防开放重定向)
|
||||||
|
const redirect = encodeURIComponent(location.pathname + location.search);
|
||||||
|
return <Navigate to={`/login?redirect=${redirect}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查当前路径是否有权限访问
|
// 检查当前路径是否有权限访问
|
||||||
@@ -64,6 +67,7 @@ function LoginGuard() {
|
|||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<PageFallback />}>
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginGuard />} />
|
<Route path="/login" element={<LoginGuard />} />
|
||||||
@@ -94,6 +98,7 @@ function AppRoutes() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import request from '../request';
|
import request from '../request';
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,8 +29,13 @@ export interface MeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
/** 登录,成功返回 access_token + refresh_token 与用户信息 */
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
request.post<any, LoginResponse>('/auth/login', { username, password }),
|
request.post<unknown, LoginResponse>('/auth/login', { username, password }),
|
||||||
getMe: () => request.get<any, MeResponse>('/auth/me'),
|
/** 登出并撤销服务端 refresh token;token 参数用于本地凭证已清理时的显式撤销 */
|
||||||
logout: () => request.post('/auth/logout'),
|
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'),
|
||||||
};
|
};
|
||||||
+153
-15
@@ -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 { 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({
|
const request = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
timeout: 30000,
|
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) => {
|
request.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = readStorage('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@@ -18,30 +143,43 @@ request.interceptors.response.use(
|
|||||||
(response) => {
|
(response) => {
|
||||||
return response.data;
|
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 status = error.response?.status;
|
||||||
const detail = error.response?.data?.detail;
|
const detail = error.response?.data && (error.response.data as { detail?: unknown }).detail;
|
||||||
const msg = detail || error.message || '未知错误';
|
const config = error.config as AxiosRequestConfig | undefined;
|
||||||
|
const silent = config?.skipErrorHandler === true;
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
localStorage.removeItem('token');
|
try {
|
||||||
window.location.href = '/login';
|
return await refreshAndRetry(error);
|
||||||
} else if (status === 429) {
|
} catch (err) {
|
||||||
toast('warning', msg);
|
// 刷新失败已由 redirectToLogin 统一处理,这里不再弹错误
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
if (status === 429) {
|
||||||
|
toast('warning', extractDetail(error.response?.data) || error.message || '请求过于频繁');
|
||||||
} else if (status === 422) {
|
} else if (status === 422) {
|
||||||
// 验证错误 — 取第一个错误信息
|
// 校验错误 — 取第一个错误信息
|
||||||
const errors = error.response?.data?.detail;
|
if (Array.isArray(detail)) {
|
||||||
if (Array.isArray(errors)) {
|
toast('error', detail[0]?.msg || '请求参数错误');
|
||||||
const first = errors[0];
|
|
||||||
toast('error', first?.msg || '请求参数错误');
|
|
||||||
} else {
|
} else {
|
||||||
toast('error', typeof errors === 'string' ? errors : '请求参数错误');
|
toast('error', typeof detail === 'string' ? detail : '请求参数错误');
|
||||||
}
|
}
|
||||||
} else if (status && status >= 400) {
|
} else if (status && status >= 400) {
|
||||||
|
const msg = extractDetail(error.response?.data) || error.message || '未知错误';
|
||||||
toast('error', msg);
|
toast('error', msg);
|
||||||
} else {
|
} else {
|
||||||
toast('error', '网络连接失败');
|
toast('error', '网络连接失败');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ interface InstanceData {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
database: string;
|
database: string | null;
|
||||||
env: string;
|
env: string;
|
||||||
access_mode: string;
|
access_mode?: string;
|
||||||
workflow_id?: number | null;
|
workflow_id?: number | null;
|
||||||
workflow_name?: string | null;
|
workflow_name?: string | null;
|
||||||
excluded_databases?: string[] | null;
|
excluded_databases?: string[] | null;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [
|
export const menuSections = [
|
||||||
{ id: 'workspace', label: '工作台' },
|
{ id: 'workspace', label: '工作台' },
|
||||||
{ id: 'change', label: '数据变更' },
|
{ id: 'change', label: '数据变更' },
|
||||||
@@ -8,7 +24,7 @@ export const menuSections = [
|
|||||||
{ id: 'system', label: '系统治理' },
|
{ id: 'system', label: '系统治理' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const menuItems = [
|
export const menuItems: MenuItem[] = [
|
||||||
// 工作台
|
// 工作台
|
||||||
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' },
|
{ 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: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' },
|
||||||
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' },
|
{ 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
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 { instanceApi } from '@/api/instance';
|
||||||
|
import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage';
|
||||||
|
|
||||||
const defaultUser = {
|
const defaultUser = {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -12,7 +13,7 @@ const defaultUser = {
|
|||||||
};
|
};
|
||||||
import { authApi } from '@/api/auth';
|
import { authApi } from '@/api/auth';
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor';
|
export type { UserRole };
|
||||||
|
|
||||||
/** 细粒度权限类型 */
|
/** 细粒度权限类型 */
|
||||||
export type PermissionAction =
|
export type PermissionAction =
|
||||||
@@ -110,28 +111,6 @@ const roleWeightMap: Record<UserRole, number> = {
|
|||||||
auditor: 40,
|
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']);
|
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
|
||||||
|
|
||||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||||
@@ -177,7 +156,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// 从 token 恢复基本用户信息(页面刷新时)
|
// 从 token 恢复基本用户信息(页面刷新时)
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(() => {
|
const [isLoggedIn, setIsLoggedIn] = useState(() => {
|
||||||
const token = localStorage.getItem('token');
|
const token = readStorage('token');
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
@@ -238,7 +217,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
/** 注册权限变更监听器 */
|
/** 注册权限变更监听器 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorageChange = (e: StorageEvent) => {
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
if (e.key === 'token') {
|
if (e.key === STORAGE_KEYS.token) {
|
||||||
if (!e.newValue) {
|
if (!e.newValue) {
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
clearPermissionCache();
|
clearPermissionCache();
|
||||||
@@ -289,7 +268,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await authApi.login(username, password);
|
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 u = res.user;
|
||||||
const role = u.role as UserRole;
|
const role = u.role as UserRole;
|
||||||
@@ -326,7 +308,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [clearPermissionCache, emitPermissionChange]);
|
}, [clearPermissionCache, emitPermissionChange]);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
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);
|
setIsLoggedIn(false);
|
||||||
clearPermissionCache();
|
clearPermissionCache();
|
||||||
setUser({
|
setUser({
|
||||||
@@ -369,7 +357,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
|
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
|
||||||
const allowedRoles = menuItem?.roles || routePermissionMap[menuId];
|
const allowedRoles = menuItem?.roles;
|
||||||
if (!allowedRoles) {
|
if (!allowedRoles) {
|
||||||
cache.menu.set(menuId, false);
|
cache.menu.set(menuId, false);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,24 +1,76 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
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 [data, setData] = useState<T[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchFnRef = useRef(fetchFn);
|
const fetchFnRef = useRef(fetchFn);
|
||||||
fetchFnRef.current = 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);
|
setLoading(true);
|
||||||
fetchFnRef
|
setError(null);
|
||||||
.current()
|
|
||||||
.then((res) => setData(res))
|
fetchFnRef.current(controller.signal)
|
||||||
.catch(() => setData([]))
|
.then((res) => {
|
||||||
.finally(() => setLoading(false));
|
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(() => {
|
useEffect(() => {
|
||||||
refresh();
|
load();
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, 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'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
||||||
import api from '@/api/request';
|
import api from '@/api/request';
|
||||||
|
import { writeStorage } from '@/lib/storage';
|
||||||
|
|
||||||
export interface LoginState {
|
export interface LoginState {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -14,8 +15,15 @@ export interface LoginState {
|
|||||||
handleLogin: (e: React.FormEvent) => Promise<void>;
|
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 {
|
export function useLogin(): LoginState {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -32,7 +40,8 @@ export function useLogin(): LoginState {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
// Apply user preferences from backend (cross-browser sync)
|
// Apply user preferences from backend (cross-browser sync)
|
||||||
try {
|
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) {
|
if (prefs?.theme) {
|
||||||
const t = prefs.theme;
|
const t = prefs.theme;
|
||||||
if (t === 'system') {
|
if (t === 'system') {
|
||||||
@@ -43,14 +52,15 @@ export function useLogin(): LoginState {
|
|||||||
setTheme(t as Theme);
|
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 */ }
|
} catch { /* ignore */ }
|
||||||
navigate('/queries');
|
const redirect = getSafeRedirect(new URLSearchParams(location.search).get('redirect'));
|
||||||
|
navigate(redirect || '/queries');
|
||||||
} else {
|
} else {
|
||||||
setError('用户名或密码错误');
|
setError('用户名或密码错误');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [username, password, login, navigate, setTheme]);
|
}, [username, password, login, navigate, setTheme, location.search]);
|
||||||
|
|
||||||
return { username, password, loading, error, setUsername, setPassword, handleLogin };
|
return { username, password, loading, error, setUsername, setPassword, handleLogin };
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
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';
|
export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green';
|
||||||
|
|
||||||
@@ -38,11 +39,11 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
|
|||||||
|
|
||||||
function getInitialTheme(): Theme {
|
function getInitialTheme(): Theme {
|
||||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||||
const stored = localStorage.getItem('dg-theme');
|
const stored = readStorage('theme');
|
||||||
const migrated = migrateLegacyTheme(stored);
|
const migrated = migrateLegacyTheme(stored);
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
// Persist the migrated value so we don't migrate every time
|
// Persist the migrated value so we don't migrate every time
|
||||||
localStorage.setItem('dg-theme', migrated);
|
writeStorage('theme', migrated);
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
||||||
@@ -79,13 +80,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setThemeState(prev => {
|
setThemeState(prev => {
|
||||||
const idx = THEME_ORDER.indexOf(prev);
|
const idx = THEME_ORDER.indexOf(prev);
|
||||||
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
||||||
localStorage.setItem('dg-theme', next);
|
writeStorage('theme', next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTheme = useCallback((t: Theme) => {
|
const setTheme = useCallback((t: Theme) => {
|
||||||
localStorage.setItem('dg-theme', t);
|
writeStorage('theme', t);
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +3,7 @@ import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
|||||||
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
|
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
|
||||||
import { Check, Save, Loader2 } from 'lucide-react';
|
import { Check, Save, Loader2 } from 'lucide-react';
|
||||||
import api from '@/api/request';
|
import api from '@/api/request';
|
||||||
|
import { readStorage, writeStorage } from '@/lib/storage';
|
||||||
import { toast } from '@/hooks/useToast';
|
import { toast } from '@/hooks/useToast';
|
||||||
|
|
||||||
// Login style preview color themes (independent from app theme)
|
// 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);
|
if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
const stored = readStorage('loginStyle') as LoginStyle | null;
|
||||||
if (stored) setLoginStyle(stored);
|
if (stored) setLoginStyle(stored);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -66,7 +67,7 @@ export default function AppearanceSettings() {
|
|||||||
theme,
|
theme,
|
||||||
login_style: loginStyle,
|
login_style: loginStyle,
|
||||||
});
|
});
|
||||||
localStorage.setItem('login-style', loginStyle);
|
writeStorage('loginStyle', loginStyle);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
toast('success', '外观设置已保存');
|
toast('success', '外观设置已保存');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface InstanceItem {
|
|||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
database: string | null;
|
database: string | null;
|
||||||
|
access_mode?: string;
|
||||||
env: string;
|
env: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import InkWashView from '@/components/login/InkWashView';
|
|||||||
import NeonCityView from '@/components/login/NeonCityView';
|
import NeonCityView from '@/components/login/NeonCityView';
|
||||||
import GradientMeshView from '@/components/login/GradientMeshView';
|
import GradientMeshView from '@/components/login/GradientMeshView';
|
||||||
import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme';
|
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';
|
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) {
|
if (s && s in LOGIN_STYLES) {
|
||||||
setStyle(s);
|
setStyle(s);
|
||||||
} else {
|
} 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);
|
if (stored && stored in LOGIN_STYLES) setStyle(stored);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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);
|
if (stored && stored in LOGIN_STYLES) setStyle(stored);
|
||||||
})
|
})
|
||||||
.finally(() => setReady(true));
|
.finally(() => setReady(true));
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ export default function PlatformConfigPage() {
|
|||||||
|
|
||||||
const fetchSettings = useCallback(async () => {
|
const fetchSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/settings');
|
const res = await api.get<unknown, SettingsMap>('/settings');
|
||||||
setSettings(res as SettingsMap);
|
setSettings(res);
|
||||||
} catch {
|
} catch {
|
||||||
setToast('加载设置失败');
|
setToast('加载设置失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDraw
|
|||||||
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
||||||
import type { QueryHistoryItem } from '@/features/query/types';
|
import type { QueryHistoryItem } from '@/features/query/types';
|
||||||
import { downloadCSV, exportToCSV } from '@/features/query/utils';
|
import { downloadCSV, exportToCSV } from '@/features/query/utils';
|
||||||
|
import api from '@/api/request';
|
||||||
|
import { readStorage, writeStorage } from '@/lib/storage';
|
||||||
|
|
||||||
interface QueryResult {
|
interface QueryResult {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
@@ -136,13 +138,13 @@ export default function QueriesPage() {
|
|||||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||||
// -- Sidebar 拖拽宽度 --
|
// -- 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);
|
const sidebarDragRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// -- 历史 state --
|
// -- 历史 state --
|
||||||
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
|
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('query_favorites');
|
const saved = readStorage('queryFavorites');
|
||||||
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
||||||
const initialHistory: QueryHistoryItem[] = [];
|
const initialHistory: QueryHistoryItem[] = [];
|
||||||
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
|
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
|
||||||
@@ -237,7 +239,7 @@ export default function QueriesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
|
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
||||||
if (/^\d+$/.test(database)) return;
|
if (/^\d+$/.test(database)) return;
|
||||||
instanceDetailApi.getDbSchema(Number(instanceId), database)
|
instanceDetailApi.getDbSchema(Number(instanceId), database)
|
||||||
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); })
|
.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 () => {
|
const handleExportResult = useCallback(async () => {
|
||||||
if (!sql || !instanceId) return;
|
if (!sql || !instanceId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/query/export', {
|
const blob = await api.post<unknown, Blob>('/query/export', {
|
||||||
method: 'POST',
|
instance_id: Number(instanceId),
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
database: database || undefined,
|
||||||
body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }),
|
sql,
|
||||||
});
|
}, { responseType: 'blob' });
|
||||||
if (!resp.ok) throw new Error('导出失败');
|
|
||||||
const blob = await resp.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -364,7 +364,7 @@ export default function QueriesPage() {
|
|||||||
const toggleFavorite = useCallback((id: number) => {
|
const toggleFavorite = useCallback((id: number) => {
|
||||||
setHistory(prev => {
|
setHistory(prev => {
|
||||||
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -388,7 +388,7 @@ export default function QueriesPage() {
|
|||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
setSideWidth(w => {
|
setSideWidth(w => {
|
||||||
localStorage.setItem('query-side-width', String(w));
|
writeStorage('querySideWidth', String(w));
|
||||||
return w;
|
return w;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -634,7 +634,7 @@ export default function QueriesPage() {
|
|||||||
<div className="dg-card overflow-hidden flex flex-col">
|
<div className="dg-card overflow-hidden flex flex-col">
|
||||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||||
language={editorLang} height="200px" onExecute={handleExecute}
|
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">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||||
|
|||||||
@@ -191,8 +191,9 @@ export default function RedisDetailPage() {
|
|||||||
|
|
||||||
var handleDeleteHashLine = function (field: string) {
|
var handleDeleteHashLine = function (field: string) {
|
||||||
if (!selectedKey || selectedKey.type !== 'hash') return;
|
if (!selectedKey || selectedKey.type !== 'hash') return;
|
||||||
|
const keySnapshot = selectedKey;
|
||||||
setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => {
|
setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => {
|
||||||
var nextValue = Object.assign({}, selectedKey.value || {});
|
var nextValue = Object.assign({}, keySnapshot.value || {});
|
||||||
delete nextValue[field];
|
delete nextValue[field];
|
||||||
updateHashValue(nextValue, 'Hash 字段已删除');
|
updateHashValue(nextValue, 'Hash 字段已删除');
|
||||||
}});
|
}});
|
||||||
@@ -407,7 +408,7 @@ export default function RedisDetailPage() {
|
|||||||
</div>
|
</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={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={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -456,8 +457,8 @@ export default function RedisDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
{inspectValue && (
|
{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">
|
<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>
|
<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)]">
|
<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" />复制
|
<Copy className="h-3.5 w-3.5" />复制
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -221,20 +221,20 @@ export default function TicketDetailPage() {
|
|||||||
/* Permission checks */
|
/* Permission checks */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const { hasActionPermission } = auth;
|
||||||
const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return { canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
return { canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
||||||
}
|
}
|
||||||
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
// 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态
|
||||||
const canApproveRole = adminOrDba || auth.user.role === 'developer';
|
|
||||||
const applicant = ticket.applicant_id === auth.user.id;
|
const applicant = ticket.applicant_id === auth.user.id;
|
||||||
return {
|
return {
|
||||||
canApprove: canApproveRole && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
canApprove: hasActionPermission('ticket.approve') && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
||||||
canExecute: adminOrDba && ticket.status === 'approved',
|
canExecute: hasActionPermission('ticket.execute') && ticket.status === 'approved',
|
||||||
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
|
canRollback: hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'),
|
||||||
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'),
|
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 */
|
/* Parse execute result for visualization */
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface UserItem {
|
|||||||
username: string;
|
username: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StepDraft {
|
interface StepDraft {
|
||||||
@@ -668,7 +669,7 @@ export default function WorkflowPage() {
|
|||||||
{(wf.steps || []).map((step, i) => {
|
{(wf.steps || []).map((step, i) => {
|
||||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||||
let uids: number[] = [];
|
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 || [];
|
const stepsArr = wf.steps || [];
|
||||||
return (
|
return (
|
||||||
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">
|
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
|||||||
Reference in new issue
Block a user