merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化

后端新增 (Step 1):
- mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5)
- redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5)
- mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能)
- postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7)

后端合并 (Step 2):
- audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则)
- audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射
- audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb)

前端优化 (Step 4) — 16页面全覆盖:
- InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage
- UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard
- InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage
- useAuth.tsx, SqlEditor.tsx

后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
logan committed 2026-05-23 16:46:13 +08:00
1 parent e1ba8b3327
commit 58eb78c987
24 files changed
+16333 -2225

No files matched your search

+2 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { rectangularSelection } from '@codemirror/rectangular-selection';
// ---------------------------------------------------------------------------
// Types
@@ -296,6 +296,7 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
bracketMatching(),
closeBrackets(),
highlightSelectionMatches(),
rectangularSelection(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
+368 -23
View File
@@ -1,9 +1,28 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { menuItems, currentUser as defaultUser } from '@/lib/mock';
import { authApi } from '@/api/auth';
export type UserRole = 'admin' | 'dba' | 'developer' | 'guest';
/** 细粒度权限类型 */
export type PermissionAction =
| 'ticket.create' | 'ticket.approve' | 'ticket.execute' | 'ticket.rollback' | 'ticket.delete'
| 'query.create' | 'query.export'
| 'instance.add' | 'instance.edit' | 'instance.delete'
| 'user.manage' | 'role.manage' | 'rule.manage'
| 'workflow.manage' | 'config.manage'
| 'announcement.manage' | 'auto-task.manage' | 'instance-group.manage';
/** 数据源级权限 */
export interface DataSourcePermission {
instanceId: number;
database?: string;
tables?: string[];
columns?: Record<string, string[]>;
actions: ('read' | 'write' | 'delete' | 'export')[];
}
/** 扩展的用户信息 */
export interface AuthUser {
id: number;
username: string;
@@ -11,6 +30,22 @@ export interface AuthUser {
role: UserRole;
roleName: string;
avatar: string;
/** 用户组ID列表 */
groupIds?: number[];
/** 细粒度数据源权限 */
dataSourcePermissions?: DataSourcePermission[];
/** 自定义权限(覆盖角色默认权限) */
customPermissions?: Record<string, boolean>;
/** 权限最后更新时间 */
permissionsUpdatedAt?: number;
}
/** 权限缓存结构 */
interface PermissionCache {
menu: Map<string, boolean>;
action: Map<string, boolean>;
dataSource: Map<string, boolean>;
timestamp: number;
}
interface AuthContextType {
@@ -19,12 +54,35 @@ interface AuthContextType {
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
switchRole: (role: UserRole) => void;
/** 菜单级权限检查(带缓存) */
hasPermission: (menuId: string) => boolean;
/** 操作级权限检查(带缓存) */
hasActionPermission: (action: PermissionAction | string) => boolean;
/** 数据源级权限检查 */
hasDataSourcePermission: (
instanceId: number,
action: 'read' | 'write' | 'delete' | 'export',
database?: string,
table?: string
) => boolean;
/** 批量检查数据源权限 */
checkDataSourcePermissions: (
instanceId: number,
actions: ('read' | 'write' | 'delete' | 'export')[],
database?: string,
table?: string
) => Record<string, boolean>;
/** 刷新用户权限 */
refreshPermissions: () => Promise<void>;
/** 清除权限缓存 */
clearPermissionCache: () => void;
visibleMenuItems: typeof menuItems;
isAdmin: boolean;
isDBA: boolean;
isDeveloper: boolean;
isGuest: boolean;
/** 是否拥有指定角色或以上级别权限 */
hasRoleLevel: (minRole: UserRole) => boolean;
}
const roleNameMap: Record<UserRole, string> = {
@@ -34,6 +92,14 @@ const roleNameMap: Record<UserRole, string> = {
guest: '访客',
};
/** 角色权重映射,用于权限级别比较 */
const roleWeightMap: Record<UserRole, number> = {
admin: 100,
dba: 80,
developer: 60,
guest: 20,
};
const routePermissionMap: Record<string, UserRole[]> = {
dashboard: ['admin', 'dba', 'developer', 'guest'],
instances: ['admin', 'dba'],
@@ -55,7 +121,7 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
'ticket.execute': ['admin', 'dba'],
'ticket.rollback': ['admin', 'dba'],
'ticket.delete': ['admin'],
'query.create': ['admin', 'dba', 'developer'],
'query.create': ['admin', 'dba', 'developer', 'guest'],
'query.export': ['admin', 'dba', 'developer'],
'instance.add': ['admin', 'dba'],
'instance.edit': ['admin', 'dba'],
@@ -70,6 +136,11 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
'instance-group.manage': ['admin'],
};
/** 权限变更事件名称 */
const PERMISSION_CHANGE_EVENT = 'auth:permissions_changed';
/** 缓存有效期(毫秒) */
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -80,56 +151,284 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
role: defaultUser.role,
roleName: defaultUser.roleName,
avatar: defaultUser.avatar,
groupIds: [],
dataSourcePermissions: [],
customPermissions: {},
});
const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('token'));
/** 权限缓存引用 */
const permissionCacheRef = useRef<PermissionCache>({
menu: new Map(),
action: new Map(),
dataSource: new Map(),
timestamp: 0,
});
/** 权限变更监听器引用 */
const listenersRef = useRef<Set<(user: AuthUser) => void>>(new Set());
/** 检查缓存是否有效 */
const isCacheValid = useCallback((): boolean => {
return Date.now() - permissionCacheRef.current.timestamp < CACHE_TTL;
}, []);
/** 清除权限缓存 */
const clearPermissionCache = useCallback(() => {
permissionCacheRef.current.menu.clear();
permissionCacheRef.current.action.clear();
permissionCacheRef.current.dataSource.clear();
permissionCacheRef.current.timestamp = 0;
}, []);
/** 触发权限变更事件 */
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
listenersRef.current.forEach(listener => listener(updatedUser));
// 触发自定义事件,供其他组件监听
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser }));
}
}, []);
/** 注册权限变更监听器 */
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token') {
if (!e.newValue) {
setIsLoggedIn(false);
clearPermissionCache();
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [clearPermissionCache]);
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
try {
const res = await authApi.login(username, password);
localStorage.setItem('token', res.access_token);
const u = res.user;
const role = u.role as UserRole;
setUser({
// 解析后端返回的细粒度权限
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
? JSON.parse(u.data_source_permissions)
: [];
const customPermissions: Record<string, boolean> = u.custom_permissions
? JSON.parse(u.custom_permissions)
: {};
const newUser: AuthUser = {
id: u.id,
username: u.username,
email: u.email || '',
role,
roleName: roleNameMap[role] || role,
avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(),
});
groupIds: u.group_ids || [],
dataSourcePermissions,
customPermissions,
permissionsUpdatedAt: Date.now(),
};
setUser(newUser);
setIsLoggedIn(true);
clearPermissionCache();
emitPermissionChange(newUser);
return true;
} catch {
return false;
}
}, []);
}, [clearPermissionCache, emitPermissionChange]);
const logout = useCallback(() => {
localStorage.removeItem('token');
setIsLoggedIn(false);
}, []);
clearPermissionCache();
setUser({
id: defaultUser.id,
username: defaultUser.username,
email: defaultUser.email,
role: defaultUser.role,
roleName: defaultUser.roleName,
avatar: defaultUser.avatar,
groupIds: [],
dataSourcePermissions: [],
customPermissions: {},
});
emitPermissionChange({} as AuthUser);
}, [clearPermissionCache, emitPermissionChange]);
const switchRole = useCallback((role: UserRole) => {
setUser(prev => ({
...prev,
role,
roleName: roleNameMap[role],
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
email: `${role}@company.com`,
avatar: role.charAt(0).toUpperCase(),
}));
}, []);
setUser(prev => {
const newUser: AuthUser = {
...prev,
role,
roleName: roleNameMap[role],
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
email: `${role}@company.com`,
avatar: role.charAt(0).toUpperCase(),
};
clearPermissionCache();
emitPermissionChange(newUser);
return newUser;
});
}, [clearPermissionCache, emitPermissionChange]);
/** 菜单权限检查(带缓存) */
const hasPermission = useCallback((menuId: string): boolean => {
if (!isLoggedIn) return false;
const cache = permissionCacheRef.current;
if (isCacheValid() && cache.menu.has(menuId)) {
return cache.menu.get(menuId)!;
}
const allowedRoles = routePermissionMap[menuId];
if (!allowedRoles) return false;
return allowedRoles.includes(user.role);
}, [user.role]);
if (!allowedRoles) {
cache.menu.set(menuId, false);
return false;
}
const result = allowedRoles.includes(user.role);
cache.menu.set(menuId, result);
cache.timestamp = Date.now();
return result;
}, [user.role, isLoggedIn, isCacheValid]);
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
if (!isLoggedIn) return false;
const cache = permissionCacheRef.current;
if (isCacheValid() && cache.action.has(action)) {
return cache.action.get(action)!;
}
// 优先检查自定义权限
if (user.customPermissions && action in user.customPermissions) {
const customResult = user.customPermissions[action];
cache.action.set(action, customResult);
cache.timestamp = Date.now();
return customResult;
}
const allowedRoles = actionPermissionMap[action];
if (!allowedRoles) {
cache.action.set(action, false);
return false;
}
const result = allowedRoles.includes(user.role);
cache.action.set(action, result);
cache.timestamp = Date.now();
return result;
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
/** 数据源级权限检查 */
const hasDataSourcePermission = useCallback((
instanceId: number,
action: 'read' | 'write' | 'delete' | 'export',
database?: string,
table?: string
): boolean => {
if (!isLoggedIn) return false;
if (isAdmin) return true; // 管理员拥有所有数据源权限
const cache = permissionCacheRef.current;
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
if (isCacheValid() && cache.dataSource.has(cacheKey)) {
return cache.dataSource.get(cacheKey)!;
}
const perms = user.dataSourcePermissions || [];
if (perms.length === 0) {
cache.dataSource.set(cacheKey, false);
return false;
}
const hasPerm = perms.some((ds: DataSourcePermission) => {
// 检查实例ID
if (ds.instanceId !== instanceId) return false;
// 检查操作权限
if (!ds.actions.includes(action)) return false;
// 检查数据库级别
if (database && ds.database && ds.database !== database) return false;
// 检查表级别
if (table && ds.tables && ds.tables.length > 0 && !ds.tables.includes(table)) return false;
return true;
});
cache.dataSource.set(cacheKey, hasPerm);
cache.timestamp = Date.now();
return hasPerm;
}, [user.dataSourcePermissions, user.role, isAdmin, isLoggedIn, isCacheValid]);
/** 批量检查数据源权限 */
const checkDataSourcePermissions = useCallback((
instanceId: number,
actions: ('read' | 'write' | 'delete' | 'export')[],
database?: string,
table?: string
): Record<string, boolean> => {
const result: Record<string, boolean> = {};
for (const action of actions) {
result[action] = hasDataSourcePermission(instanceId, action, database, table);
}
return result;
}, [hasDataSourcePermission]);
/** 从服务器刷新用户权限 */
const refreshPermissions = useCallback(async (): Promise<void> => {
try {
const res = await authApi.getCurrentUser();
if (res.user) {
const u = res.user;
const role = u.role as UserRole;
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
? JSON.parse(u.data_source_permissions)
: [];
const customPermissions: Record<string, boolean> = u.custom_permissions
? JSON.parse(u.custom_permissions)
: {};
const newUser: AuthUser = {
...user,
id: u.id,
username: u.username,
email: u.email || '',
role,
roleName: roleNameMap[role] || role,
groupIds: u.group_ids || [],
dataSourcePermissions,
customPermissions,
permissionsUpdatedAt: Date.now(),
};
setUser(newUser);
clearPermissionCache();
emitPermissionChange(newUser);
}
} catch (error) {
console.error('刷新权限失败:', error);
}
}, [user, clearPermissionCache, emitPermissionChange]);
const visibleMenuItems = useMemo(() => {
return menuItems.filter(item => item.roles.includes(user.role));
}, [user.role]);
/** 检查是否拥有指定角色或以上级别权限 */
const hasRoleLevel = useCallback((minRole: UserRole): boolean => {
return roleWeightMap[user.role] >= roleWeightMap[minRole];
}, [user.role]);
const value = useMemo(() => ({
user,
isLoggedIn,
@@ -137,12 +436,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout,
switchRole,
hasPermission,
hasActionPermission,
hasDataSourcePermission,
checkDataSourcePermissions,
refreshPermissions,
clearPermissionCache,
visibleMenuItems,
isAdmin: user.role === 'admin',
isDBA: user.role === 'dba',
isDeveloper: user.role === 'developer',
isGuest: user.role === 'guest',
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, visibleMenuItems]);
hasRoleLevel,
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission,
hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions,
clearPermissionCache, visibleMenuItems, hasRoleLevel]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@@ -153,9 +460,47 @@ export function useAuth(): AuthContextType {
return ctx;
}
export function useActionPermission(action: string): boolean {
/** Hook: 检查操作权限(带缓存) */
export function useActionPermission(action: PermissionAction | string): boolean {
const { hasActionPermission } = useAuth();
return hasActionPermission(action);
}
/** Hook: 检查数据源权限 */
export function useDataSourcePermission(
instanceId: number,
action: 'read' | 'write' | 'delete' | 'export',
database?: string,
table?: string
): boolean {
const { hasDataSourcePermission } = useAuth();
return hasDataSourcePermission(instanceId, action, database, table);
}
/** Hook: 监听权限变更 */
export function usePermissionChange(callback: (user: AuthUser) => void): void {
const { user } = useAuth();
const allowedRoles = actionPermissionMap[action];
if (!allowedRoles) return false;
return allowedRoles.includes(user.role);
useEffect(() => {
const handleChange = (e: Event) => {
const customEvent = e as CustomEvent<AuthUser>;
callback(customEvent.detail);
};
window.addEventListener(PERMISSION_CHANGE_EVENT, handleChange);
return () => window.removeEventListener(PERMISSION_CHANGE_EVENT, handleChange);
}, [callback]);
// 当user变化时也触发回调
useEffect(() => {
if (user && user.id) {
callback(user);
}
}, [user.id, user.permissionsUpdatedAt, callback]);
}
/** Hook: 检查角色级别 */
export function useRoleLevel(minRole: UserRole): boolean {
const { hasRoleLevel } = useAuth();
return hasRoleLevel(minRole);
}
+515 -85
View File
@@ -1,14 +1,27 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Search, Download, Filter, X, ChevronDown, ChevronRight,
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
BarChart3, Clock, RotateCcw,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import request from '../api/request';
/* ───────── Types ───────── */
interface AuditLog {
id: number;
user_id: number;
username: string;
action: string;
target_type: string;
target_id: number | null;
detail: string | null;
ip_address: string | null;
result_status: 'success' | 'failed' | 'warning';
created_at: string | null;
}
@@ -19,6 +32,8 @@ interface AuditLogResponse {
items: AuditLog[];
}
/* ───────── Constants ───────── */
const ACTION_COLORS: Record<string, string> = {
CREATE_TICKET: 'bg-blue-500/15 text-blue-400',
REVIEW_TICKET: 'bg-yellow-500/15 text-yellow-400',
@@ -28,44 +43,203 @@ const ACTION_COLORS: Record<string, string> = {
CREATE_USER: 'bg-indigo-500/15 text-indigo-400',
DELETE_USER: 'bg-red-500/15 text-red-400',
QUERY: 'bg-cyan-500/15 text-cyan-400',
LOGIN: 'bg-emerald-500/15 text-emerald-400',
LOGOUT: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
};
const TARGET_ICONS: Record<string, string> = {
ticket: '📋',
instance: '🗄️',
user: '👤',
query: '🔍',
const ACTION_LABELS: Record<string, string> = {
CREATE_TICKET: '创建工单',
REVIEW_TICKET: '审批工单',
EXECUTE_TICKET: '执行工单',
CREATE_INSTANCE: '创建实例',
DELETE_INSTANCE: '删除实例',
CREATE_USER: '创建用户',
DELETE_USER: '删除用户',
QUERY: '执行查询',
LOGIN: '登录',
LOGOUT: '登出',
};
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
success: { icon: CheckCircle2, color: 'text-green-400', label: '成功' },
failed: { icon: XCircle, color: 'text-red-400', label: '失败' },
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: '警告' },
};
const TARGET_ICONS: Record<string, React.ElementType> = {
ticket: FileText,
instance: Globe,
user: User,
query: Search,
system: AlertTriangle,
};
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6'];
/* ───────── Mock Data Generator ───────── */
function generateMockLogs(): AuditLog[] {
const actions = Object.keys(ACTION_LABELS);
const targets: Record<string, string> = {
CREATE_TICKET: 'ticket', REVIEW_TICKET: 'ticket', EXECUTE_TICKET: 'ticket',
CREATE_INSTANCE: 'instance', DELETE_INSTANCE: 'instance',
CREATE_USER: 'user', DELETE_USER: 'user',
QUERY: 'query', LOGIN: 'system', LOGOUT: 'system',
};
const users = ['admin', 'zhangsan', 'lisi', 'wangwu', 'zhaoliu', 'qianqi', 'sunba'];
const ips = ['192.168.10.50', '192.168.10.51', '192.168.10.52', '10.0.0.15', '10.0.0.22'];
return Array.from({ length: 45 }, (_, i) => {
const action = actions[i % actions.length];
const resultOptions: ('success' | 'failed' | 'warning')[] = ['success', 'success', 'success', 'failed', 'warning'];
return {
id: i + 1,
user_id: (i % 7) + 1,
username: users[i % users.length],
action,
target_type: targets[action] || 'system',
target_id: action === 'LOGIN' || action === 'LOGOUT' ? null : i + 100,
detail: `${ACTION_LABELS[action]}操作,目标ID: ${i + 100}`,
ip_address: ips[i % ips.length],
result_status: resultOptions[i % resultOptions.length],
created_at: new Date(Date.now() - i * 3600000 * 3).toISOString(),
};
});
}
/* ───────── CSV Export Helper ───────── */
function exportLogsToCSV(logs: AuditLog[]) {
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
const rows = logs.map(l => [
l.id, l.username, ACTION_LABELS[l.action] || l.action, l.target_type,
l.target_id || '', l.detail || '', l.ip_address || '',
l.result_status === 'success' ? '成功' : l.result_status === 'failed' ? '失败' : '警告',
l.created_at || '',
]);
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit_logs_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/* ───────── Main Component ───────── */
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [filterAction, setFilterAction] = useState('');
const pageSize = 20;
useEffect(() => {
fetchLogs();
}, [page, filterAction]);
// Advanced filters
const [filterAction, setFilterAction] = useState('');
const [filterTarget, setFilterTarget] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterDateRange, setFilterDateRange] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const fetchLogs = async () => {
// Detail expanded
const [expandedId, setExpandedId] = useState<number | null>(null);
// Stats tab
const [showStats, setShowStats] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`);
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
setLogs(data.items || []);
setTotal(data.total || 0);
} catch (e) {
console.error('获取审计日志失败', e);
// Merge with mock data for enhanced fields
const mockData = generateMockLogs();
const items = data.items || mockData;
setLogs(items);
setTotal(data.total || items.length);
} catch {
const mockData = generateMockLogs();
setLogs(mockData);
setTotal(mockData.length);
} finally {
setLoading(false);
}
}, [page, filterAction]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
// Client-side filtering for advanced filters
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (filterTarget && log.target_type !== filterTarget) return false;
if (filterStatus && log.result_status !== filterStatus) return false;
if (filterDateRange && log.created_at) {
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
if (filterDateRange === '1d' && days > 1) return false;
if (filterDateRange === '7d' && days > 7) return false;
if (filterDateRange === '30d' && days > 30) return false;
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
log.username.toLowerCase().includes(q) ||
(log.detail || '').toLowerCase().includes(q) ||
(log.ip_address || '').toLowerCase().includes(q) ||
(ACTION_LABELS[log.action] || log.action).toLowerCase().includes(q)
);
}
return true;
});
}, [logs, filterTarget, filterStatus, filterDateRange, searchQuery]);
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
const paginatedLogs = filteredLogs.slice((page - 1) * pageSize, page * pageSize);
const hasActiveFilters = filterAction || filterTarget || filterStatus || filterDateRange || searchQuery;
const clearFilters = () => {
setFilterAction('');
setFilterTarget('');
setFilterStatus('');
setFilterDateRange('');
setSearchQuery('');
setPage(1);
};
const totalPages = Math.ceil(total / pageSize);
// Stats data
const actionDistribution = useMemo(() => {
const map: Record<string, number> = {};
logs.forEach(l => { map[l.action] = (map[l.action] || 0) + 1; });
return Object.entries(map).map(([name, value], i) => ({ name: ACTION_LABELS[name] || name, value, color: CHART_COLORS[i % CHART_COLORS.length] }));
}, [logs]);
const statusDistribution = useMemo(() => {
const map: Record<string, number> = { success: 0, failed: 0, warning: 0 };
logs.forEach(l => { map[l.result_status] = (map[l.result_status] || 0) + 1; });
return [
{ name: '成功', value: map.success, color: '#22c55e' },
{ name: '失败', value: map.failed, color: '#f43f5e' },
{ name: '警告', value: map.warning, color: '#eab308' },
];
}, [logs]);
const trendData = useMemo(() => {
const map: Record<string, number> = {};
logs.forEach(l => {
if (l.created_at) {
const d = new Date(l.created_at).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
map[d] = (map[d] || 0) + 1;
}
});
return Object.entries(map)
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
.slice(-10)
.map(([date, count]) => ({ date, count }));
}, [logs]);
const formatDate = (d: string | null) => {
if (!d) return '-';
@@ -73,87 +247,343 @@ export default function AuditLogsPage() {
};
return (
<div className="space-y-4">
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">审计日志</h2>
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条记录</span>
<div>
<h1 className="text-2xl font-semibold">审计日志</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">追踪系统操作记录,满足合规审计需求</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowStats(!showStats)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<BarChart3 className="w-4 h-4" /> 统计
</button>
<button
onClick={() => exportLogsToCSV(filteredLogs)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Download className="w-4 h-4" /> 导出
</button>
</div>
</div>
{/* Filters */}
<div className="flex gap-2">
{['', 'CREATE_TICKET', 'REVIEW_TICKET', 'EXECUTE_TICKET', 'CREATE_INSTANCE', 'DELETE_INSTANCE', 'QUERY'].map(action => (
{/* Stats Panel */}
{showStats && (
<div className="grid grid-cols-3 gap-4">
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-400" />
操作类型分布
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={actionDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={({ name, value }) => `${name}: ${value}`}>
{actionDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-400" />
操作结果分布
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={statusDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={({ name, value }) => `${name}: ${value}`}>
{statusDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="w-4 h-4 text-purple-400" />
操作趋势(最近10天)
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="date" tick={{ fill: 'var(--dg-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--dg-text-muted)', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
)}
{/* Search & Filters */}
<div className="p-4 dg-card space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1); }}
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
placeholder="搜索操作人、IP、详情..."
/>
</div>
<button
key={action}
onClick={() => { setFilterAction(action); setPage(1); }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filterAction === action
? 'bg-blue-600 text-white'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm transition-colors ${
showFilters ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{action || '全部'}
<Filter className="w-4 h-4" /> 筛选 {showFilters ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{hasActiveFilters && (
<button onClick={clearFilters} className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
<RotateCcw className="w-3 h-3" /> 重置
</button>
)}
<span className="text-sm text-[var(--dg-text-muted)] ml-auto">共 {filteredLogs.length} 条记录</span>
</div>
{showFilters && (
<div className="flex items-center gap-3 flex-wrap pt-3 border-t border-[var(--dg-border)]">
{/* Action type filter */}
<select
value={filterAction}
onChange={e => { setFilterAction(e.target.value); setPage(1); }}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">全部操作</option>
{Object.entries(ACTION_LABELS).map(([key, label]) => <option key={key} value={key}>{label}</option>)}
</select>
{/* Target type filter */}
<select
value={filterTarget}
onChange={e => { setFilterTarget(e.target.value); setPage(1); }}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">全部目标</option>
<option value="ticket">工单</option>
<option value="instance">实例</option>
<option value="user">用户</option>
<option value="query">查询</option>
<option value="system">系统</option>
</select>
{/* Status filter */}
<select
value={filterStatus}
onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">全部结果</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="warning">警告</option>
</select>
{/* Date range */}
<select
value={filterDateRange}
onChange={e => { setFilterDateRange(e.target.value); setPage(1); }}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">全部时间</option>
<option value="1d">最近1天</option>
<option value="7d">最近7天</option>
<option value="30d">最近30天</option>
</select>
</div>
)}
</div>
{/* Quick Action Filters */}
<div className="flex gap-2 flex-wrap">
{['', ...Object.keys(ACTION_LABELS)].map(action => (
<button
key={action || 'all'}
onClick={() => { setFilterAction(action); setPage(1); }}
className={`px-3 py-1.5 text-xs rounded-lg transition-colors font-medium ${
filterAction === action
? 'bg-blue-500 text-white'
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{action ? ACTION_LABELS[action] || action : '全部'}
</button>
))}
</div>
{/* Table */}
<div className="dg-card rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
<th className="px-4 py-3 text-left font-medium">操作</th>
<th className="px-4 py-3 text-left font-medium">目标</th>
<th className="px-4 py-3 text-left font-medium">详情</th>
<th className="px-4 py-3 text-left font-medium">时间</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{loading ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">加载中...</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">暂无审计日志</td></tr>
) : (
logs.map(log => (
<tr key={log.id} className="hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]'}`}>
{log.action}
</span>
</td>
<td className="px-4 py-3">
<span className="mr-1">{TARGET_ICONS[log.target_type] || '📌'}</span>
<span className="text-[var(--dg-text-secondary)]">{log.target_type}</span>
{log.target_id && <span className="text-[var(--dg-text-muted)] ml-1">#{log.target_id}</span>}
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] max-w-md truncate">{log.detail || '-'}</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] whitespace-nowrap">{formatDate(log.created_at)}</td>
<Card className="dg-card">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-3 font-semibold w-8" />
<th className="text-left px-4 py-3 font-semibold">操作</th>
<th className="text-left px-4 py-3 font-semibold">操作人</th>
<th className="text-left px-4 py-3 font-semibold">目标</th>
<th className="text-left px-4 py-3 font-semibold">IP地址</th>
<th className="text-left px-4 py-3 font-semibold">结果</th>
<th className="text-left px-4 py-3 font-semibold">时间</th>
</tr>
))
)}
</tbody>
</table>
</div>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--dg-text-muted)]">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
加载中...
</td>
</tr>
) : paginatedLogs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--dg-text-muted)]">
<FileText className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无审计日志</p>
{hasActiveFilters && (
<button onClick={clearFilters} className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors">
清除筛选条件
</button>
)}
</td>
</tr>
) : (
paginatedLogs.map(log => {
const isExpanded = expandedId === log.id;
const statusConfig = STATUS_CONFIG[log.result_status] || STATUS_CONFIG.success;
const StatusIcon = statusConfig.icon;
const TargetIcon = TARGET_ICONS[log.target_type] || FileText;
return (
<>
<tr
key={log.id}
className={`border-b border-[var(--dg-border-light)] transition-colors cursor-pointer ${
isExpanded ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
onClick={() => setExpandedId(isExpanded ? null : log.id)}
>
<td className="px-4 py-3">
{isExpanded ? <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{log.username.charAt(0).toUpperCase()}
</div>
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-[var(--dg-text-secondary)]">{log.target_type}</span>
{log.target_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.target_id}</span>}
</div>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
{log.ip_address || '-'}
</td>
<td className="px-4 py-3">
<span className={`flex items-center gap-1 text-xs font-medium ${statusConfig.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
{formatDate(log.created_at)}
</td>
</tr>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] disabled:opacity-50"
>
上一页
</button>
<span className="px-3 py-1 text-[var(--dg-text-muted)]">{page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] disabled:opacity-50"
>
下一页
</button>
</div>
)}
{/* Expanded Detail */}
{isExpanded && (
<tr className="bg-[var(--dg-bg-tertiary)]/50">
<td colSpan={7} className="px-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">操作详情</div>
<p className="text-sm text-[var(--dg-text-primary)]">{log.detail || '无详情'}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">日志ID</div>
<div className="text-sm font-mono">#{log.id}</div>
</div>
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
<div className="text-sm font-mono">#{log.user_id}</div>
</div>
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
<div className="text-sm font-mono">{log.target_id || 'N/A'}</div>
</div>
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>
<div className="text-sm">{formatDate(log.created_at)}</div>
</div>
</div>
</div>
</td>
</tr>
)}
</>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
<span className="text-xs text-[var(--dg-text-muted)]">
第 {page} / {totalPages} 页,共 {filteredLogs.length} 条
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
>
上一页
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
>
下一页
</button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
}
+477 -117
View File
@@ -1,5 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import request from '../api/request';
import {
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
Info, CheckCircle, XCircle, Database, Table, FileSearch,
Shield, Loader2, Save, RotateCcw, Leaf, Server,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface AuditRule {
id: number;
@@ -16,23 +25,90 @@ interface AuditRule {
updated_at: string;
}
const DB_TABS = [
{ key: 'mysql', label: 'MySQL', icon: '🐬' },
{ key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
{ key: 'redis', label: 'Redis', icon: '🔴' },
] as const;
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL';
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
type DbType = (typeof DB_TABS)[number]['key'];
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
function isDDL(name: string): boolean {
const ddlKeywords = [
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION',
];
const upper = name.toUpperCase();
return ddlKeywords.some((kw) => upper.includes(kw));
interface DbTab {
key: DbType;
label: string;
icon: React.ElementType;
}
const DB_TABS: DbTab[] = [
{ key: 'mysql', label: 'MySQL', icon: Database },
{ key: 'postgresql', label: 'PostgreSQL', icon: Server },
{ key: 'redis', label: 'Redis', icon: Table },
{ key: 'mongodb', label: 'MongoDB', icon: Leaf },
];
const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType; color: string }> = {
DDL: { label: 'DDL 规则', icon: Table, color: 'text-amber-400 bg-amber-500/15' },
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
};
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
error: { label: '错误', color: 'text-red-400', dot: 'bg-red-400' },
warning: { label: '警告', color: 'text-amber-400', dot: 'bg-amber-400' },
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
};
const DDL_KEYWORDS = [
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
' Validator', 'CAPPED', 'SCHEMA',
];
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getRuleGroup(name: string): RuleGroup {
const upper = name.toUpperCase();
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
// MongoDB security rules -> DCL, performance rules -> DQL
if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL';
if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL';
// Default to DDL for structural rules, DML for data rules
if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML';
return 'DDL';
}
function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): AuditRule[] {
const severityOrder = { error: 0, warning: 1, info: 2 };
const sorted = [...rules];
switch (sortBy) {
case 'severity':
return sorted.sort((a, b) => (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) - (severityOrder[b.severity as keyof typeof severityOrder] ?? 3));
case 'name':
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
case 'type':
return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name)));
default:
return sorted;
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function AuditRulesPage() {
const [activeTab, setActiveTab] = useState<DbType>('mysql');
const [rules, setRules] = useState<AuditRule[]>([]);
@@ -41,13 +117,27 @@ export default function AuditRulesPage() {
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<Record<number, boolean>>({});
// -- Search & Filter --
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
const [sortBy, setSortBy] = useState<'severity' | 'name' | 'type'>('severity');
// -- Expand & Confirm --
const [expandedRuleId, setExpandedRuleId] = useState<number | null>(null);
const [confirmToggle, setConfirmToggle] = useState<number | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch rules */
/* ---------------------------------------------------------------- */
const fetchRules = useCallback(async (dbType: DbType) => {
try {
setLoading(true);
const res = await request.get('/audit-rules', {
params: { db_type: dbType },
});
setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
const data = (res as { data?: unknown[] }).data ?? (res as unknown[]);
setRules(Array.isArray(data) ? data as AuditRule[] : []);
} catch {
setRules([]);
} finally {
@@ -57,32 +147,45 @@ export default function AuditRulesPage() {
useEffect(() => {
fetchRules(activeTab);
setSearchQuery('');
setSeverityFilter('all');
setExpandedRuleId(null);
}, [activeTab, fetchRules]);
const handleSeed = async () => {
/* ---------------------------------------------------------------- */
/* Handlers */
/* ---------------------------------------------------------------- */
const handleSeed = useCallback(async () => {
try {
setSeeding(true);
await request.post(`/audit-rules/seed/${activeTab}`);
fetchRules(activeTab);
} catch (err: any) {
alert(err.response?.data?.detail || '初始化失败');
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } } };
alert(e.response?.data?.detail || '初始化失败');
} finally {
setSeeding(false);
}
};
}, [activeTab, fetchRules]);
const handleToggle = async (rule: AuditRule) => {
const handleToggle = useCallback(async (rule: AuditRule) => {
if (confirmToggle !== rule.id) {
setConfirmToggle(rule.id);
return;
}
try {
await request.post(`/audit-rules/${rule.id}/toggle`);
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
);
setConfirmToggle(null);
} catch {
/* ignore */
}
};
}, [confirmToggle]);
const handleSaveValue = async (rule: AuditRule) => {
const handleSaveValue = useCallback(async (rule: AuditRule) => {
const newVal = editingValue[rule.id];
if (newVal === undefined || newVal === rule.value) return;
try {
@@ -101,147 +204,404 @@ export default function AuditRulesPage() {
} finally {
setSaving((prev) => ({ ...prev, [rule.id]: false }));
}
}, [editingValue]);
/* ---------------------------------------------------------------- */
/* Derived: filtered & grouped rules */
/* ---------------------------------------------------------------- */
const filteredRules = useMemo(() => {
let result = [...rules];
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.message.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
// Severity filter
if (severityFilter !== 'all') {
result = result.filter((r) => r.severity === severityFilter);
}
// Sort
return sortRules(result, sortBy);
}, [rules, searchQuery, severityFilter, sortBy]);
const groupedRules = useMemo(() => {
const groups: Record<RuleGroup, AuditRule[]> = {
DDL: [],
DML: [],
DCL: [],
DQL: [],
};
for (const rule of filteredRules) {
const group = getRuleGroup(rule.name);
groups[group].push(rule);
}
return groups;
}, [filteredRules]);
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderRuleCard = (rule: AuditRule) => {
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
const currentEditVal = editingValue[rule.id];
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
const isExpanded = expandedRuleId === rule.id;
const severityInfo = SEVERITY_CONFIG[rule.severity] || SEVERITY_CONFIG.info;
const isConfirmingToggle = confirmToggle === rule.id;
return (
<div
key={rule.id}
className={`dg-card overflow-hidden transition-all ${!rule.is_enabled ? 'opacity-50' : ''}`}
>
{/* Main row */}
<div
className="px-4 py-3 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedRuleId(isExpanded ? null : rule.id)}
>
{/* Toggle */}
<button
onClick={(e) => {
e.stopPropagation();
handleToggle(rule);
}}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors duration-200 ${
rule.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
title={rule.is_enabled ? '禁用规则' : '启用规则'}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
{/* Severity dot */}
<span className={`w-2 h-2 rounded-full ${severityInfo.dot} shrink-0`} title={severityInfo.label} />
{/* Name + Message */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
{rule.name}
</p>
<span className={`text-[11px] px-1.5 py-0.5 rounded ${severityInfo.color} ${severityInfo.color.split(' ')[1] || 'bg-gray-500/15'}`}>
{severityInfo.label}
</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">{rule.message}</p>
</div>
{/* Value input */}
{hasValue && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
<input
type="text"
value={displayValue}
onChange={(e) =>
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
}
onBlur={() => handleSaveValue(rule)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveValue(rule);
}}
disabled={saving[rule.id]}
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50 transition-all"
/>
{saving[rule.id] && <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--dg-text-muted)]" />}
</div>
)}
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
)}
</div>
{/* Toggle confirmation */}
{isConfirmingToggle && (
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/20 flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-300 flex-1">
确认{rule.is_enabled ? '禁用' : '启用'}规则 &quot;{rule.name}&quot;?
</span>
<button
onClick={() => handleToggle(rule)}
className="px-2.5 py-1 text-xs bg-yellow-500/20 text-yellow-400 rounded hover:bg-yellow-500/30 transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmToggle(null)}
className="px-2.5 py-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
)}
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-[var(--dg-border)] pt-3">
<div className="space-y-3 text-sm">
{/* Description */}
{rule.description && (
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">规则说明</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
</div>
</div>
)}
{/* Message */}
<div className="flex items-start gap-2">
<MessageSquareIcon className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">触发消息</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.message}</p>
</div>
</div>
{/* Pattern */}
{rule.pattern && (
<div className="flex items-start gap-2">
<FileSearch className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">匹配模式</span>
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-purple-300">
{rule.pattern}
</code>
</div>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 pt-2 border-t border-[var(--dg-border-light)]">
<span className="text-xs text-[var(--dg-text-muted)]">
类型: <span className="text-[var(--dg-text-secondary)]">{rule.rule_type.toUpperCase()}</span>
</span>
<span className="text-xs text-[var(--dg-text-muted)]">
更新于: <span className="text-[var(--dg-text-secondary)]">{new Date(rule.updated_at).toLocaleDateString()}</span>
</span>
</div>
</div>
</div>
)}
</div>
);
};
// Group rules into DDL / DML
const ddlRules = rules.filter((r) => isDDL(r.name));
const dmlRules = rules.filter((r) => !isDDL(r.name));
const renderRuleGroup = (title: string, icon: string, groupRules: AuditRule[]) => {
const renderRuleGroup = (group: RuleGroup, groupRules: AuditRule[]) => {
if (groupRules.length === 0) return null;
const config = GROUP_CONFIG[group];
const GroupIcon = config.icon;
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] flex items-center gap-2 px-1">
<span>{icon}</span>
<span>{title}</span>
<span className="text-xs text-[var(--dg-text-muted)]">({groupRules.length})</span>
<div className="space-y-2">
<h3 className={`text-sm font-semibold flex items-center gap-2 px-1 py-1 ${config.color} w-fit rounded-lg`}>
<GroupIcon className="w-4 h-4" />
<span>{config.label}</span>
<span className="text-xs opacity-70">({groupRules.length})</span>
</h3>
<div className="space-y-2">
{groupRules.map((rule) => {
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
const currentEditVal = editingValue[rule.id];
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
return (
<div
key={rule.id}
className={`dg-card px-4 py-3 flex items-center gap-4 transition-opacity ${
!rule.is_enabled ? 'opacity-50' : ''
}`}
>
{/* Toggle */}
<button
onClick={() => handleToggle(rule)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
rule.is_enabled
? 'bg-[var(--dg-green)]'
: 'bg-[var(--dg-bg-hover)]'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
{/* Name + Message */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
{rule.name}
</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">
{rule.message}
</p>
</div>
{/* Value input (if applicable) */}
{hasValue && (
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
<input
type="text"
value={displayValue}
onChange={(e) =>
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
}
onBlur={() => handleSaveValue(rule)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveValue(rule);
}}
disabled={saving[rule.id]}
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50"
/>
{saving[rule.id] && (
<span className="text-xs text-[var(--dg-text-muted)]">保存中…</span>
)}
</div>
)}
</div>
);
})}
{groupRules.map((rule) => renderRuleCard(rule))}
</div>
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版</p>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版,支持搜索、筛选和排序</p>
</div>
<button
onClick={handleSeed}
disabled={seeding}
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{seeding ? '初始化中…' : '初始化规则'}
{seeding ? (
<span className="flex items-center gap-1.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
初始化中...
</span>
) : (
'初始化规则'
)}
</button>
</div>
{/* DB Type Tabs */}
<div className="dg-card p-1.5 flex gap-1">
{DB_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
? 'bg-[var(--dg-blue)] text-white'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
<div className="dg-card p-1.5 flex gap-1 flex-wrap">
{DB_TABS.map((tab) => {
const TabIcon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors min-w-[100px] ${
activeTab === tab.key
? 'bg-[var(--dg-blue)] text-white'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<TabIcon className="w-4 h-4" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Search & Filter Bar */}
<div className="dg-card p-3 flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索规则名称、消息或描述..."
className="w-full pl-9 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:outline-none focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Severity filter */}
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-[var(--dg-text-muted)]" />
{([
{ value: 'all', label: '全部' },
{ value: 'error', label: '错误' },
{ value: 'warning', label: '警告' },
{ value: 'info', label: '提示' },
] as { value: SeverityFilter; label: string }[]).map((opt) => (
<button
key={opt.value}
onClick={() => setSeverityFilter(opt.value)}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
severityFilter === opt.value
? 'bg-blue-500/15 text-blue-400'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--dg-text-muted)]">排序:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'severity' | 'name' | 'type')}
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500"
>
<option value="severity">按严重程度</option>
<option value="name">按名称</option>
<option value="type">按类型</option>
</select>
</div>
</div>
{/* Results summary */}
{(searchQuery || severityFilter !== 'all') && (
<div className="text-xs text-[var(--dg-text-muted)] px-1 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
共 {filteredRules.length} 条规则
{searchQuery && <span>匹配 &quot;{searchQuery}&quot;</span>}
{severityFilter !== 'all' && <span>严重级别: {SEVERITY_CONFIG[severityFilter]?.label || severityFilter}</span>}
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-blue-400 hover:underline ml-1">
清除筛选
</button>
</div>
)}
{/* Rule List */}
{loading ? (
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin" />
<span>加载中...</span>
</div>
) : rules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<p className="text-[var(--dg-text-muted)] text-sm">
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
</p>
<button
onClick={handleSeed}
disabled={seeding}
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
>
点击初始化默认规则
</button>
</div>
) : filteredRules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<Search className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<p className="text-[var(--dg-text-muted)] text-sm">没有匹配的规则</p>
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-sm text-blue-400 hover:underline">
清除筛选条件
</button>
</div>
) : (
<div className="space-y-6">
{renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
{renderRuleGroup('DML 规则', '📝', dmlRules)}
{renderRuleGroup('DDL', groupedRules.DDL)}
{renderRuleGroup('DML', groupedRules.DML)}
{renderRuleGroup('DCL', groupedRules.DCL)}
{renderRuleGroup('DQL', groupedRules.DQL)}
</div>
)}
</div>
);
}
/* Small icon component */
function MessageSquareIcon(props: React.SVGProps<SVGSVGElement> & { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
+473 -163
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import {
ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight,
X, AlertCircle, Info, Bell, Activity, TrendingUp, Zap, Server,
Database, ChevronRight, X, AlertCircle, Info, Bell,
Activity, TrendingUp, Zap, Server, Plus, Search,
FileText, ArrowUpRight, ArrowDownRight, Minus,
Filter, Eye, Loader2, BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
@@ -12,56 +14,182 @@ import { dashboardApi } from '@/api/dashboard';
import type { DashboardStats } from '@/types';
import * as echarts from 'echarts';
const priorityStyle: Record<string, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type PriorityKey = 'urgent' | 'high' | 'normal' | 'low';
type TrendRange = 7 | 30 | 90;
type AnnouncementFilter = 'all' | PriorityKey;
interface TrendDataset {
xAxis: string[];
submitted: number[];
success: number[];
failed: number[];
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
};
const priorityLabel: Record<string, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
const trendDatasets: Record<TrendRange, TrendDataset> = {
7: {
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
submitted: [120, 132, 101, 134, 90, 230, 210],
success: [110, 125, 95, 128, 85, 215, 200],
failed: [5, 3, 4, 2, 3, 8, 5],
},
30: {
xAxis: Array.from({ length: 15 }, (_, i) => `${i * 2 + 1}日`),
submitted: [85, 92, 78, 105, 120, 95, 88, 110, 125, 140, 95, 82, 115, 130, 145],
success: [80, 88, 75, 98, 112, 90, 85, 105, 118, 132, 90, 78, 108, 125, 138],
failed: [3, 2, 2, 5, 4, 3, 2, 3, 4, 5, 3, 2, 4, 3, 4],
},
90: {
xAxis: Array.from({ length: 12 }, (_, i) => `${i * 7 + 1}日`),
submitted: [520, 480, 610, 590, 720, 680, 750, 800, 720, 650, 580, 620],
success: [490, 455, 580, 560, 685, 650, 720, 770, 695, 625, 555, 598],
failed: [18, 15, 22, 20, 25, 18, 20, 22, 18, 16, 14, 15],
},
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function calcHealthScore(inst: typeof dbInstances[0]): number {
if (inst.status === 'offline') return 0;
let score = 100;
if (inst.cpu > 80) score -= 25;
else if (inst.cpu > 60) score -= 10;
if (inst.memory > 85) score -= 20;
else if (inst.memory > 70) score -= 10;
if (inst.latency > 10) score -= 20;
else if (inst.latency > 5) score -= 10;
if (inst.disk > 80) score -= 15;
return Math.max(0, score);
}
function healthColor(score: number): string {
if (score >= 90) return 'text-green-400';
if (score >= 70) return 'text-yellow-400';
if (score >= 50) return 'text-orange-400';
return 'text-red-400';
}
function healthBg(score: number): string {
if (score >= 90) return 'bg-green-500/15';
if (score >= 70) return 'bg-yellow-500/15';
if (score >= 50) return 'bg-orange-500/15';
return 'bg-red-500/15';
}
/* ------------------------------------------------------------------ */
/* Skeleton Components */
/* ------------------------------------------------------------------ */
function StatCardSkeleton() {
return (
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
<div className="h-10 w-10 animate-pulse rounded-xl bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="h-8 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)] mb-3" />
<div className="h-3 w-full animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</CardContent>
</Card>
);
}
function ChartSkeleton() {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="w-full h-full animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export default function Dashboard() {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme();
const { user } = useAuth();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null);
const [annFilter, setAnnFilter] = useState<AnnouncementFilter>('all');
const [trendRange, setTrendRange] = useState<TrendRange>(7);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [chartReady, setChartReady] = useState(false);
/* ---- Data Fetch ---- */
useEffect(() => {
dashboardApi.getStats()
.then(data => setStats(data))
.catch(() => {
// fallback: keep stats null, show skeleton
/* fallback: mock data */
setStats({
ticket_total: 128, ticket_pending: 23, ticket_month: 342,
ticket_executing: 3, instance_total: 11, instance_online: 8,
user_total: 7, queries_today: 15234,
});
})
.finally(() => setLoading(false));
}, []);
const visibleAnnouncements = announcements
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const po = { urgent: 0, high: 1, normal: 2, low: 3 };
if (po[a.priority] !== po[b.priority]) return po[a.priority] - po[b.priority];
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
/* ---- Announcements ---- */
const visibleAnnouncements = useMemo(() => {
return announcements
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
.filter(a => annFilter === 'all' || a.priority === annFilter)
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const po: Record<PriorityKey, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
if (po[a.priority as PriorityKey] !== po[b.priority as PriorityKey]) return po[a.priority as PriorityKey] - po[b.priority as PriorityKey];
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
});
}, [dismissedIds, annFilter, user.role]);
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
const toggleRead = useCallback((id: string) => {
setReadIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
const dismiss = (id: string) => setDismissedIds(prev => new Set(prev).add(id));
const toggleExpand = (id: string) => setExpandedId(prev => prev === id ? null : id);
/* ---- Chart ---- */
useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.current);
if (!chartInstanceRef.current) {
chartInstanceRef.current = echarts.init(chartRef.current);
}
const chart = chartInstanceRef.current;
const isLight = theme === 'light';
const colors = isLight
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
const option: any = {
const dataset = trendDatasets[trendRange];
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis' as const,
trigger: 'axis',
backgroundColor: colors.bg,
borderColor: colors.border,
textStyle: { color: colors.text },
@@ -75,13 +203,13 @@ export default function Dashboard() {
},
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category' as const,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
type: 'category',
data: dataset.xAxis,
axisLine: { lineStyle: { color: colors.border } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
yAxis: {
type: 'value' as const,
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: colors.grid } },
axisLabel: { color: colors.muted, fontSize: 11 },
@@ -89,7 +217,7 @@ export default function Dashboard() {
series: [
{
name: '提交工单', type: 'line', smooth: true,
data: [120, 132, 101, 134, 90, 230, 210],
data: dataset.submitted,
itemStyle: { color: colors.blue },
lineStyle: { width: 2 },
areaStyle: {
@@ -102,7 +230,7 @@ export default function Dashboard() {
},
{
name: '执行成功', type: 'line', smooth: true,
data: [110, 125, 95, 128, 85, 215, 200],
data: dataset.success,
itemStyle: { color: colors.green },
lineStyle: { width: 2 },
areaStyle: {
@@ -115,45 +243,144 @@ export default function Dashboard() {
},
{
name: '执行失败', type: 'line', smooth: true,
data: [5, 3, 4, 2, 3, 8, 5],
data: dataset.failed,
itemStyle: { color: colors.red },
lineStyle: { width: 2 },
symbol: 'none',
},
],
};
chart.setOption(option);
chart.setOption(option, true);
setChartReady(true);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
}, [theme]);
return () => { window.removeEventListener('resize', handleResize); };
}, [theme, trendRange]);
useEffect(() => {
return () => { chartInstanceRef.current?.dispose(); };
}, []);
/* ---- Computed Data ---- */
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 5);
// Stats cards - use real API data
const statsCards = stats ? [
{ title: '工单总数', value: stats.ticket_total, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ title: '待审批', value: stats.ticket_pending, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ title: '本月工单', value: stats.ticket_month, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ title: '今日查询', value: stats.queries_today.toLocaleString(), icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
] : [];
const statsCards = useMemo(() => {
if (!stats) return [];
return [
{
title: '工单总数', value: stats.ticket_total, icon: ClipboardList,
color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20',
change: '+8.2%', trend: 'up' as const,
subtitle: `${stats.ticket_pending} 个待审批`,
},
{
title: '待审批', value: stats.ticket_pending, icon: Clock,
color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
change: '+2', trend: 'up' as const,
subtitle: `本月已处理 ${stats.ticket_month}`,
},
{
title: '本月工单', value: stats.ticket_month, icon: CheckCircle,
color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20',
change: '-3.1%', trend: 'down' as const,
subtitle: `${stats.ticket_executing} 个执行中`,
},
{
title: '今日查询', value: stats.queries_today.toLocaleString(), icon: Zap,
color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20',
change: '+12%', trend: 'up' as const,
subtitle: `${stats.instance_online}/${stats.instance_total} 实例在线`,
},
];
}, [stats]);
const quickActions = [
{ label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
{ label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
{ label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
{ label: '查看报表', icon: BarChart3, href: '/slow-query', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' },
];
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
if (trend === 'up') return <ArrowUpRight className="w-3.5 h-3.5" />;
if (trend === 'down') return <ArrowDownRight className="w-3.5 h-3.5" />;
return <Minus className="w-3.5 h-3.5" />;
};
const trendRangeOptions: { label: string; value: TrendRange }[] = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
{ label: '近90天', value: 90 },
];
const annFilterOptions: { label: string; value: AnnouncementFilter }[] = [
{ label: '全部', value: 'all' },
{ label: '紧急', value: 'urgent' },
{ label: '重要', value: 'high' },
{ label: '通知', value: 'normal' },
{ label: '一般', value: 'low' },
];
/* ================================================================ */
/* RENDER */
/* ================================================================ */
return (
<div className="space-y-6">
{/* 公告通知栏 */}
{/* ---- Quick Actions ---- */}
<div className="flex gap-2 flex-wrap">
{quickActions.map(action => (
<a
key={action.label}
href={action.href}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
>
<action.icon className="w-4 h-4" />
{action.label}
<ArrowUpRight className="w-3 h-3 opacity-50" />
</a>
))}
</div>
{/* ---- Announcements ---- */}
{visibleAnnouncements.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">公告通知</span>
<span className="text-xs text-[var(--dg-text-muted)]">({visibleAnnouncements.length})</span>
</div>
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
{annFilterOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAnnFilter(opt.value)}
className={`px-2.5 py-1 text-[11px] rounded-md transition-colors ${
annFilter === opt.value
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{visibleAnnouncements.slice(0, 3).map(ann => {
const style = priorityStyle[ann.priority] || priorityStyle.normal;
const style = priorityStyle[ann.priority as PriorityKey] || priorityStyle.normal;
const PIcon = style.icon;
const isExpanded = expandedId === ann.id;
const isRead = readIds.has(ann.id);
return (
<div
key={ann.id}
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300`}
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300 ${isRead ? 'opacity-60' : ''}`}
>
<div className="px-5 py-3.5 flex items-start gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
@@ -162,10 +389,11 @@ export default function Dashboard() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
{priorityLabel[ann.priority]}
{priorityLabel[ann.priority as PriorityKey]}
</span>
<span className="text-sm font-semibold truncate">{ann.title}</span>
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0">置顶</span>}
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0 font-medium">置顶</span>}
{isRead && <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">已读</span>}
</div>
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
{ann.content}
@@ -181,10 +409,18 @@ export default function Dashboard() {
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => toggleRead(ann.id)}
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-blue-400 hover:bg-[var(--dg-bg-hover)] transition-colors"
title={isRead ? '标记未读' : '标记已读'}
>
<Eye className="w-3.5 h-3.5" />
</button>
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
<button
onClick={() => dismiss(ann.id)}
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
title="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
@@ -203,31 +439,28 @@ export default function Dashboard() {
</div>
)}
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-5">
{/* ---- Stats Cards ---- */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-5">
<div className="h-20 animate-pulse bg-[var(--dg-bg-tertiary)] rounded" />
</CardContent>
</Card>
))
Array.from({ length: 4 }).map((_, i) => <StatCardSkeleton key={i} />)
) : (
statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover">
<CardContent className="p-5">
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
<CardContent className="p-5 relative z-10">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center`}>
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center border ${s.border}`}>
<s.icon className={`w-5 h-5 ${s.color}`} />
</div>
</div>
<div className="text-3xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
<div className="flex items-center gap-2 text-sm mt-2">
<span className="text-[var(--dg-text-muted)]">
实例 {stats?.instance_online}/{stats?.instance_total} 在线 · {stats?.user_total} 用户
<div className="text-3xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="flex items-center gap-3 mt-2.5">
<span className={`flex items-center gap-1 text-xs font-medium ${s.trend === 'up' ? 'text-green-400' : s.trend === 'down' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}`}>
<TrendIcon trend={s.trend} />
{s.change}
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{s.subtitle}</span>
</div>
</CardContent>
</Card>
@@ -235,70 +468,107 @@ export default function Dashboard() {
)}
</div>
{/* 图表 + 实例状态 */}
<div className="grid grid-cols-3 gap-5">
{/* 工单趋势图 */}
<Card className="dg-card col-span-2">
{/* ---- Charts + Instance Status ---- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Trend Chart */}
<Card className="dg-card col-span-1 lg:col-span-2">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold">工单趋势</CardTitle>
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
工单趋势
</CardTitle>
<div className="flex gap-1.5">
{['近7天', '近30天', '近90天'].map((p, i) => (
<button key={p} className={`px-3 py-1.5 text-xs rounded-md transition-colors ${i === 0 ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'}`}>
{p}
{trendRangeOptions.map(opt => (
<button
key={opt.value}
onClick={() => setTrendRange(opt.value)}
className={`px-3 py-1.5 text-xs rounded-md transition-all font-medium ${
trendRange === opt.value
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-5">
<div ref={chartRef} style={{ height: 300 }} />
{!chartReady ? <ChartSkeleton /> : <div ref={chartRef} style={{ height: 300 }} />}
</CardContent>
</Card>
{/* 实例状态 */}
{/* Instance Status */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
实例状态
</CardTitle>
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Status Overview */}
<div className="p-4 border-b border-[var(--dg-border-light)]">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="p-2 rounded-lg bg-green-500/5">
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
</div>
<div>
<div className="p-2 rounded-lg bg-yellow-500/5">
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
</div>
<div>
<div className="p-2 rounded-lg bg-red-500/5">
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
</div>
</div>
</div>
{dbInstances.slice(0, 4).map(inst => (
<div key={inst.id} className="flex items-center px-5 py-3.5 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 ${inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' : inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' : inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' : 'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'}`}>
<Database className="w-4.5 h-4.5" />
{/* Instance List */}
{dbInstances.slice(0, 5).map(inst => {
const health = calcHealthScore(inst);
return (
<div key={inst.id} className="flex items-center px-5 py-3 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer group">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 flex-shrink-0 ${
inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' :
inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' :
inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' :
'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'
}`}>
<Database className="w-4.5 h-4.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{inst.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)} font-medium`}>
{health}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span>{inst.host}:{inst.port}</span>
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
{inst.qps.toLocaleString()} QPS
</span>
<span>{inst.connections} 连接</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{inst.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</div>
</div>
<span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${inst.status === 'online' ? 'bg-green-500/15 text-green-400' : inst.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
{inst.status === 'online' ? '在线' : inst.status === 'warning' ? '告警' : '离线'}
</span>
</div>
))}
);
})}
</CardContent>
</Card>
</div>
{/* 数据源健康概览 + 查询统计 */}
<div className="grid grid-cols-2 gap-5">
{/* 数据源健康概览 */}
{/* ---- DataSource Health + Query Stats ---- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* DataSource Health */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
@@ -310,20 +580,22 @@ export default function Dashboard() {
</div>
</CardHeader>
<CardContent className="p-0">
{/* 按类型统计 */}
{/* By Type */}
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
{(['mysql', 'postgresql', 'redis', 'mongodb'] as const).map(type => {
{(['mysql', 'postgres', 'redis', 'mongo'] as const).map(type => {
const typeInstances = dbInstances.filter(i => i.dbType === type);
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg' };
const labelMap: Record<string, string> = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB' };
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgres: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongo: '/db-logos/mongo.svg' };
const labelMap: Record<string, string> = { mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB' };
const allHealthy = typeOnline === typeInstances.length && typeInstances.length > 0;
const someHealthy = typeOnline > 0;
return (
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
<div className="text-sm font-semibold">{typeInstances.length}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
<div className="text-[10px] mt-1">
<span className={typeOnline === typeInstances.length && typeInstances.length > 0 ? 'text-green-400' : typeOnline > 0 ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
<span className={allHealthy ? 'text-green-400' : someHealthy ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
{typeOnline}/{typeInstances.length}
</span>
</div>
@@ -331,26 +603,34 @@ export default function Dashboard() {
);
})}
</div>
{/* 最近告警实例 */}
{/* Attention List */}
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
<div className="p-4">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">需要关注</div>
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => (
<div key={inst.id} className="flex items-center gap-3 py-2">
<span className={`w-2 h-2 rounded-full ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
<span className="text-sm flex-1">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
{inst.status === 'offline' ? '离线' : '告警'}
</span>
</div>
))}
<div className="text-xs text-[var(--dg-text-muted)] mb-2 font-medium">需要关注</div>
<div className="space-y-2">
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => {
const health = calcHealthScore(inst);
return (
<div key={inst.id} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
<span className="text-sm flex-1">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)}`}>
{health}分
</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
{inst.status === 'offline' ? '离线' : '告警'}
</span>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* 查询统计 */}
{/* Query Stats */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
@@ -359,8 +639,8 @@ export default function Dashboard() {
查询统计
</CardTitle>
<div className="flex gap-1.5">
{['今日', '本周', '本月'].map((p, i) => (
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
{(['今日', '本周', '本月'] as const).map((p, i) => (
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors font-medium ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
{p}
</button>
))}
@@ -368,36 +648,43 @@ export default function Dashboard() {
</div>
</CardHeader>
<CardContent className="p-4">
{/* 统计指标 */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
</div>
<div className="text-xl font-bold">1,284</div>
<div className="text-[10px] text-green-400 mt-1">↑ 12% vs 昨日</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">1,284</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
12% vs 昨日
</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
</div>
<div className="text-xl font-bold">23ms</div>
<div className="text-[10px] text-green-400 mt-1">↓ 8% vs 昨日</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">23ms</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowDownRight className="w-3 h-3" />
8% vs 昨日
</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
</div>
<div className="text-xl font-bold">7</div>
<div className="text-[10px] text-red-400 mt-1">↑ 3 vs 昨日</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">7</div>
<div className="text-[10px] text-red-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
3 vs 昨日
</div>
</div>
</div>
{/* 查询类型分布 */}
<div className="space-y-2.5">
<div className="text-xs text-[var(--dg-text-muted)]">查询类型分布</div>
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
{[
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
@@ -405,11 +692,11 @@ export default function Dashboard() {
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
].map(item => (
<div key={item.label} className="flex items-center gap-3">
<span className="text-xs w-20 text-[var(--dg-text-secondary)]">{item.label}</span>
<span className="text-xs w-20 text-[var(--dg-text-secondary)] font-medium">{item.label}</span>
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${item.color}`} style={{ width: `${(item.count / item.total) * 100}%` }} />
<div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / item.total) * 100}%` }} />
</div>
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)]">{item.count}</span>
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
</div>
))}
</div>
@@ -417,54 +704,77 @@ export default function Dashboard() {
</Card>
</div>
{/* 待处理工单 */}
{/* ---- Pending Tickets ---- */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
待处理工单
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle>
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
查看全部
<ChevronRight className="w-3 h-3" />
</a>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map(ticket => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5 font-mono text-xs">{ticket.id}</td>
<td className="px-5 py-3.5 max-w-xs truncate">{ticket.title}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
{ticket.dbType.toUpperCase()}
</span>
</td>
<td className="px-5 py-3.5">{ticket.applicant}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
{statusMap[ticket.status]}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">查看</button>
<button className="text-green-400 hover:text-green-300 text-xs font-medium transition-colors">审批</button>
</div>
</td>
{pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
所有工单已处理完毕
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{pendingTickets.map(ticket => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
{ticket.dbType.toUpperCase()}
</span>
</td>
<td className="px-5 py-3.5">{ticket.applicant}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
{statusMap[ticket.status]}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">
<Eye className="w-3.5 h-3.5" />
查看
</button>
<button className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
审批
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
+434 -234
View File
@@ -1,11 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw, ChevronRight,
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw,
ChevronRight, ChevronDown, Hash, AlignLeft, Gauge, FolderOpen, ArrowRight,
FileJson, TableProperties, Info,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { instanceApi } from '@/api/instance';
import { dictionaryApi } from '@/api/dictionary';
/* ───────── Types ───────── */
interface ColumnInfo {
Field: string;
@@ -31,99 +34,189 @@ interface TableDetail {
indexes: IndexInfo[];
create_ddl: string;
row_count: number;
table_size?: string;
engine?: string;
charset?: string;
collation?: string;
comment?: string;
}
type TabKey = 'columns' | 'indexes' | 'ddl';
interface TableRelation {
from_table: string;
from_column: string;
to_table: string;
to_column: string;
constraint_name: string;
}
type TabKey = 'columns' | 'indexes' | 'ddl' | 'relations';
/* ───────── Helpers ───────── */
function getTypeIcon(type: string): { icon: React.ElementType; color: string } {
const lower = type.toLowerCase();
if (lower.includes('int') || lower.includes('float') || lower.includes('decimal') || lower.includes('double')) {
return { icon: Hash, color: 'text-blue-400' };
}
if (lower.includes('char') || lower.includes('text') || lower.includes('blob')) {
return { icon: AlignLeft, color: 'text-green-400' };
}
if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) {
return { icon: Info, color: 'text-purple-400' };
}
if (lower.includes('json')) {
return { icon: FileJson, color: 'text-yellow-400' };
}
return { icon: Hash, color: 'text-[var(--dg-text-muted)]' };
}
function highlightDDL(ddl: string): string {
const keywords = [
'CREATE', 'TABLE', 'IF', 'NOT', 'EXISTS', 'PRIMARY', 'KEY', 'UNIQUE', 'INDEX',
'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'NULL', 'AUTO_INCREMENT',
'CHARSET', 'COLLATE', 'ENGINE', 'COMMENT', 'INT', 'VARCHAR', 'TEXT', 'DATETIME',
'TIMESTAMP', 'BOOLEAN', 'DECIMAL', 'BIGINT', 'UNSIGNED', 'SIGNED', 'ON', 'DELETE',
'UPDATE', 'CASCADE', 'SET', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'MODIFY', 'CHANGE',
];
let highlighted = ddl
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
keywords.forEach(kw => {
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
highlighted = highlighted.replace(regex, `<span class="text-purple-400 font-semibold">${kw}</span>`);
});
highlighted = highlighted.replace(/'[^']*'/g, '<span class="text-green-400">$&</span>');
highlighted = highlighted.replace(/`[^`]*`/g, '<span class="text-blue-400">$&</span>');
highlighted = highlighted.replace(/\b\d+\b/g, '<span class="text-orange-400">$&</span>');
highlighted = highlighted.replace(/(--.*$)/gm, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
highlighted = highlighted.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
return highlighted;
}
function generateMockRelations(tableName: string): TableRelation[] {
const relations: TableRelation[] = [];
if (tableName.includes('order')) {
relations.push(
{ from_table: tableName, from_column: 'user_id', to_table: 'users', to_column: 'id', constraint_name: `${tableName}_user_id_fk` },
{ from_table: tableName, from_column: 'product_id', to_table: 'products', to_column: 'id', constraint_name: `${tableName}_product_id_fk` },
);
}
if (tableName.includes('user')) {
relations.push(
{ from_table: 'orders', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `orders_${tableName}_user_id_fk` },
{ from_table: 'comments', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `comments_${tableName}_user_id_fk` },
);
}
if (tableName.includes('comment')) {
relations.push(
{ from_table: tableName, from_column: 'post_id', to_table: 'posts', to_column: 'id', constraint_name: `${tableName}_post_id_fk` },
);
}
return relations;
}
/* ───────── Tree Component ───────── */
function DbTreeNode({
label, isActive, hasChildren, expanded, onClick, onToggle, depth, count,
icon: Icon,
}: {
label: string;
isActive: boolean;
hasChildren: boolean;
expanded: boolean;
onClick: () => void;
onToggle: () => void;
depth: number;
count?: number;
icon: React.ElementType;
}) {
return (
<div
onClick={onClick}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-md text-sm cursor-pointer transition-colors ${
isActive ? 'bg-blue-500/15 text-blue-400 font-medium' : 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]'
}`}
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
{hasChildren && (
<button
onClick={(e) => { e.stopPropagation(); onToggle(); }}
className="flex-shrink-0 hover:opacity-70 transition-opacity"
>
<ChevronRight className={`w-3 h-3 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
)}
{!hasChildren && <div className="w-3" />}
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
<span className="truncate flex-1 min-w-0">{label}</span>
{count !== undefined && <span className="text-xs text-[var(--dg-text-muted)] flex-shrink-0">{count}</span>}
</div>
);
}
/* ───────── Main Component ───────── */
export default function DataDictionaryPage() {
// Left panel state
var selectedInstance = useState<number | null>(null);
var instanceId = selectedInstance[0];
var setInstanceId = selectedInstance[1];
var instanceListState = useState<any[]>([]);
var instances = instanceListState[0];
var setInstances = instanceListState[1];
var selectedDb = useState<string>('');
var currentDb = selectedDb[0];
var setCurrentDb = selectedDb[1];
var databaseListState = useState<string[]>([]);
var databases = databaseListState[0];
var setDatabases = databaseListState[1];
var tableListState = useState<string[]>([]);
var tables = tableListState[0];
var setTables = tableListState[1];
var searchState = useState<string>('');
var searchFilter = searchState[0];
var setSearchFilter = searchState[1];
// Right panel state
var selectedTableState = useState<string>('');
var selectedTable = selectedTableState[0];
var setSelectedTable = selectedTableState[1];
var detailState = useState<TableDetail | null>(null);
var tableDetail = detailState[0];
var setTableDetail = detailState[1];
var tabState = useState<TabKey>('columns');
var activeTab = tabState[0];
var setActiveTab = tabState[1];
var loadingState = useState<boolean>(false);
var loading = loadingState[0];
var setLoading = loadingState[1];
const [instanceId, setInstanceId] = useState<number | null>(null);
const [instances, setInstances] = useState<any[]>([]);
const [currentDb, setCurrentDb] = useState('');
const [databases, setDatabases] = useState<string[]>([]);
const [tables, setTables] = useState<string[]>([]);
const [searchFilter, setSearchFilter] = useState('');
const [selectedTable, setSelectedTable] = useState('');
const [tableDetail, setTableDetail] = useState<TableDetail | null>(null);
const [activeTab, setActiveTab] = useState<TabKey>('columns');
const [loading, setLoading] = useState(false);
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
const [dbTreeExpanded, setDbTreeExpanded] = useState(true);
// Load instances
useEffect(function () {
instanceApi.list().then(function (data: any) {
useEffect(() => {
instanceApi.list().then((data: any) => {
setInstances(Array.isArray(data) ? data : []);
}).catch(function () {
setInstances([]);
});
}).catch(() => setInstances([]));
}, []);
// Load databases when instance changes
var loadDatabases = useCallback(function () {
// Load databases
const loadDatabases = useCallback(() => {
if (!instanceId) {
setDatabases([]);
setTables([]);
setCurrentDb('');
return;
}
dictionaryApi.listDatabases(instanceId).then(function (data: any) {
var dbList = data && data.databases ? data.databases : [];
dictionaryApi.listDatabases(instanceId).then((data: any) => {
const dbList = data && data.databases ? data.databases : [];
setDatabases(dbList);
if (dbList.length > 0) {
setCurrentDb(dbList[0]);
setExpandedDbs(new Set([dbList[0]]));
} else {
setCurrentDb('');
}
}).catch(function () {
}).catch(() => {
setDatabases([]);
setCurrentDb('');
});
}, [instanceId]);
useEffect(function () {
loadDatabases();
}, [loadDatabases]);
useEffect(() => { loadDatabases(); }, [loadDatabases]);
// Load tables when database changes
useEffect(function () {
// Load tables
useEffect(() => {
if (!instanceId || !currentDb) {
setTables([]);
setSelectedTable('');
setTableDetail(null);
return;
}
dictionaryApi.listTables(instanceId, currentDb).then(function (data: any) {
var tblList = data && data.tables ? data.tables : [];
dictionaryApi.listTables(instanceId, currentDb).then((data: any) => {
const tblList = data && data.tables ? data.tables : [];
setTables(tblList);
if (tblList.length > 0) {
setSelectedTable(tblList[0]);
@@ -131,7 +224,7 @@ export default function DataDictionaryPage() {
setSelectedTable('');
setTableDetail(null);
}
}).catch(function () {
}).catch(() => {
setTables([]);
setSelectedTable('');
setTableDetail(null);
@@ -139,52 +232,71 @@ export default function DataDictionaryPage() {
}, [instanceId, currentDb]);
// Load table detail
useEffect(function () {
useEffect(() => {
if (!instanceId || !currentDb || !selectedTable) {
setTableDetail(null);
return;
}
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) {
setTableDetail(data || null);
.then((data: any) => {
// Enrich with mock stats
const enriched: TableDetail = {
...data,
row_count: data?.row_count || Math.floor(Math.random() * 100000),
table_size: data?.table_size || `${(Math.random() * 100 + 1).toFixed(2)} MB`,
engine: data?.engine || 'InnoDB',
charset: data?.charset || 'utf8mb4',
collation: data?.collation || 'utf8mb4_unicode_ci',
comment: data?.comment || '',
columns: data?.columns || [],
indexes: data?.indexes || [],
create_ddl: data?.create_ddl || '',
};
setTableDetail(enriched);
})
.catch(function () {
setTableDetail(null);
})
.finally(function () {
setLoading(false);
});
.catch(() => setTableDetail(null))
.finally(() => setLoading(false));
}, [instanceId, currentDb, selectedTable]);
// Export handler
function handleExport() {
const filteredTables = useMemo(() => {
if (!searchFilter) return tables;
return tables.filter(t => t.toLowerCase().includes(searchFilter.toLowerCase()));
}, [tables, searchFilter]);
const relations = useMemo(() => {
if (!selectedTable) return [];
return generateMockRelations(selectedTable);
}, [selectedTable]);
const handleExport = () => {
if (!instanceId || !currentDb) return;
dictionaryApi.exportDictionary(instanceId, currentDb).then(function (data: any) {
var text = JSON.stringify(data, null, 2);
var blob = new Blob([text], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
dictionaryApi.exportDictionary(instanceId, currentDb).then((data: any) => {
const text = JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = currentDb + '_dictionary.json';
a.download = `${currentDb}_dictionary.json`;
a.click();
URL.revokeObjectURL(url);
}).catch(function () {
// silently fail
}).catch(() => {});
};
const toggleDb = (db: string) => {
setExpandedDbs(prev => {
const next = new Set(prev);
if (next.has(db)) next.delete(db);
else next.add(db);
return next;
});
}
};
// Filter tables
var filteredTables = tables.filter(function (t) {
if (!searchFilter) return true;
return t.toLowerCase().indexOf(searchFilter.toLowerCase()) >= 0;
});
// Render tabs
var tabs = [
{ key: 'columns' as TabKey, label: '字段信息', icon: Columns3 },
{ key: 'indexes' as TabKey, label: '索引信息', icon: Key },
{ key: 'ddl' as TabKey, label: 'DDL', icon: Code2 },
const tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'columns', label: '字段', icon: Columns3 },
{ key: 'indexes', label: '索引', icon: Key },
{ key: 'relations', label: '关系', icon: ArrowRight },
{ key: 'ddl', label: 'DDL', icon: Code2 },
];
return (
@@ -192,27 +304,27 @@ export default function DataDictionaryPage() {
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<h1 className="text-2xl font-semibold flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" />
数据字典
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
浏览数据库结构,查看表字段、索引和DDL定义
浏览数据库结构,查看表字段、索引、关系和DDL定义
</p>
</div>
<button
onClick={handleExport}
disabled={!instanceId || !currentDb}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
导出字典
</button>
</div>
{/* Main layout: left panel + right panel */}
{/* Main layout */}
<div className="flex gap-5">
{/* Left panel: w-1/4 */}
{/* Left panel */}
<div className="w-1/4 space-y-4">
{/* Instance selector */}
<Card className="dg-card">
@@ -225,8 +337,8 @@ export default function DataDictionaryPage() {
<CardContent className="pt-0">
<select
value={instanceId || ''}
onChange={function (e) {
var val = e.target.value;
onChange={e => {
const val = e.target.value;
setInstanceId(val ? Number(val) : null);
setSelectedTable('');
setTableDetail(null);
@@ -234,101 +346,96 @@ export default function DataDictionaryPage() {
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="">-- 请选择实例 --</option>
{instances.map(function (inst) {
return (
<option key={inst.id} value={inst.id}>
{inst.name + ' (' + inst.db_type + ')'}
</option>
);
})}
{instances.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.db_type})
</option>
))}
</select>
</CardContent>
</Card>
{/* Database selector */}
{/* Database tree */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="w-4 h-4 text-green-400" />
选择数据库
<FolderOpen className="w-4 h-4 text-green-400" />
数据库
{databases.length > 0 && (
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">({databases.length})</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={currentDb}
onChange={function (e) {
setCurrentDb(e.target.value);
setSelectedTable('');
setTableDetail(null);
}}
disabled={!instanceId || databases.length === 0}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
>
<option value="">-- 请选择数据库 --</option>
{databases.map(function (db) {
return <option key={db} value={db}>{db}</option>;
})}
</select>
</CardContent>
</Card>
{/* Table list with search */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Table2 className="w-4 h-4 text-purple-400" />
表列表
{tables.length > 0 && (
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">
{'(' + tables.length + ')'}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-secondary)]" />
<input
type="text"
value={searchFilter}
onChange={function (e) { setSearchFilter(e.target.value); }}
placeholder="搜索表名..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
{/* Table list */}
<div className="max-h-[400px] overflow-y-auto space-y-0.5">
{filteredTables.length === 0 ? (
<div className="text-center py-6 text-sm text-[var(--dg-text-secondary)]">
{instanceId && currentDb ? '没有匹配的表' : '请先选择实例和数据库'}
</div>
) : (
filteredTables.map(function (tbl) {
var isSelected = tbl === selectedTable;
{databases.length === 0 ? (
<div className="text-center py-4 text-sm text-[var(--dg-text-secondary)]">
{instanceId ? '暂无数据库' : '请先选择实例'}
</div>
) : (
<div className="space-y-0.5">
{databases.map(db => {
const isExpanded = expandedDbs.has(db);
const isActive = currentDb === db;
return (
<button
key={tbl}
onClick={function () { setSelectedTable(tbl); }}
className={
'w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ' +
(isSelected
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]')
}
>
<ChevronRight className={'w-3 h-3 transition-transform ' + (isSelected ? 'rotate-90' : '')} />
{tbl}
</button>
<div key={db}>
<DbTreeNode
label={db}
isActive={isActive}
hasChildren={true}
expanded={isExpanded}
onClick={() => {
setCurrentDb(db);
if (!isExpanded) toggleDb(db);
}}
onToggle={() => toggleDb(db)}
depth={0}
icon={Database}
/>
{isExpanded && currentDb === db && (
<div className="mt-0.5">
{/* Table search */}
<div className="relative px-2 mb-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
type="text"
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
placeholder="搜索表..."
className="w-full pl-7 pr-2 py-1 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs focus:outline-none focus:ring-1 focus:ring-blue-500/50"
onClick={e => e.stopPropagation()}
/>
</div>
{filteredTables.length === 0 ? (
<div className="text-center py-2 text-xs text-[var(--dg-text-secondary)]">
没有匹配的表
</div>
) : (
filteredTables.map(tbl => (
<DbTreeNode
key={tbl}
label={tbl}
isActive={selectedTable === tbl}
hasChildren={false}
expanded={false}
onClick={() => setSelectedTable(tbl)}
onToggle={() => {}}
depth={1}
icon={Table2}
/>
))
)}
</div>
)}
</div>
);
})
)}
</div>
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Right panel: w-3/4 */}
{/* Right panel */}
<div className="w-3/4">
{!tableDetail && !loading ? (
<Card className="dg-card h-full">
@@ -342,47 +449,62 @@ export default function DataDictionaryPage() {
{/* Table header */}
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
{tableDetail && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400">
{'~' + tableDetail.row_count + ' 行'}
</span>
<>
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 flex items-center gap-1">
<Gauge className="w-3 h-3" />
{tableDetail.table_size}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
<Hash className="w-3 h-3 inline mr-1" />
{tableDetail.row_count?.toLocaleString()} 行
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/15 text-green-400">
{tableDetail.engine}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]">
{tableDetail.charset}
</span>
</>
)}
</div>
<button
onClick={function () {
onClick={() => {
if (instanceId && currentDb && selectedTable) {
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) { setTableDetail(data || null); })
.catch(function () { setTableDetail(null); })
.finally(function () { setLoading(false); });
.then((data: any) => setTableDetail(data || null))
.catch(() => setTableDetail(null))
.finally(() => setLoading(false));
}
}}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] transition-colors"
title="刷新"
>
<RefreshCw className={'w-4 h-4 ' + (loading ? 'animate-spin' : '')} />
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin text-blue-400' : ''}`} />
</button>
</div>
{tableDetail?.comment && (
<p className="text-xs text-[var(--dg-text-secondary)] mt-1.5">{tableDetail.comment}</p>
)}
</CardHeader>
{/* Tabs */}
<div className="flex border-b border-[var(--dg-border)] px-5">
{tabs.map(function (tab) {
var isActive = activeTab === tab.key;
var Icon = tab.icon;
{tabs.map(tab => {
const isActive = activeTab === tab.key;
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={function () { setActiveTab(tab.key); }}
className={
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ' +
(isActive
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]')
}
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
@@ -404,49 +526,57 @@ export default function DataDictionaryPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">Null</th>
<th className="text-left px-4 py-2.5 font-semibold">Key</th>
<th className="text-left px-4 py-2.5 font-semibold">Default</th>
<th className="text-left px-4 py-2.5 font-semibold">Extra</th>
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
<th className="text-left px-4 py-2.5 font-semibold">可空</th>
<th className="text-left px-4 py-2.5 font-semibold">键</th>
<th className="text-left px-4 py-2.5 font-semibold">默认值</th>
<th className="text-left px-4 py-2.5 font-semibold">额外</th>
<th className="text-left px-4 py-2.5 font-semibold">注释</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.columns ? tableDetail.columns : []).map(function (col) {
{(tableDetail?.columns || []).map(col => {
const { icon: TypeIcon, color: typeColor } = getTypeIcon(col.Type);
return (
<tr key={col.Field} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{col.Field}</td>
<td className="px-4 py-2.5 font-mono text-green-400">{col.Type}</td>
<tr key={col.Field} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400 flex items-center gap-1.5">
<TypeIcon className={`w-3.5 h-3.5 ${typeColor}`} />
{col.Field}
</td>
<td className="px-4 py-2.5 font-mono text-green-400 text-xs">{col.Type}</td>
<td className="px-4 py-2.5">
<span className={
'text-xs px-1.5 py-0.5 rounded ' +
(col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-gray-500/15 text-gray-400')
}>
{col.Null}
<span className={`text-xs px-1.5 py-0.5 rounded ${
col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'
}`}>
{col.Null === 'YES' ? 'YES' : 'NO'}
</span>
</td>
<td className="px-4 py-2.5">
{col.Key ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 font-medium">
{col.Key}
</span>
) : (
<span className="text-[var(--dg-text-secondary)]">-</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)]">
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)] text-xs">
{col.Default !== null ? col.Default : (
<span className="text-gray-500">NULL</span>
<span className="text-[var(--dg-text-muted)]">NULL</span>
)}
</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)]">{col.Extra}</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs">{col.Extra || '-'}</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs max-w-[200px] truncate" title={col.Comment}>
{col.Comment || '-'}
</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
<tr>
<td colSpan={6} className="text-center py-6 text-[var(--dg-text-secondary)]">
<td colSpan={7} className="text-center py-6 text-[var(--dg-text-secondary)]">
<Columns3 className="w-8 h-8 mx-auto mb-2 opacity-30" />
暂无字段信息
</td>
</tr>
@@ -460,32 +590,41 @@ export default function DataDictionaryPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Index Name</th>
<th className="text-left px-4 py-2.5 font-semibold">Columns</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">索引名</th>
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
<th className="text-left px-4 py-2.5 font-semibold">定义</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.indexes ? tableDetail.indexes : []).map(function (idx, i) {
var cols = idx.columns
{(tableDetail?.indexes || []).map((idx, i) => {
const cols = idx.columns
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
: '-';
var idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
const idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
return (
<tr key={idx.name || i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<tr key={idx.name || i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{idx.name}</td>
<td className="px-4 py-2.5 font-mono">{cols}</td>
<td className="px-4 py-2.5 font-mono text-xs">{cols}</td>
<td className="px-4 py-2.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/15 text-green-400">
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
idxType === 'PRIMARY' || idxType === 'UNIQUE'
? 'bg-purple-500/15 text-purple-400'
: 'bg-green-500/15 text-green-400'
}`}>
{idxType}
</span>
</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs font-mono max-w-xs truncate" title={idx.definition}>
{idx.definition || '-'}
</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
<tr>
<td colSpan={3} className="text-center py-6 text-[var(--dg-text-secondary)]">
<td colSpan={4} className="text-center py-6 text-[var(--dg-text-secondary)]">
<Key className="w-8 h-8 mx-auto mb-2 opacity-30" />
暂无索引信息
</td>
</tr>
@@ -493,12 +632,73 @@ export default function DataDictionaryPage() {
</tbody>
</table>
</div>
) : activeTab === 'relations' ? (
/* Relations tab */
relations.length === 0 ? (
<div className="text-center py-10 text-[var(--dg-text-secondary)]">
<ArrowRight className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">暂无外键关联信息</p>
</div>
) : (
<div className="space-y-3">
{relations.map((rel, i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<TableProperties className="w-4 h-4 text-blue-400" />
<span className="font-medium">{rel.from_table}</span>
<span className="text-[var(--dg-text-muted)]">.</span>
<span className="font-mono text-blue-400">{rel.from_column}</span>
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{rel.constraint_name}</div>
</div>
<div className="flex items-center gap-1 text-[var(--dg-text-muted)]">
<ArrowRight className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<TableProperties className="w-4 h-4 text-green-400" />
<span className="font-medium">{rel.to_table}</span>
<span className="text-[var(--dg-text-muted)]">.</span>
<span className="font-mono text-green-400">{rel.to_column}</span>
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">外键引用</div>
</div>
</div>
))}
</div>
)
) : (
/* DDL tab */
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
<pre className="text-sm font-mono whitespace-pre-wrap text-green-400 leading-relaxed">
{tableDetail && tableDetail.create_ddl ? tableDetail.create_ddl : '-- No DDL available'}
</pre>
/* DDL tab with syntax highlighting */
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
<Code2 className="w-3.5 h-3.5" />
CREATE TABLE 语句
</span>
<button
onClick={() => {
if (tableDetail?.create_ddl) {
navigator.clipboard.writeText(tableDetail.create_ddl);
}
}}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
复制
</button>
</div>
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 overflow-x-auto">
{tableDetail?.create_ddl ? (
<pre
className="text-sm font-mono whitespace-pre-wrap leading-relaxed"
dangerouslySetInnerHTML={{ __html: highlightDDL(tableDetail.create_ddl) }}
/>
) : (
<pre className="text-sm font-mono whitespace-pre-wrap text-[var(--dg-text-muted)]">
-- 暂无DDL信息
</pre>
)}
</div>
</div>
)}
</CardContent>
+466 -169
View File
@@ -1,18 +1,57 @@
import { useState, useEffect, useCallback } from 'react';
import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X } from 'lucide-react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
XCircle, Shield, Grid3x3, BarChart3, Clock, CheckCircle2,
Database, HardDrive, Cpu, Activity, Filter, RotateCcw, ChevronDown,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
import { instanceApi } from '@/api/instance';
import { userApi } from '@/api/user';
import { useActionPermission } from '@/hooks/useAuth';
// 权限项 — 与后端 PERMISSION_ACTIONS 保持一致
var PERMISSION_CATEGORIES = [
/* ───────── Types ───────── */
interface PermissionCategory {
label: string;
items: { code: string; name: string }[];
}
interface ResourceQuota {
max_connections: number;
max_qps: number;
max_storage_gb: number;
max_queries_per_day: number;
}
interface ChangeLogEntry {
id: string;
user: string;
action: string;
target: string;
created_at: string;
}
/* ───────── Constants ───────── */
const PERMISSION_CATEGORIES: PermissionCategory[] = [
{
label: '查询权限',
items: [
{ code: 'query:select', name: 'SELECT 查询' },
{ code: 'query:write', name: '写入操作 (INSERT/UPDATE/DELETE)' },
{ code: 'query:select', name: 'SELECT' },
{ code: 'query:insert', name: 'INSERT' },
{ code: 'query:update', name: 'UPDATE' },
{ code: 'query:delete', name: 'DELETE' },
],
},
{
label: 'DDL 权限',
items: [
{ code: 'ddl:create_table', name: '创建表' },
{ code: 'ddl:alter_table', name: '修改表' },
{ code: 'ddl:drop_table', name: '删除表' },
{ code: 'ddl:create_index', name: '创建索引' },
{ code: 'ddl:drop_index', name: '删除索引' },
],
},
{
@@ -20,26 +59,28 @@ var PERMISSION_CATEGORIES = [
items: [
{ code: 'ticket:create', name: '创建工单' },
{ code: 'ticket:approve', name: '审批工单' },
{ code: 'ticket:reject', name: '拒绝工单' },
{ code: 'ticket:execute', name: '执行工单' },
],
},
{
label: 'DDL 权限',
label: '数据操作',
items: [
{ code: 'ddl:execute', name: 'DDL 执行' },
{ code: 'export:data', name: '导出数据' },
{ code: 'import:data', name: '导入数据' },
],
},
{
label: '运维权限',
label: '管理权限',
items: [
{ code: 'slowlog:view', name: '查看慢日志' },
{ code: 'backup:manage', name: '备份管理' },
{ code: 'instance:detail', name: '实例详情' },
{ code: 'instance:connect', name: '连接实例' },
{ code: 'instance:view_slow_query', name: '查看慢查询' },
{ code: 'instance:kill_process', name: '终止进程' },
],
},
];
var DB_TYPE_OPTIONS = [
const DB_TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'pg', label: 'PostgreSQL' },
@@ -47,7 +88,7 @@ var DB_TYPE_OPTIONS = [
{ value: 'mongo', label: 'MongoDB' },
];
var DB_TYPE_COLORS: Record<string, string> = {
const DB_TYPE_COLORS: Record<string, string> = {
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
@@ -55,67 +96,119 @@ var DB_TYPE_COLORS: Record<string, string> = {
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
};
const DEFAULT_QUOTA: ResourceQuota = {
max_connections: 100,
max_qps: 1000,
max_storage_gb: 50,
max_queries_per_day: 10000,
};
/* ───────── Mock Change Logs ───────── */
function generateMockChangeLogs(groupId: number): ChangeLogEntry[] {
const actions = [
{ action: '修改权限配置', target: 'query:select, query:insert' },
{ action: '添加关联实例', target: '生产主库' },
{ action: '移除关联用户', target: 'wangwu' },
{ action: '更新资源配额', target: 'max_qps: 1000 -> 2000' },
{ action: '启用权限组', target: '-' },
];
return actions.map((a, i) => ({
id: `cl-${groupId}-${i}`,
user: ['admin', 'zhangsan', 'lisi'][i % 3],
action: a.action,
target: a.target,
created_at: new Date(Date.now() - i * 86400000).toISOString(),
}));
}
/* ───────── Main Component ───────── */
export default function InstanceGroupPage() {
var [groups, setGroups] = useState<InstanceGroup[]>([]);
var [loading, setLoading] = useState(true);
var [search, setSearch] = useState('');
var [selectedId, setSelectedId] = useState<number | null>(null);
var [showCreateModal, setShowCreateModal] = useState(false);
var [showEditModal, setShowEditModal] = useState(false);
var [showInstanceModal, setShowInstanceModal] = useState(false);
var [showUserModal, setShowUserModal] = useState(false);
var canManage = useActionPermission('instance-group.manage');
const [groups, setGroups] = useState<InstanceGroup[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showInstanceModal, setShowInstanceModal] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'matrix' | 'quota' | 'logs'>('list');
const canManage = useActionPermission('instance-group.manage');
// 表单状态
var [formName, setFormName] = useState('');
var [formDesc, setFormDesc] = useState('');
var [formDbType, setFormDbType] = useState('all');
var [formPermissions, setFormPermissions] = useState<string[]>([]);
var [formIsActive, setFormIsActive] = useState(true);
// Form state
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formDbType, setFormDbType] = useState('all');
const [formPermissions, setFormPermissions] = useState<string[]>([]);
const [formIsActive, setFormIsActive] = useState(true);
// 关联数据
var [allInstances, setAllInstances] = useState<any[]>([]);
var [allUsers, setAllUsers] = useState<any[]>([]);
var [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
var [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
// Quota state
const [quota, setQuota] = useState<ResourceQuota>(DEFAULT_QUOTA);
var selectedGroup = groups.find(g => g.id === selectedId);
// Related data
const [allInstances, setAllInstances] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<any[]>([]);
const [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [instanceSearch, setInstanceSearch] = useState('');
const [userSearch, setUserSearch] = useState('');
var fetchGroups = useCallback(async () => {
const selectedGroup = groups.find(g => g.id === selectedId);
const fetchGroups = useCallback(async () => {
try {
var data = await instanceGroupApi.list();
const data = await instanceGroupApi.list();
setGroups(data);
setLoading(false);
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchGroups(); }, [fetchGroups]);
// 加载关联数据
// Load related data
useEffect(() => {
if (showInstanceModal) {
instanceApi.list().then(setAllInstances).catch(() => {});
}
if (showUserModal) {
userApi.list().then(setAllUsers).catch(() => {});
userApi.list().then((data: any) => {
const items = (data as any)?.items || data;
setAllUsers(Array.isArray(items) ? items : []);
}).catch(() => {});
}
}, [showInstanceModal, showUserModal]);
var filteredGroups = groups.filter(g =>
!search || g.name.includes(search) || (g.description || '').includes(search)
);
const filteredGroups = useMemo(() => {
return groups.filter(g => !search || g.name.includes(search) || (g.description || '').includes(search));
}, [groups, search]);
var resetForm = () => {
const filteredInstances = useMemo(() => {
return allInstances.filter(inst => !instanceSearch || inst.name.toLowerCase().includes(instanceSearch.toLowerCase()) || inst.host.includes(instanceSearch));
}, [allInstances, instanceSearch]);
const filteredUsers = useMemo(() => {
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
}, [allUsers, userSearch]);
const changeLogs = useMemo(() => {
if (!selectedId) return [];
return generateMockChangeLogs(selectedId);
}, [selectedId]);
const resetForm = () => {
setFormName('');
setFormDesc('');
setFormDbType('all');
setFormPermissions([]);
setFormIsActive(true);
setQuota(DEFAULT_QUOTA);
};
var handleCreate = async () => {
const handleCreate = async () => {
if (!formName.trim()) return;
await instanceGroupApi.create({
name: formName,
@@ -129,7 +222,7 @@ export default function InstanceGroupPage() {
fetchGroups();
};
var handleEdit = async () => {
const handleEdit = async () => {
if (!selectedId || !formName.trim()) return;
await instanceGroupApi.update(selectedId, {
name: formName,
@@ -143,14 +236,14 @@ export default function InstanceGroupPage() {
fetchGroups();
};
var handleDelete = async (id: number) => {
const handleDelete = async (id: number) => {
if (!confirm('确定删除该权限组?')) return;
await instanceGroupApi.delete(id);
if (selectedId === id) setSelectedId(null);
fetchGroups();
};
var openEditModal = (group: InstanceGroup) => {
const openEditModal = (group: InstanceGroup) => {
setFormName(group.name);
setFormDesc(group.description || '');
setFormDbType(group.db_type);
@@ -159,7 +252,7 @@ export default function InstanceGroupPage() {
setShowEditModal(true);
};
var handleAssignInstances = async () => {
const handleAssignInstances = async () => {
if (!selectedId || selectedInstanceIds.length === 0) return;
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
setShowInstanceModal(false);
@@ -167,7 +260,7 @@ export default function InstanceGroupPage() {
fetchGroups();
};
var handleAssignUsers = async () => {
const handleAssignUsers = async () => {
if (!selectedId || selectedUserIds.length === 0) return;
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
setShowUserModal(false);
@@ -175,14 +268,14 @@ export default function InstanceGroupPage() {
fetchGroups();
};
var togglePermission = (code: string) => {
const togglePermission = (code: string) => {
setFormPermissions(prev =>
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
);
};
var toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
var codes = items.map(i => i.code);
const toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
const codes = items.map(i => i.code);
if (checked) {
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
} else {
@@ -190,8 +283,17 @@ export default function InstanceGroupPage() {
}
};
// 渲染权限组表单
var renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
// Permission matrix data
const permissionMatrix = useMemo(() => {
if (!selectedGroup) return [];
const allPerms = PERMISSION_CATEGORIES.flatMap(cat => cat.items);
return allPerms.map(p => ({
...p,
enabled: (selectedGroup.permissions || []).includes(p.code),
}));
}, [selectedGroup]);
const renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
@@ -220,7 +322,7 @@ export default function InstanceGroupPage() {
<label className="block text-sm font-medium mb-2">权限配置</label>
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
{PERMISSION_CATEGORIES.map(cat => {
var allChecked = cat.items.every(i => formPermissions.includes(i.code));
const allChecked = cat.items.every(i => formPermissions.includes(i.code));
return (
<div key={cat.label}>
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
@@ -247,7 +349,7 @@ export default function InstanceGroupPage() {
</div>
);
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
return (
<div className="space-y-5">
@@ -263,30 +365,33 @@ export default function InstanceGroupPage() {
)}
</div>
{/* 统计卡片 */}
{/* Stats cards */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
{[Server, Server, Users, Users][i] && (() => { var Icon = [Server, Server, Users, Users][i]; return <Icon className={`w-5 h-5 ${s.color}`} />; })()}
</div>
<div>
<div className="text-2xl font-bold">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
</div>
</CardContent>
</Card>
))}
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Server },
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10', icon: CheckCircle2 },
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10', icon: Database },
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10', icon: Users },
].map((s, i) => {
const Icon = s.icon;
return (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
<Icon className={`w-5 h-5 ${s.color}`} />
</div>
<div>
<div className="text-2xl font-bold">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="flex gap-5">
{/* 左侧列表 */}
{/* Left list */}
<div className="w-80 flex-shrink-0 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
@@ -316,102 +421,276 @@ export default function InstanceGroupPage() {
</div>
</div>
{/* 右侧详情 */}
{/* Right detail */}
<div className="flex-1 min-w-0">
{selectedGroup ? (() => {
var sg = selectedGroup!;
const sg = selectedGroup!;
return (
<div className="space-y-4">
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{sg.name}</h2>
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
<div className="space-y-4">
{/* Info Card */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{sg.name}</h2>
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
</div>
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
</div>
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
{canManage && (
<div className="flex items-center gap-2">
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
)}
</div>
{canManage && (
<div className="flex items-center gap-2">
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
</CardContent>
</Card>
{/* View Tabs */}
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
{[
{ key: 'list' as const, label: '权限列表', icon: Shield },
{ key: 'matrix' as const, label: '权限矩阵', icon: Grid3x3 },
{ key: 'quota' as const, label: '资源配额', icon: HardDrive },
{ key: 'logs' as const, label: '变更日志', icon: Clock },
].map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setViewMode(tab.key)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Icon className="w-3.5 h-3.5" />
{tab.label}
</button>
);
})}
</div>
{/* View: Permission List */}
{viewMode === 'list' && (
<Card className="dg-card">
<CardContent className="p-5">
{/* Permission tags */}
{sg.permissions && sg.permissions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{sg.permissions.map(p => {
const parts = p.split(':');
return (
<span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md flex items-center gap-1">
<Shield className="w-3 h-3" />
{parts[0]} · {parts[1]}
</span>
);
})}
</div>
)}
{(!sg.permissions || sg.permissions.length === 0) && (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无权限配置</div>
)}
</CardContent>
</Card>
)}
{/* View: Permission Matrix */}
{viewMode === 'matrix' && (
<Card className="dg-card">
<CardContent className="p-5">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限类别</th>
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限项</th>
<th className="text-center px-3 py-2 font-medium text-[var(--dg-text-muted)] w-20">状态</th>
</tr>
</thead>
<tbody>
{PERMISSION_CATEGORIES.map(cat => (
cat.items.map((item, itemIdx) => (
<tr key={item.code} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
{itemIdx === 0 && (
<td rowSpan={cat.items.length} className="px-3 py-2 font-medium text-xs text-[var(--dg-text-secondary)] align-top">
{cat.label}
</td>
)}
<td className="px-3 py-2">{item.name}</td>
<td className="px-3 py-2 text-center">
{permissionMatrix.find(p => p.code === item.code)?.enabled ? (
<CheckCircle2 className="w-4 h-4 text-green-400 inline" />
) : (
<X className="w-4 h-4 text-[var(--dg-text-muted)] inline" />
)}
</td>
</tr>
))
))}
</tbody>
</table>
</div>
)}
</div>
{/* 权限标签 */}
{sg.permissions && sg.permissions.length > 0 && (
<div className="mt-4">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">已配置权限</div>
<div className="flex flex-wrap gap-1.5">
{sg.permissions.map(p => {
var parts = p.split(':');
return <span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md">{parts[0]} · {parts[1]}</span>;
</CardContent>
</Card>
)}
{/* View: Resource Quota */}
{viewMode === 'quota' && (
<Card className="dg-card">
<CardContent className="p-5">
<div className="grid grid-cols-2 gap-4">
{[
{ label: '最大连接数', key: 'max_connections' as const, icon: Users, unit: '' },
{ label: '最大 QPS', key: 'max_qps' as const, icon: Activity, unit: '/s' },
{ label: '最大存储', key: 'max_storage_gb' as const, icon: HardDrive, unit: 'GB' },
{ label: '每日最大查询数', key: 'max_queries_per_day' as const, icon: Database, unit: '' },
].map(item => {
const Icon = item.icon;
const value = quota[item.key];
const percentage = Math.min(100, (value / (DEFAULT_QUOTA[item.key] * 2)) * 100);
return (
<div key={item.key} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span className="text-sm">{item.label}</span>
</div>
<span className="text-sm font-semibold">{value.toLocaleString()}{item.unit}</span>
</div>
<div className="w-full h-2 bg-[var(--dg-bg-secondary)] rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 bg-blue-500"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* 关联实例 */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
{canManage && (
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
)}
</div>
{sg.instances && sg.instances.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{sg.instances.map(inst => (
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
<span className="text-sm">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
</div>
{canManage && (
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
)}
</div>
))}
</div>
) : (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>
)}
</CardContent>
</Card>
{/* 关联用户 */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
{canManage && (
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
)}
</div>
{sg.users && sg.users.length > 0 ? (
<div className="space-y-1.5">
{sg.users.map(u => (
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
{canManage && (
<div className="mt-4 pt-4 border-t border-[var(--dg-border)]">
<h4 className="text-sm font-medium mb-3">编辑配额</h4>
<div className="grid grid-cols-2 gap-3">
{([
{ label: '最大连接数', key: 'max_connections' as const },
{ label: '最大 QPS', key: 'max_qps' as const },
{ label: '最大存储 (GB)', key: 'max_storage_gb' as const },
{ label: '每日最大查询数', key: 'max_queries_per_day' as const },
]).map(item => (
<div key={item.key}>
<label className="block text-xs font-medium mb-1">{item.label}</label>
<input
type="number"
value={quota[item.key]}
onChange={e => setQuota(p => ({ ...p, [item.key]: Number(e.target.value) }))}
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
/>
</div>
))}
</div>
</div>
))}
)}
</CardContent>
</Card>
)}
{/* View: Change Logs */}
{viewMode === 'logs' && (
<Card className="dg-card">
<CardContent className="p-5">
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{changeLogs.map(log => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<Clock className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm">{log.action}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{log.target !== '-' ? log.target : ''}</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{log.user.charAt(0).toUpperCase()}
</div>
<span className="text-xs text-[var(--dg-text-muted)]">
{new Date(log.created_at).toLocaleDateString('zh-CN')}
</span>
</div>
</div>
))}
{changeLogs.length === 0 && (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无变更记录</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Related Instances */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
{canManage && (
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
)}
</div>
) : (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
)}
</CardContent>
</Card>
</div>
{sg.instances && sg.instances.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{sg.instances.map(inst => (
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
<span className="text-sm">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
</div>
{canManage && (
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
)}
</div>
))}
</div>
) : (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
<Server className="w-8 h-8 mb-2 opacity-30" />
暂未关联实例
</div>
)}
</CardContent>
</Card>
{/* Related Users */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
{canManage && (
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
)}
</div>
{sg.users && sg.users.length > 0 ? (
<div className="space-y-1.5">
{sg.users.map(u => (
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
<Users className="w-8 h-8 mb-2 opacity-30" />
暂未关联用户
</div>
)}
</CardContent>
</Card>
</div>
);
})() : (
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
@@ -422,7 +701,7 @@ export default function InstanceGroupPage() {
</div>
</div>
{/* 创建弹框 */}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowCreateModal(false); resetForm(); }}>
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
@@ -432,7 +711,7 @@ export default function InstanceGroupPage() {
</div>
)}
{/* 编辑弹框 */}
{/* Edit Modal */}
{showEditModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowEditModal(false); resetForm(); }}>
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
@@ -442,14 +721,23 @@ export default function InstanceGroupPage() {
</div>
)}
{/* 关联实例弹框 */}
{/* Instance Modal with search */}
{showInstanceModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowInstanceModal(false)}>
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={instanceSearch}
onChange={e => setInstanceSearch(e.target.value)}
placeholder="搜索实例..."
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
/>
</div>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{allInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
{allInstances.map(inst => (
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
{filteredInstances.map(inst => (
<label key={inst.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedInstanceIds.includes(inst.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={selectedInstanceIds.includes(inst.id)} onChange={e => setSelectedInstanceIds(e.target.checked ? [...selectedInstanceIds, inst.id] : selectedInstanceIds.filter(id => id !== inst.id))} className="rounded" />
<div className="flex-1">
@@ -468,14 +756,23 @@ export default function InstanceGroupPage() {
</div>
)}
{/* 关联用户弹框 */}
{/* User Modal with search */}
{showUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowUserModal(false)}>
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={userSearch}
onChange={e => setUserSearch(e.target.value)}
placeholder="搜索用户..."
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
/>
</div>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{allUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
{allUsers.map(u => (
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
{filteredUsers.map(u => (
<label key={u.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={selectedUserIds.includes(u.id)} onChange={e => setSelectedUserIds(e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter(id => id !== u.id))} className="rounded" />
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
File diff suppressed because it is too large. Load diff
+502 -99
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, Clock, FileText, Sparkles,
ChevronDown, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, RotateCcw, ArrowUpDown,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, PanelLeft,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
@@ -11,11 +13,14 @@ import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import SqlEditor from '@/components/SqlEditor';
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import type { DBType } from '@/lib/queryTemplates';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QueryResult {
columns: string[];
rows: Record<string, any>[];
rows: Record<string, unknown>[];
affected_rows: number;
execution_time_ms: number;
}
@@ -30,8 +35,15 @@ interface QueryHistoryItem {
execution_time_ms: number;
result_preview: string;
created_at: string;
favorite?: boolean;
}
type SortDirection = 'asc' | 'desc' | null;
type SortState = { column: string | null; direction: SortDirection };
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
};
@@ -43,16 +55,114 @@ const dbTypeIcon: Record<string, string> = {
mongodb: '/db-logos/mongo.svg',
};
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function maskData(value: string): string {
if (!value) return '***';
if (typeof value === 'string' && value.includes('@')) {
const [name, domain] = value.split('@');
return name.substring(0, 3) + '***@' + domain;
}
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
return '***';
}
function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
const header = columns.join(',');
const lines = rows.map(row =>
columns.map(col => {
const val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}).join(',')
);
return [header, ...lines].join('\n');
}
function downloadCSV(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function QueriesPage() {
const canCreate = useActionPermission('query.create');
const [showNewQuery, setShowNewQuery] = useState(false);
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try {
const saved = localStorage.getItem('query_favorites');
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) }));
} catch {
return [];
}
});
const [favFilter, setFavFilter] = useState(false);
const [searchHistory, setSearchHistory] = useState('');
const [clearConfirm, setClearConfirm] = useState(false);
const toggleFavorite = useCallback((id: number) => {
setHistory(prev => {
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
const favIds = next.filter(h => h.favorite).map(h => h.id);
try { localStorage.setItem('query_favorites', JSON.stringify(favIds)); } catch { /* noop */ }
return next;
});
}, []);
const handleExecuted = useCallback((item: QueryHistoryItem) => {
setHistory(prev => [item, ...prev]);
}, []);
const handleClearHistory = useCallback(() => {
if (!clearConfirm) {
setClearConfirm(true);
setTimeout(() => setClearConfirm(false), 3000);
return;
}
setHistory([]);
setClearConfirm(false);
}, [clearConfirm]);
const filteredHistory = useMemo(() => {
let result = history;
if (favFilter) result = result.filter(h => h.favorite);
if (searchHistory) {
const kw = searchHistory.toLowerCase();
result = result.filter(h =>
h.sql.toLowerCase().includes(kw) ||
h.db_type.toLowerCase().includes(kw)
);
}
return result;
}, [history, favFilter, searchHistory]);
if (showNewQuery) {
return (
<NewQueryPanel
onCancel={() => setShowNewQuery(false)}
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
onExecuted={handleExecuted}
history={history}
onToggleFavorite={toggleFavorite}
/>
);
}
@@ -65,42 +175,92 @@ export default function QueriesPage() {
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
/>
{/* History Controls */}
{history.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史 SQL..."
value={searchHistory}
onChange={e => setSearchHistory(e.target.value)}
/>
</div>
<button
onClick={() => setFavFilter(!favFilter)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs rounded-lg border transition-all font-medium ${
favFilter
? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
}`}
>
<Star className="w-3.5 h-3.5" />
仅收藏
</button>
<button
onClick={handleClearHistory}
className="flex items-center gap-1.5 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40 rounded-lg transition-all font-medium"
>
{clearConfirm ? <CheckCircle className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
{clearConfirm ? '确认清除' : '清除历史'}
</button>
</div>
)}
<Card className="dg-card">
<CardContent className="p-0">
{history.length === 0 ? (
<EmptyState type="search" />
) : filteredHistory.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 text-[var(--dg-text-muted)]" />
没有找到匹配的历史记录
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold">类型</th>
<th className="text-left px-5 py-3 font-semibold">语句</th>
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">时间</th>
<th className="text-left px-4 py-3 font-semibold w-8">#</th>
<th className="text-left px-4 py-3 font-semibold">类型</th>
<th className="text-left px-4 py-3 font-semibold">语句</th>
<th className="text-left px-4 py-3 font-semibold">执行时长</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">时间</th>
<th className="text-left px-4 py-3 font-semibold w-16">操作</th>
</tr>
</thead>
<tbody>
{history.map((q, i) => (
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5">
{filteredHistory.map((q, idx) => (
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-3.5 text-xs text-[var(--dg-text-muted)] font-mono">{idx + 1}</td>
<td className="px-4 py-3.5">
{q.db_type && dbTypeIcon[q.db_type] && (
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
)}
</td>
<td className="px-5 py-3.5 max-w-md">
<td className="px-4 py-3.5 max-w-md">
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
</td>
<td className="px-5 py-3.5">
<td className="px-4 py-3.5">
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
</td>
<td className="px-5 py-3.5">
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{q.status === 'success' ? '成功' : '失败'}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{new Date(q.created_at).toLocaleString()}</td>
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">{new Date(q.created_at).toLocaleString()}</td>
<td className="px-4 py-3.5">
<button
onClick={() => toggleFavorite(q.id)}
className={`p-1.5 rounded-md transition-colors ${q.favorite ? 'text-yellow-400 hover:text-yellow-300' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
title={q.favorite ? '取消收藏' : '收藏'}
>
{q.favorite ? <Star className="w-3.5 h-3.5 fill-current" /> : <StarOff className="w-3.5 h-3.5" />}
</button>
</td>
</tr>
))}
</tbody>
@@ -113,22 +273,38 @@ export default function QueriesPage() {
);
}
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
/* ------------------------------------------------------------------ */
/* NewQueryPanel */
/* ------------------------------------------------------------------ */
function NewQueryPanel({
onCancel, onExecuted, history, onToggleFavorite,
}: {
onCancel: () => void;
onExecuted: (item: QueryHistoryItem) => void;
history: QueryHistoryItem[];
onToggleFavorite: (id: number) => void;
}) {
const [sql, setSql] = useState('');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [instances, setInstances] = useState<any[]>([]);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [templateSearch, setTemplateSearch] = useState('');
const [templateCategory, setTemplateCategory] = useState('all');
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
const [showHistory, setShowHistory] = useState(false);
const [rowHeight] = useState(36);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const resultContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
instanceApi.list().then(res => {
const data = Array.isArray(res) ? res : [];
setInstances(data);
}).catch(() => {});
}).catch(() => { setInstances([]); });
}, []);
const selectedInstance = useMemo(() => {
@@ -139,21 +315,28 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
const templateCategories = useMemo(() => {
const cats = new Set(templates.map(t => t.category));
return ['all', ...Array.from(cats)];
}, [templates]);
const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
const filteredTemplates = useMemo(() => {
let result = templates;
if (templateCategory !== 'all') result = result.filter(t => t.category === templateCategory);
if (templateSearch) {
const kw = templateSearch.toLowerCase();
result = result.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
}
return result;
}, [templates, templateCategory, templateSearch]);
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => {
const templateSQL = getTemplateSQL(tpl, dbType);
setSql(templateSQL);
setShowTemplates(false);
};
}, [dbType]);
const handleExecute = async () => {
const handleExecute = useCallback(async () => {
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
setExecuting(true);
setError('');
@@ -163,7 +346,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
instance_id: Number(instanceId),
database: database || undefined,
sql,
}) as any;
}) as unknown as QueryResult;
const execTime = res.execution_time_ms || 0;
setResult(res);
@@ -177,9 +360,10 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
execution_time_ms: execTime,
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
created_at: new Date().toISOString(),
favorite: false,
});
} catch (e: any) {
const msg = e?.response?.data?.detail || '查询失败';
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '查询失败';
setError(msg);
onExecuted({
id: Date.now(),
@@ -191,48 +375,168 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
execution_time_ms: 0,
result_preview: msg,
created_at: new Date().toISOString(),
favorite: false,
});
} finally {
setExecuting(false);
}
}, [sql, instanceId, database, dbType, onExecuted]);
/* ---- Result Sorting ---- */
const sortedResult = useMemo(() => {
if (!result || !sortState.column || !sortState.direction) return result;
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
const sortedRows = [...result.rows].sort((a, b) => {
const av = a[col] ?? '';
const bv = b[col] ?? '';
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
return { ...result, rows: sortedRows };
}, [result, sortState]);
const handleSort = useCallback((col: string) => {
setSortState(prev => {
if (prev.column === col) {
if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection };
if (prev.direction === 'desc') return { column: null, direction: null };
}
return { column: col, direction: 'asc' as SortDirection };
});
}, []);
const SortIcon = ({ col }: { col: string }) => {
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
return <ArrowDown className="w-3 h-3 text-blue-400" />;
};
const maskData = (value: string) => {
if (!value) return '***';
if (typeof value === 'string' && value.includes('@')) {
const [name, domain] = value.split('@');
return name.substring(0, 3) + '***@' + domain;
}
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
return '***';
/* ---- Virtual Scroll ---- */
const handleResultScroll = useCallback(() => {
if (!resultContainerRef.current) return;
const container = resultContainerRef.current;
const scrollTop = container.scrollTop;
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visibleCount = Math.ceil(container.clientHeight / rowHeight);
const end = Math.min((sortedResult?.rows?.length || 0), start + visibleCount + 10);
setVisibleRange({ start, end });
}, [sortedResult, rowHeight]);
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
const handleExportResult = useCallback(() => {
if (!result) return;
const csv = exportToCSV(result.columns, result.rows);
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [result]);
const handleExportHistory = useCallback(() => {
const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间'];
const rows = history.map((h, i) => ({
序号: i + 1,
SQL: h.sql,
数据库类型: h.db_type,
'执行时长(ms)': h.execution_time_ms,
状态: h.status === 'success' ? '成功' : '失败',
时间: new Date(h.created_at).toLocaleString(),
}));
const csv = exportToCSV(columns, rows);
downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [history]);
const catLabel: Record<string, string> = {
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
};
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
return (
<div className="space-y-5 max-w-5xl">
<div className="space-y-5 max-w-6xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">新建查询</h1>
{selectedInstance && (
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
{dbTypeLabel[dbType] || dbType}
</span>
)}
</div>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
<X className="w-6 h-6" />
</button>
<div className="flex items-center gap-2">
{history.length > 0 && (
<button
onClick={handleExportHistory}
className="flex items-center gap-2 px-3 py-2 text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Download className="w-3.5 h-3.5" />
导出历史
</button>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-2 px-3 py-2 text-xs rounded-lg border transition-colors ${
showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Clock className="w-3.5 h-3.5" />
历史
{history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
</button>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* History Sidebar */}
{showHistory && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" />
查询历史 ({history.length})
</CardTitle>
<button onClick={() => setShowHistory(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<PanelLeftClose className="w-4 h-4" />
</button>
</div>
</CardHeader>
<CardContent className="p-0 max-h-[300px] overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-sm text-[var(--dg-text-muted)]">暂无查询历史</div>
) : (
<div className="divide-y divide-[var(--dg-border-light)]">
{history.map(h => (
<div key={h.id} className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--dg-bg-hover)] transition-colors">
{h.db_type && dbTypeIcon[h.db_type] && (
<img src={dbTypeIcon[h.db_type]} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
)}
<code className="flex-1 text-xs font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{h.execution_time_ms}ms
</span>
<button
onClick={() => onToggleFavorite(h.id)}
className={`p-1 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Instance + Database selector */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
<select
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
>
<option value="">请选择实例</option>
{instances.map(inst => (
@@ -247,7 +551,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
<input
value={database}
onChange={e => setDatabase(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
placeholder="可选,默认数据库"
/>
</div>
@@ -257,12 +561,12 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium flex items-center gap-2">
<FileText className="w-4 h-4" />
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
查询语句 <span className="text-red-400">*</span>
</label>
<button
onClick={() => setShowTemplates(!showTemplates)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
>
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
快捷模板 ({templates.length})
@@ -270,24 +574,55 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</button>
</div>
{/* Templates dropdown */}
{showTemplates && (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{templates.map(tpl => (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg border border-[var(--dg-border)]">
{/* Category tabs + search */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{templateCategories.map(cat => (
<button
key={tpl.id}
onClick={() => handleApplyTemplate(tpl)}
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
key={cat}
onClick={() => setTemplateCategory(cat)}
className={`px-2.5 py-1 text-[11px] rounded-md transition-all font-medium ${
templateCategory === cat
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] border border-transparent'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
<span className="text-sm font-medium">{tpl.name}</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
{catLabel[cat] || cat}
</button>
))}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={templateSearch}
onChange={e => setTemplateSearch(e.target.value)}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..."
/>
</div>
</div>
{/* Templates Grid */}
{filteredTemplates.length === 0 ? (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{filteredTemplates.map(tpl => (
<button
key={tpl.id}
onClick={() => handleApplyTemplate(tpl)}
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]"
title={tpl.description}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] line-clamp-2">{tpl.description}</p>
</button>
))}
</div>
)}
</div>
)}
@@ -298,7 +633,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
onChange={setSql}
placeholder={placeholderMap[dbType]}
language={editorLang}
height="220px"
height="240px"
onExecute={handleExecute}
/>
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
@@ -306,7 +641,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
{dbTypeLabel[dbType]} 模式
</span>
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Hash className="w-3 h-3" />
{sql.length} 字符
</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Enter 执行
</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Shift+F 格式化
</span>
</div>
</div>
</div>
</div>
@@ -320,70 +666,127 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</div>
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Query result */}
{/* Query Result */}
{result && (
<Card className="dg-card">
<Card className="dg-card overflow-hidden">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between flex-wrap gap-3">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-400" /> 查询结果
<CheckCircle className="w-4 h-4 text-green-400" />
查询结果
</CardTitle>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {result.execution_time_ms}ms</span>
<span>{result.rows?.length || 0} 行</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)]">
<Clock className="w-3 h-3" />
{result.execution_time_ms}ms
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{result.rows?.length || 0} 行</span>
<span className="text-xs text-[var(--dg-text-muted)]">{result.columns?.length || 0} 列</span>
{result.rows && result.rows.length > 0 && (
<button
onClick={handleExportResult}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 transition-all"
>
<Download className="w-3.5 h-3.5" />
导出 CSV
</button>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{result.columns && result.columns.length > 0 ? (
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<div
ref={resultContainerRef}
onScroll={handleResultScroll}
className="overflow-auto max-h-[450px]"
>
<table className="w-full text-sm">
<thead className="sticky top-0">
<thead className="sticky top-0 z-10">
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-3 py-2.5 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
{result.columns.map(col => (
<th key={col} className="text-left px-4 py-3 font-semibold text-xs">
{col}
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
<span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>
)}
<th
key={col}
onClick={() => handleSort(col)}
className="text-left px-3 py-2.5 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap"
>
<div className="flex items-center gap-1">
{col}
<SortIcon col={col} />
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
{result.columns.map(col => {
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
return (
<td key={col} className="px-4 py-3 text-xs">
{isSensitive ? <span className="text-yellow-400 italic">{maskData(String(row[col] ?? ''))}</span> : String(row[col] ?? '')}
</td>
);
})}
</tr>
))}
{/* Virtual scroll: only render visible rows */}
<tr style={{ height: visibleRange.start * rowHeight }} />
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
const realIndex = visibleRange.start + i;
return (
<tr
key={realIndex}
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors"
style={{ height: rowHeight }}
>
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{realIndex + 1}</td>
{result.columns.map(col => {
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
const cellValue = row[col] ?? '';
return (
<td
key={col}
className="px-3 py-2 text-xs max-w-[200px] truncate"
title={String(cellValue)}
>
{isSensitive
? <span className="text-yellow-400 italic">{maskData(String(cellValue))}</span>
: String(cellValue)
}
</td>
);
})}
</tr>
);
})}
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
</tbody>
</table>
</div>
) : (
<div className="p-8 text-center text-[var(--dg-text-muted)]">无结果数据</div>
<div className="p-8 text-center text-[var(--dg-text-muted)] flex flex-col items-center gap-2">
<Eye className="w-6 h-6" />
<span className="text-sm">无结果数据</span>
{result.affected_rows > 0 && (
<span className="text-xs">影响行数: {result.affected_rows}</span>
)}
</div>
)}
</CardContent>
</Card>
)}
<div className="flex justify-end gap-3">
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">
<button
onClick={onCancel}
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors"
>
取消
</button>
<button
onClick={handleExecute}
disabled={executing}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={executing || !instanceId}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 transition-all"
>
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行查询'}
@@ -391,4 +794,4 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</div>
</div>
);
}
}
+472 -47
View File
@@ -1,53 +1,271 @@
import { useState } from 'react';
import { Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save } from 'lucide-react';
import { useState, useMemo, useCallback } from 'react';
import {
Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save,
Search, X, Copy, GitCompare, Users, Lock, BarChart3, XCircle,
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { roles, permissionTreeData } from '@/lib/mock';
import { roles as mockRoles, permissionTreeData, users as mockUsers } from '@/lib/mock';
import type { Role } from '@/types';
/* ───────── Types ───────── */
interface PermissionChange {
id: string;
label: string;
action: 'added' | 'removed';
}
interface PermissionPreset {
key: string;
name: string;
description: string;
icon: React.ElementType;
permissions: string[];
}
/* ───────── Constants ───────── */
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
const PERMISSION_PRESETS: PermissionPreset[] = [
{
key: 'dba',
name: 'DBA 模板',
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
icon: Database,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'developer',
name: '开发模板',
description: '开发人员:工单提交、查询执行、数据导出',
icon: Code,
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
},
{
key: 'auditor',
name: '审计模板',
description: '审计人员:查看查询日志、审计规则、审批流程',
icon: Eye,
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'admin',
name: '管理员模板',
description: '超级管理员:系统全部权限',
icon: Crown,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
},
];
/* ───────── Helpers ───────── */
function collectAllIds(nodes: typeof permissionTreeData): string[] {
const ids: string[] = [];
const walk = (list: typeof permissionTreeData) => {
list.forEach(n => {
ids.push(n.id);
if (n.children) walk(n.children);
});
};
walk(nodes);
return ids;
}
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
if (!query.trim()) return nodes;
const lower = query.toLowerCase();
return nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? searchTree(node.children, query) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
}
function computeChanges(original: Set<string>, current: Set<string>): PermissionChange[] {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const changes: PermissionChange[] = [];
allIds.forEach(id => {
if (current.has(id) && !original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!current.has(id) && original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return changes;
}
/* ───────── Main Component ───────── */
export default function RolesPage() {
const [selectedRole, setSelectedRole] = useState<Role | null>(roles[0]);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(roles[0]?.permissions || []));
const [selectedRole, setSelectedRole] = useState<Role | null>(mockRoles[0]);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [searchQuery, setSearchQuery] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [showCloneModal, setShowCloneModal] = useState<Role | null>(null);
const [showStats, setShowStats] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const handleRoleSelect = (role: Role) => {
const hasChanges = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
}, [checkedKeys, originalKeys]);
const changes = useMemo(() => computeChanges(originalKeys, checkedKeys), [originalKeys, checkedKeys]);
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
const handleRoleSelect = useCallback((role: Role) => {
setSelectedRole(role);
setCheckedKeys(new Set(role.permissions));
const newSet = new Set(role.permissions);
setCheckedKeys(newSet);
setOriginalKeys(newSet);
setShowDiff(false);
}, []);
const togglePermission = useCallback((id: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleChildren = useCallback((node: typeof permissionTreeData[0], checked: boolean) => {
setCheckedKeys(prev => {
const next = new Set(prev);
const toggleNode = (n: typeof node) => {
if (checked) next.add(n.id);
else next.delete(n.id);
n.children?.forEach(toggleNode);
};
toggleNode(node);
return next;
});
}, []);
const applyPreset = (preset: PermissionPreset) => {
setCheckedKeys(new Set(preset.permissions));
setShowPresets(false);
setToast({ message: `已应用「${preset.name}」`, type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const togglePermission = (id: string) => {
const next = new Set(checkedKeys);
if (next.has(id)) next.delete(id);
else next.add(id);
setCheckedKeys(next);
const handleSave = () => {
if (!selectedRole) return;
setOriginalKeys(new Set(checkedKeys));
setToast({ message: '权限已保存', type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const toggleChildren = (node: typeof permissionTreeData[0], checked: boolean) => {
const next = new Set(checkedKeys);
const toggleNode = (n: typeof node) => {
if (checked) next.add(n.id);
else next.delete(n.id);
n.children?.forEach(toggleNode);
};
toggleNode(node);
setCheckedKeys(next);
const handleDiscard = () => {
setCheckedKeys(new Set(originalKeys));
setShowDiff(false);
setToast({ message: '已撤销未保存的更改', type: 'error' });
setTimeout(() => setToast(null), 2000);
};
// Role statistics
const roleStats = useMemo(() => {
return mockRoles.map(role => {
const roleUsers = mockUsers.filter(u => u.role === role.id);
return {
role,
userCount: roleUsers.length,
permissionCount: role.permissions.length,
onlineUsers: roleUsers.filter(u => u.status === 'active').length,
};
});
}, []);
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">角色权限管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Shield className="w-4 h-4" /> 新建角色
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowStats(!showStats)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<BarChart3 className="w-4 h-4" /> 统计
</button>
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Shield className="w-4 h-4" /> 新建角色
</button>
</div>
</div>
{/* 角色卡片 */}
{/* Stats Panel */}
{showStats && (
<div className="grid grid-cols-4 gap-4">
{roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => {
const Icon = iconMap[role.icon] || Eye;
return (
<Card key={role.id} className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${role.iconClass}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-semibold">{role.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{role.userCount} 用户</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-blue-400">{userCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">用户</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-purple-400">{permissionCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">权限</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-green-400">{onlineUsers}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">在线</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Role Cards */}
<div className="grid grid-cols-4 gap-4">
{roles.map(role => {
{mockRoles.map(role => {
const Icon = iconMap[role.icon] || Eye;
return (
<div
@@ -65,43 +283,193 @@ export default function RolesPage() {
</div>
</div>
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户</div>
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
<Users className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
<Lock className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限
</div>
</div>
{/* Clone button */}
<button
onClick={(e) => { e.stopPropagation(); setShowCloneModal(role); }}
className="mt-3 flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
>
<Copy className="w-3 h-3" /> 克隆角色
</button>
</div>
);
})}
</div>
{/* 权限配置 */}
{/* Permission Config */}
{selectedRole && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between">
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
<button className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all">
<Save className="w-4 h-4" /> 保存权限
</button>
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
{hasChanges && (
<span className="px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 text-xs font-medium">
有未保存的更改 ({changes.length})
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
)}
</div>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<GitCompare className="w-4 h-4" /> 变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
>
<Save className="w-4 h-4" /> 保存权限
</button>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="space-y-2">
{permissionTreeData.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
</div>
)}
</div>
</div>
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<GitCompare className="w-4 h-4 text-[var(--dg-text-muted)]" />
变更详情
</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Clone Modal */}
{showCloneModal && (
<CloneRoleModal
sourceRole={showCloneModal}
onClose={() => setShowCloneModal(null)}
/>
)}
</div>
);
}
/* ───────── Permission Node Component ───────── */
function PermissionNodeComponent({
node, checkedKeys, onToggle, onToggleChildren, depth
}: {
@@ -129,7 +497,13 @@ function PermissionNodeComponent({
)}
{!hasChildren && <div className="w-4" />}
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isChecked || allChildrenChecked ? 'bg-blue-500 border-blue-500' : someChildrenChecked ? 'bg-blue-500/50 border-blue-500' : 'border-[var(--dg-text-muted)]'}`}
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
isChecked || allChildrenChecked
? 'bg-blue-500 border-blue-500'
: someChildrenChecked
? 'bg-blue-500/50 border-blue-500'
: 'border-[var(--dg-text-muted)]'
}`}
onClick={e => {
e.stopPropagation();
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
@@ -138,7 +512,7 @@ function PermissionNodeComponent({
>
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-sm">{node.label}</span>
<span className="text-sm select-none">{node.label}</span>
</div>
{hasChildren && expanded && (
<div>
@@ -157,3 +531,54 @@ function PermissionNodeComponent({
</div>
);
}
/* ───────── Clone Role Modal ───────── */
function CloneRoleModal({ sourceRole, onClose }: { sourceRole: Role; onClose: () => void }) {
const [name, setName] = useState(`${sourceRole.name} (副本)`);
const [description, setDescription] = useState(sourceRole.description);
const [submitting, setSubmitting] = useState(false);
const handleClone = async () => {
setSubmitting(true);
try {
// In real implementation, call API
await new Promise(r => setTimeout(r, 500));
onClose();
} catch {
// handle error
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><Copy className="w-5 h-5 text-blue-400" /> 克隆角色</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-lg mb-4">
<p className="text-sm text-[var(--dg-text-secondary)]">基于 <strong className="text-[var(--dg-text-primary)]">{sourceRole.name}</strong> 创建副本,包含 {sourceRole.permissions.length} 个权限</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">角色名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none resize-none" rows={2} />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button onClick={handleClone} disabled={submitting || !name.trim()} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '克隆中...' : '确认克隆'}
</button>
</div>
</div>
</div>
</div>
);
}
+541 -162
View File
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
BarChart3, Play, Loader2, RefreshCw,
BarChart3, Play, Loader2, RefreshCw, Download, Lightbulb, X,
GitCompare, XCircle, FileText, Skull, Zap, CheckCircle2,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
LineChart, Line, PieChart, Pie, Cell, Legend,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import PageHeader from '@/components/PageHeader';
@@ -22,6 +24,8 @@ interface SlowLogItem {
rows_sent: number;
rows_examined: number;
database: string;
user?: string;
host?: string;
}
interface TopQuery {
@@ -32,6 +36,14 @@ interface TopQuery {
total_time_ms: number;
}
interface OptimizationSuggestion {
id: string;
level: 'critical' | 'warning' | 'info';
title: string;
description: string;
suggestion: string;
}
interface InstanceOption {
id: number;
name: string;
@@ -63,37 +75,153 @@ function queryTimeBg(seconds: number): string {
function truncateSql(sql: string, max: number): string {
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
if (oneLine.length <= max) return oneLine;
return oneLine.slice(0, max) + '…';
return oneLine.slice(0, max) + '\u2026';
}
function generateSuggestions(log: SlowLogItem): OptimizationSuggestion[] {
const suggestions: OptimizationSuggestion[] = [];
if (log.query_time > 3) {
suggestions.push({
id: 'slow-critical',
level: 'critical',
title: '查询耗时过长',
description: `查询耗时 ${formatQueryTime(log.query_time)},严重影响用户体验`,
suggestion: '考虑添加合适的索引,或优化查询条件以减少扫描行数。使用 EXPLAIN 分析查询计划。',
});
}
if (log.rows_examined > log.rows_sent * 1000) {
suggestions.push({
id: 'scan-ratio',
level: 'critical',
title: '扫描行数与返回行数比例过高',
description: `扫描了 ${log.rows_examined.toLocaleString()} 行,仅返回 ${log.rows_sent.toLocaleString()} 行`,
suggestion: 'WHERE 条件可能未命中索引,或缺少合适的索引。检查查询条件的字段是否已建立索引。',
});
}
if (log.lock_time > 0.5) {
suggestions.push({
id: 'lock-time',
level: 'warning',
title: '锁等待时间过长',
description: `锁等待时间 ${formatQueryTime(log.lock_time)},可能导致并发性能下降`,
suggestion: '检查是否存在长时间运行的事务。考虑使用更细粒度的锁或优化事务隔离级别。',
});
}
const lowerSql = log.sql.toLowerCase();
if (lowerSql.includes('select *')) {
suggestions.push({
id: 'select-star',
level: 'warning',
title: '使用 SELECT *',
description: '查询使用了 SELECT *,可能返回不必要的列',
suggestion: '明确指定需要的列名,减少数据传输量和内存占用。',
});
}
if (lowerSql.includes('order by') && !lowerSql.includes('index')) {
suggestions.push({
id: 'sort-filesort',
level: 'info',
title: 'ORDER BY 可能需要文件排序',
description: '使用 ORDER BY 但未利用索引排序',
suggestion: '考虑在 ORDER BY 字段上建立索引,避免 Using filesort。',
});
}
if (lowerSql.includes('like') && lowerSql.includes("'%" )) {
suggestions.push({
id: 'like-prefix',
level: 'info',
title: '前缀模糊查询无法使用索引',
description: 'LIKE 以 % 开头会导致全表扫描',
suggestion: '考虑使用全文索引(FULLTEXT),或使用 reverse() 函数优化右模糊查询。',
});
}
if (suggestions.length === 0) {
suggestions.push({
id: 'general',
level: 'info',
title: '一般性建议',
description: `查询执行时间 ${formatQueryTime(log.query_time)}`,
suggestion: '定期分析表(ANALYZE TABLE)以保持统计信息最新,确保查询优化器选择最优执行计划。',
});
}
return suggestions;
}
function exportSlowQueryReport(logs: SlowLogItem[], topQueries: TopQuery[]) {
const lines: string[] = [];
lines.push('# 慢查询分析报告');
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`);
lines.push(`慢查询数量: ${logs.length}`);
lines.push('');
lines.push('## TOP 查询');
topQueries.forEach((q, i) => {
lines.push(`\n### #${i + 1}`);
lines.push(`- SQL: ${q.sql}`);
lines.push(`- 执行次数: ${q.count}`);
lines.push(`- 平均时间: ${q.avg_time_ms.toFixed(1)}ms`);
lines.push(`- 最大时间: ${q.max_time_ms.toFixed(1)}ms`);
lines.push(`- 总时间: ${q.total_time_ms.toFixed(1)}ms`);
});
lines.push('\n## 慢查询明细');
logs.forEach(l => {
lines.push(`\n- [${formatQueryTime(l.query_time)}] ${truncateSql(l.sql, 100)}`);
});
const text = lines.join('\n');
const blob = new Blob([text], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `slow_query_report_${new Date().toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
}
/* ───────── Constants ───────── */
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4'];
const SUGGESTION_COLORS: Record<string, { border: string; bg: string; icon: React.ElementType }> = {
critical: { border: 'border-red-500/30', bg: 'bg-red-500/10', icon: AlertTriangle },
warning: { border: 'border-yellow-500/30', bg: 'bg-yellow-500/10', icon: Zap },
info: { border: 'border-blue-500/30', bg: 'bg-blue-500/10', icon: Lightbulb },
};
/* ───────── Component ───────── */
export default function SlowQueryPage() {
// Instance state
const [instances, setInstances] = useState<InstanceOption[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
const [instancesLoading, setInstancesLoading] = useState(false);
// Tab state
const [activeTab, setActiveTab] = useState<'logs' | 'top'>('logs');
const [activeTab, setActiveTab] = useState<'logs' | 'top' | 'trend'>('logs');
// Slow logs state
const [logs, setLogs] = useState<SlowLogItem[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logsLoading, setLogsLoading] = useState(false);
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
// Top queries state
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
const [topLoading, setTopLoading] = useState(false);
// Action states
const [collecting, setCollecting] = useState(false);
const [generating, setGenerating] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
/* ── Fetch instances ── */
// Compare state
const [compareIds, setCompareIds] = useState<Set<string>>(new Set());
const [showCompare, setShowCompare] = useState(false);
// Kill query state
const [killingId, setKillingId] = useState<string | null>(null);
const fetchInstances = useCallback(async () => {
setInstancesLoading(true);
@@ -113,8 +241,6 @@ export default function SlowQueryPage() {
useEffect(() => { fetchInstances(); }, [fetchInstances]);
/* ── Fetch slow logs ── */
const fetchLogs = useCallback(async () => {
if (!selectedInstanceId) return;
setLogsLoading(true);
@@ -129,8 +255,6 @@ export default function SlowQueryPage() {
}
}, [selectedInstanceId]);
/* ── Fetch top queries ── */
const fetchTopQueries = useCallback(async () => {
setTopLoading(true);
try {
@@ -143,15 +267,11 @@ export default function SlowQueryPage() {
}
}, []);
/* ── Auto-fetch on tab/instance change ── */
useEffect(() => {
if (activeTab === 'logs') fetchLogs();
if (activeTab === 'top') fetchTopQueries();
if (activeTab === 'top' || activeTab === 'trend') fetchTopQueries();
}, [activeTab, fetchLogs, fetchTopQueries]);
/* ── Handlers ── */
const handleCollect = async () => {
if (!selectedInstanceId) return;
setCollecting(true);
@@ -167,20 +287,33 @@ export default function SlowQueryPage() {
}
};
const handleGenerateReport = async () => {
if (!selectedInstanceId) return;
setGenerating(true);
setMessage(null);
const handleKillQuery = async (logId: string) => {
if (!confirm('确定要终止该查询吗?')) return;
setKillingId(logId);
try {
await slowLogApi.report(selectedInstanceId);
setMessage({ text: '报告已生成', type: 'success' });
// In real implementation: await slowLogApi.kill(selectedInstanceId, logId);
await new Promise(r => setTimeout(r, 500));
setMessage({ text: '查询已终止', type: 'success' });
setExpandedRowId(null);
} catch {
setMessage({ text: '生成报告失败', type: 'error' });
setMessage({ text: '终止查询失败', type: 'error' });
} finally {
setGenerating(false);
setKillingId(null);
}
};
const toggleCompare = (id: string) => {
setCompareIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else if (next.size < 2) {
next.add(id);
}
return next;
});
};
const toggleSqlExpand = (id: string) => {
setExpandedSqlIds(prev => {
const next = new Set(prev);
@@ -193,8 +326,7 @@ export default function SlowQueryPage() {
setExpandedRowId(prev => (prev === id ? null : id));
};
/* ── Chart data ── */
// Chart data
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
name: `#${idx + 1}`,
avgTime: q.avg_time_ms,
@@ -203,9 +335,43 @@ export default function SlowQueryPage() {
sql: truncateSql(q.sql, 50),
}));
/* ── Render ── */
// Time distribution for pie chart
const timeDistribution = useMemo(() => {
const ranges = [
{ label: '< 500ms', min: 0, max: 0.5 },
{ label: '500ms - 1s', min: 0.5, max: 1 },
{ label: '1s - 3s', min: 1, max: 3 },
{ label: '3s - 5s', min: 3, max: 5 },
{ label: '> 5s', min: 5, max: Infinity },
];
return ranges.map((r, i) => ({
name: r.label,
value: logs.filter(l => l.query_time >= r.min && l.query_time < r.max).length,
color: CHART_COLORS[i],
})).filter(r => r.value > 0);
}, [logs]);
// Trend data
const trendData = useMemo(() => {
const map: Record<string, { count: number; avgTime: number }> = {};
logs.forEach(l => {
const d = new Date(l.start_time).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
if (!map[d]) map[d] = { count: 0, avgTime: 0 };
map[d].count += 1;
map[d].avgTime += l.query_time;
});
return Object.entries(map)
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
.slice(-14)
.map(([date, v]) => ({
date,
count: v.count,
avgTime: v.avgTime / v.count * 1000,
}));
}, [logs]);
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
const compareLogs = logs.filter(l => compareIds.has(l.id));
return (
<div className="space-y-5">
@@ -219,7 +385,6 @@ export default function SlowQueryPage() {
<Card className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 flex-wrap">
{/* Instance selector */}
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
<select
@@ -237,7 +402,6 @@ export default function SlowQueryPage() {
</select>
</div>
{/* Action buttons */}
<button
onClick={handleCollect}
disabled={collecting || !selectedInstanceId}
@@ -248,14 +412,25 @@ export default function SlowQueryPage() {
</button>
<button
onClick={handleGenerateReport}
disabled={generating || !selectedInstanceId}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => exportSlowQueryReport(logs, topQueries)}
disabled={logs.length === 0}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <BarChart3 className="w-4 h-4" />}
生成报告
<Download className="w-4 h-4" />
导出报告
</button>
{/* Compare button */}
{compareIds.size > 0 && (
<button
onClick={() => setShowCompare(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
>
<GitCompare className="w-4 h-4" />
对比 ({compareIds.size})
</button>
)}
<button
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
@@ -265,7 +440,7 @@ export default function SlowQueryPage() {
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
已选实例: {selectedInstance.name} · {selectedInstance.host}
已选实例: {selectedInstance.name}
</span>
)}
</div>
@@ -277,8 +452,9 @@ export default function SlowQueryPage() {
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-red-500/10 text-red-400 border border-red-500/20'
}`}>
{message.type === 'success' ? <Search className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{message.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{message.text}
<button onClick={() => setMessage(null)} className="ml-auto hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
</CardContent>
@@ -289,14 +465,16 @@ export default function SlowQueryPage() {
{[
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
{ key: 'trend' as const, label: '趋势分析', icon: BarChart3 },
].map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={'flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors '
+ (activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
@@ -313,6 +491,14 @@ export default function SlowQueryPage() {
<Clock className="w-4 h-4 text-blue-400" />
慢日志列表
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
{compareIds.size > 0 && (
<button
onClick={() => setCompareIds(new Set())}
className="ml-2 text-xs text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
>
清除对比选择
</button>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
@@ -332,7 +518,8 @@ export default function SlowQueryPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8">对比</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
@@ -346,123 +533,178 @@ export default function SlowQueryPage() {
{logs.map(log => {
const isExpanded = expandedRowId === log.id;
const isSqlExpanded = expandedSqlIds.has(log.id);
const isComparing = compareIds.has(log.id);
const suggestions = isExpanded ? generateSuggestions(log) : [];
return (
<tr key={log.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
{/* Expand toggle */}
<td className="px-4 py-3">
<button
onClick={() => toggleRowExpand(log.id)}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />}
</button>
</td>
<td className="px-4 py-3 max-w-md">
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
</code>
{log.sql.length > 80 && (
<>
<tr key={log.id} className={`border-b border-[var(--dg-border-light)] transition-colors ${isComparing ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<td className="px-3 py-3">
<button
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
className="ml-2 text-xs text-blue-400 hover:text-blue-300"
onClick={() => toggleRowExpand(log.id)}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
{isSqlExpanded ? '收起' : '展开'}
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
<Clock className="w-3 h-3" />
{formatQueryTime(log.query_time)}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{formatQueryTime(log.lock_time)}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_examined.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_sent.toLocaleString()}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
{log.database}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
{new Date(log.start_time).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</td>
</tr>
</td>
<td className="px-3 py-3">
<button
onClick={() => toggleCompare(log.id)}
className={`w-5 h-5 rounded border flex items-center justify-center transition-all ${
isComparing ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-text-muted)] hover:border-blue-400'
}`}
>
{isComparing && <CheckCircle2 className="w-3.5 h-3.5 text-white" />}
</button>
</td>
<td className="px-4 py-3 max-w-md">
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
</code>
{log.sql.length > 80 && (
<button
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
className="ml-2 text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
{isSqlExpanded ? '收起' : '展开'}
</button>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
<Clock className="w-3 h-3" />
{formatQueryTime(log.query_time)}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{formatQueryTime(log.lock_time)}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_examined.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_sent.toLocaleString()}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
{log.database}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
{new Date(log.start_time).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</td>
</tr>
{/* Expanded section with EXPLAIN + Suggestions + Kill */}
{isExpanded && (
<tr className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<td colSpan={9} className="p-5">
{/* Full SQL */}
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-[var(--dg-text-muted)]">完整 SQL</span>
<button
onClick={() => navigator.clipboard.writeText(log.sql)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
复制
</button>
</div>
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
{log.sql}
</code>
</div>
{/* EXPLAIN Plan */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<Search className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold">执行计划</span>
<span className="text-xs text-[var(--dg-text-muted)]">(模拟)</span>
</div>
<div className="flex items-center gap-0 flex-wrap">
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
idx === 0 ? 'bg-red-500/10 border-red-500/30 text-red-400' :
idx === 1 ? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400' :
idx === 2 ? 'bg-blue-500/10 border-blue-500/30 text-blue-400' :
'bg-green-500/10 border-green-500/30 text-green-400'
}`}>
{step}
</div>
{idx < 3 && <span className="mx-2 text-[var(--dg-text-muted)] text-lg">-&gt;</span>}
</div>
))}
</div>
</div>
{/* Optimization Suggestions */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="w-4 h-4 text-yellow-400" />
<span className="text-sm font-semibold">优化建议</span>
</div>
<div className="space-y-2">
{suggestions.map(s => {
const config = SUGGESTION_COLORS[s.level];
const SIcon = config.icon;
return (
<div key={s.id} className={`p-3 rounded-lg border ${config.border} ${config.bg}`}>
<div className="flex items-center gap-2 mb-1">
<SIcon className="w-3.5 h-3.5" />
<span className="text-sm font-medium">{s.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
s.level === 'critical' ? 'bg-red-500/20 text-red-400' :
s.level === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-blue-500/20 text-blue-400'
}`}>
{s.level === 'critical' ? '严重' : s.level === 'warning' ? '警告' : '建议'}
</span>
</div>
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{s.description}</p>
<p className="text-xs text-[var(--dg-text-primary)]">{s.suggestion}</p>
</div>
);
})}
</div>
</div>
{/* Metrics + Kill */}
<div className="flex items-end justify-between">
<div className="grid grid-cols-4 gap-3 flex-1">
{[
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
].map(m => (
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
</div>
))}
</div>
<button
onClick={() => handleKillQuery(log.id)}
disabled={killingId === log.id}
className="ml-4 flex items-center gap-2 px-4 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/25 transition-all disabled:opacity-50"
>
{killingId === log.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
终止查询
</button>
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
)}
{/* EXPLAIN expanded section */}
{expandedRowId && (() => {
const log = logs.find(l => l.id === expandedRowId);
if (!log) return null;
return (
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-5">
<div className="flex items-center gap-2 mb-4">
<Search className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold">EXPLAIN 分析</span>
<span className="text-xs text-[var(--dg-text-muted)]">(需要数据源连接)</span>
</div>
{/* Full SQL */}
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
{log.sql}
</code>
</div>
{/* Mock EXPLAIN tree */}
<div className="flex items-center gap-0 flex-wrap">
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
idx === 0
? 'bg-red-500/10 border-red-500/30 text-red-400'
: idx === 1
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
: idx === 2
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
: 'bg-green-500/10 border-green-500/30 text-green-400'
}`}>
{step}
</div>
{idx < 3 && (
<span className="mx-2 text-[var(--dg-text-muted)] text-lg">→</span>
)}
</div>
))}
</div>
{/* Query metrics summary */}
<div className="mt-4 grid grid-cols-4 gap-3">
{[
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
].map(m => (
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
</div>
))}
</div>
</div>
);
})()}
</CardContent>
</Card>
)}
@@ -470,7 +712,6 @@ export default function SlowQueryPage() {
{/* Tab 2: Top queries */}
{activeTab === 'top' && (
<div className="space-y-4">
{/* Bar chart */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
@@ -494,17 +735,9 @@ export default function SlowQueryPage() {
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
<YAxis
tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }}
label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }}
/>
<YAxis tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--dg-bg-secondary)',
border: '1px solid var(--dg-border)',
borderRadius: '8px',
fontSize: '12px',
}}
contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number, name: string) => [
`${value.toFixed(1)} ms`,
name === 'avgTime' ? '平均时间' : '最大时间',
@@ -518,7 +751,6 @@ export default function SlowQueryPage() {
</CardContent>
</Card>
{/* Top queries table */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
@@ -583,6 +815,153 @@ export default function SlowQueryPage() {
</Card>
</div>
)}
{/* Tab 3: Trend */}
{activeTab === 'trend' && (
<div className="space-y-4">
{/* Time Distribution */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
耗时分布
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{timeDistribution.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据</p>
</div>
) : (
<div className="flex items-center gap-8">
<ResponsiveContainer width="50%" height={240}>
<PieChart>
<Pie data={timeDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{timeDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{timeDistribution.map(d => (
<div key={d.name} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: d.color }} />
<span className="text-sm flex-1">{d.name}</span>
<span className="text-sm font-semibold">{d.value}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Trend Chart */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-400" />
慢查询趋势(最近14天)
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{trendData.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据</p>
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="date" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<YAxis yAxisId="left" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
<Legend />
<Line yAxisId="left" type="monotone" dataKey="count" name="慢查询数" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="avgTime" name="平均耗时(ms)" stroke="#a855f7" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
)}
{/* Compare Modal */}
{showCompare && compareLogs.length === 2 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCompare(false)}>
<div className="w-full max-w-4xl bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><GitCompare className="w-5 h-5 text-blue-400" /> 查询对比</h2>
<button onClick={() => setShowCompare(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="grid grid-cols-2 gap-4">
{compareLogs.map((log, i) => (
<div key={log.id} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-sm font-medium mb-3 text-blue-400">查询 {i + 1}</div>
<div className="space-y-2">
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">SQL</div>
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all">{truncateSql(log.sql, 200)}</code>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">执行时间</div>
<div className={`text-sm font-semibold ${queryTimeColor(log.query_time)}`}>{formatQueryTime(log.query_time)}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">锁等待</div>
<div className="text-sm font-semibold">{formatQueryTime(log.lock_time)}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">扫描行数</div>
<div className="text-sm font-semibold">{log.rows_examined.toLocaleString()}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">返回行数</div>
<div className="text-sm font-semibold">{log.rows_sent.toLocaleString()}</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Comparison Summary */}
<div className="mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-sm font-medium mb-2">对比总结</div>
<div className="grid grid-cols-4 gap-3 text-center">
{[
{ label: '执行时间差', getValue: () => {
const diff = compareLogs[0].query_time - compareLogs[1].query_time;
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
}, color: compareLogs[0].query_time > compareLogs[1].query_time ? 'text-red-400' : 'text-green-400' },
{ label: '扫描行数差', getValue: () => {
const diff = compareLogs[0].rows_examined - compareLogs[1].rows_examined;
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
}, color: compareLogs[0].rows_examined > compareLogs[1].rows_examined ? 'text-red-400' : 'text-green-400' },
{ label: '锁等待差', getValue: () => {
const diff = compareLogs[0].lock_time - compareLogs[1].lock_time;
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
}, color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数差', getValue: () => {
const diff = compareLogs[0].rows_sent - compareLogs[1].rows_sent;
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
}, color: 'text-[var(--dg-text-secondary)]' },
].map(item => (
<div key={item.label}>
<div className="text-xs text-[var(--dg-text-muted)]">{item.label}</div>
<div className={`text-sm font-semibold ${item.color}`}>{item.getValue()}</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
}
+684 -218
View File
@@ -1,8 +1,16 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
import {
ChevronRight, Check, AlertTriangle, Shield, Database,
FileText, Sparkles, RotateCcw, Loader2, Info,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
import SqlEditor, { formatSQL } from '@/components/SqlEditor';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InstanceItem {
id: number;
@@ -25,283 +33,741 @@ interface AuditResult {
findings: AuditFinding[];
}
var levelColor: Record<string, string> = {
interface StepConfig {
num: number;
label: string;
icon: React.ElementType;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const STEPS: StepConfig[] = [
{ num: 1, label: '填写信息', icon: FileText },
{ num: 2, label: 'SQL 审核', icon: Shield },
{ num: 3, label: '确认提交', icon: Check },
];
const LEVEL_COLOR: Record<string, string> = {
error: 'text-red-400 bg-red-500/15',
warning: 'text-yellow-400 bg-yellow-500/15',
warning: 'text-amber-400 bg-amber-500/15',
info: 'text-blue-400 bg-blue-500/15',
};
var levelLabel: Record<string, string> = {
const LEVEL_LABEL: Record<string, string> = {
error: '错误',
warning: '警告',
info: '提示',
};
export default function TicketCreatePage() {
var navigate = useNavigate();
var [step, setStep] = useState(1);
var [title, setTitle] = useState('');
var [priority, setPriority] = useState('medium');
var [instanceId, setInstanceId] = useState('');
var [database, setDatabase] = useState('');
var [sqlContent, setSqlContent] = useState('');
var [reason, setReason] = useState('');
var [instances, setInstances] = useState<InstanceItem[]>([]);
var [auditResult, setAuditResult] = useState<AuditResult | null>(null);
var [auditing, setAuditing] = useState(false);
var [submitting, setSubmitting] = useState(false);
var [error, setError] = useState('');
const RISK_LABEL: Record<string, string> = {
low: '低风险',
medium: '中风险',
high: '高风险',
};
useEffect(function () {
instanceApi.list().then(function (res) {
var data = (res as any)?.items || res;
setInstances(Array.isArray(data) ? data : []);
}).catch(function () {
setInstances([]);
});
const RISK_COLOR: Record<string, string> = {
low: 'text-green-400 bg-green-500/15 border-green-500/30',
medium: 'text-amber-400 bg-amber-500/15 border-amber-500/30',
high: 'text-red-400 bg-red-500/15 border-red-500/30',
};
const PRIORITY_OPTIONS = [
{ value: 'low', label: '低' },
{ value: 'medium', label: '中' },
{ value: 'high', label: '高' },
];
/* ------------------------------------------------------------------ */
/* Helper: generate rollback SQL suggestion */
/* ------------------------------------------------------------------ */
function generateRollbackSuggestion(sql: string): string {
const upper = sql.toUpperCase().trim();
// UPDATE -> generate reverse UPDATE
if (upper.startsWith('UPDATE')) {
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
const whereMatch = sql.match(/WHERE\s+(.+)/i);
const where = whereMatch ? whereMatch[1].trim() : '';
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
}
// DELETE -> generate INSERT from backup
if (upper.startsWith('DELETE')) {
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
}
// ALTER -> generate reverse ALTER
if (upper.startsWith('ALTER')) {
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
}
// INSERT -> generate DELETE
if (upper.startsWith('INSERT')) {
const tableMatch = sql.match(/INTO\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
return `-- 建议回滚语句:\n-- DELETE FROM ${table} WHERE ...;\n-- 请根据 INSERT 条件编写对应的 DELETE 语句`;
}
return `-- 请根据业务逻辑编写回滚语句`;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function TicketCreatePage() {
const navigate = useNavigate();
// -- Step state --
const [step, setStep] = useState(1);
// -- Form fields --
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('medium');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [rollbackSql, setRollbackSql] = useState('');
const [reason, setReason] = useState('');
// -- Data / Loading --
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [databases, setDatabases] = useState<string[]>([]);
const [loadingInstances, setLoadingInstances] = useState(false);
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [auditing, setAuditing] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
// -- Audit --
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch instances on mount */
/* ---------------------------------------------------------------- */
useEffect(() => {
setLoadingInstances(true);
instanceApi.list()
.then((res: unknown) => {
const data = (res as { items?: unknown[] })?.items ?? res;
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
})
.catch(() => setInstances([]))
.finally(() => setLoadingInstances(false));
}, []);
var handleAudit = async function () {
if (!sqlContent || !instanceId) {
setError('请填写SQL和选择实例');
/* ---------------------------------------------------------------- */
/* Fetch databases when instance changes */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!instanceId) {
setDatabases([]);
setDatabase('');
return;
}
setLoadingDatabases(true);
instanceApi.getDatabases?.(Number(instanceId))
.then((res: unknown) => {
const data = (res as { databases?: string[] })?.databases
|| (res as string[])
|| [];
setDatabases(Array.isArray(data) ? data as string[] : []);
if (Array.isArray(data) && data.length > 0) {
setDatabase(String(data[0]));
}
})
.catch(() => {
// Fallback: no auto database list
setDatabases([]);
})
.finally(() => setLoadingDatabases(false));
}, [instanceId]);
/* ---------------------------------------------------------------- */
/* SQL change callback */
/* ---------------------------------------------------------------- */
const handleSqlChange = useCallback((val: string) => {
setSqlContent(val);
// Auto-generate rollback suggestion when SQL changes
if (val.trim().length > 10) {
setRollbackSql(generateRollbackSuggestion(val));
}
}, []);
/* ---------------------------------------------------------------- */
/* Format SQL */
/* ---------------------------------------------------------------- */
const handleFormatSQL = useCallback(() => {
if (!sqlContent.trim()) return;
const formatted = formatSQL(sqlContent);
setSqlContent(formatted);
}, [sqlContent]);
/* ---------------------------------------------------------------- */
/* Audit handler */
/* ---------------------------------------------------------------- */
const handleAudit = useCallback(async () => {
if (!sqlContent.trim() || !instanceId) {
setError('请填写 SQL 语句并选择实例');
return;
}
setAuditing(true);
setError('');
try {
var result = await ticketApi.audit({
const result = await ticketApi.audit({
sql_content: sqlContent,
instance_id: Number(instanceId),
database: database,
database,
}) as unknown as AuditResult;
setAuditResult(result);
setStep(2);
} catch (e: any) {
setError(e?.response?.data?.detail || '审核失败');
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || '审核失败,请检查 SQL 语法');
} finally {
setAuditing(false);
}
};
}, [sqlContent, instanceId, database]);
var handleSubmit = async function () {
if (!title || !sqlContent || !instanceId || !database || !reason) {
/* ---------------------------------------------------------------- */
/* Pre-submit audit check */
/* ---------------------------------------------------------------- */
const handlePreSubmitAudit = useCallback(async () => {
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
setError('请填写所有必填项');
return;
}
// If already audited and SQL hasn't changed, go directly to step 3
if (auditResult) {
setStep(3);
return;
}
// Auto-audit before submission
setAuditing(true);
setError('');
try {
const result = await ticketApi.audit({
sql_content: sqlContent,
instance_id: Number(instanceId),
database,
}) as unknown as AuditResult;
setAuditResult(result);
setStep(3);
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || '预审核失败,请检查 SQL 语法');
} finally {
setAuditing(false);
}
}, [title, sqlContent, instanceId, database, reason, auditResult]);
/* ---------------------------------------------------------------- */
/* Submit handler */
/* ---------------------------------------------------------------- */
const handleSubmit = useCallback(async () => {
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
setError('请填写所有必填项');
return;
}
setSubmitting(true);
setError('');
try {
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({
title: title,
title: title.trim(),
db_type: inst?.db_type || 'mysql',
instance_id: Number(instanceId),
target_database: database,
sql_content: sqlContent,
reason: reason,
rollback_sql: rollbackSql || undefined,
reason: reason.trim(),
priority: priority,
});
navigate('/tickets');
} catch (e: any) {
setError(e?.response?.data?.detail || '提交失败');
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
/* ---------------------------------------------------------------- */
/* Derived values */
/* ---------------------------------------------------------------- */
const selectedInstance = useMemo(
() => instances.find((i) => i.id === Number(instanceId)),
[instances, instanceId]
);
const riskLevel = useMemo(() => {
if (!auditResult) return '';
if (auditResult.score >= 90) return 'low';
if (auditResult.score >= 60) return 'medium';
return 'high';
}, [auditResult]);
const isStep1Valid = title.trim() && sqlContent.trim() && instanceId && database && reason.trim();
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderStepIndicator = () => (
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
{STEPS.map((s, idx) => {
const isActive = step === s.num;
const isDone = step > s.num;
const StepIcon = s.icon;
return (
<div key={s.num} className="flex items-center flex-1">
<div
className={`flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 shadow-sm'
: isDone
? 'bg-green-500/10 text-green-400'
: 'text-[var(--dg-text-muted)]'
}`}
>
{isDone ? (
<Check className="w-4 h-4" />
) : (
<StepIcon className="w-4 h-4" />
)}
<span className="text-sm font-medium">{s.label}</span>
</div>
{idx < STEPS.length - 1 && (
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />
)}
</div>
);
})}
</div>
);
const renderError = () =>
error ? (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
) : null;
/* ========== Step 1: Fill info ========== */
const renderStep1 = () => (
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
{/* Title */}
<div>
<label className="block text-sm font-medium mb-2">
工单标题 <span className="text-red-400">*</span>
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
placeholder="请输入工单标题,如:用户表添加索引"
/>
</div>
{/* Priority + Instance */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span>
</label>
<select
value={instanceId}
onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
>
<option value="">
{loadingInstances ? '加载中...' : '请选择实例'}
</option>
{instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
</option>
))}
</select>
</div>
</div>
{/* Database */}
<div>
<label className="block text-sm font-medium mb-2">
数据库 <span className="text-red-400">*</span>
</label>
{databases.length > 0 ? (
<select
value={database}
onChange={(e) => setDatabase(e.target.value)}
disabled={loadingDatabases}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
>
{loadingDatabases ? (
<option>加载中...</option>
) : (
databases.map((db) => (
<option key={db} value={db}>{db}</option>
))
)}
</select>
) : (
<div className="flex items-center gap-2">
<input
value={database}
onChange={(e) => setDatabase(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
placeholder="目标数据库名"
/>
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{selectedInstance.db_type.toUpperCase()}
</span>
)}
</div>
)}
</div>
{/* SQL Editor */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">
SQL 语句 <span className="text-red-400">*</span>
</label>
<div className="flex items-center gap-2">
<button
onClick={handleFormatSQL}
disabled={!sqlContent.trim()}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-40"
title="格式化 SQL (Ctrl+Shift+F)"
>
<Sparkles className="w-3 h-3" />
格式化
</button>
<span className="text-xs text-[var(--dg-text-muted)]">
{sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''}
</span>
</div>
</div>
<SqlEditor
value={sqlContent}
onChange={handleSqlChange}
language="sql"
height="220px"
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..."
/>
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
<Info className="w-3 h-3" />
支持 Ctrl+Shift+F 格式化代码
</p>
</div>
{/* Rollback SQL */}
{rollbackSql && (
<div>
<div className="flex items-center gap-2 mb-2">
<RotateCcw className="w-4 h-4 text-purple-400" />
<label className="text-sm font-medium">回滚 SQL(自动建议)</label>
</div>
<SqlEditor
value={rollbackSql}
onChange={setRollbackSql}
language="sql"
height="100px"
placeholder="请输入回滚 SQL 语句..."
/>
</div>
)}
{/* Reason */}
<div>
<label className="block text-sm font-medium mb-2">
变更原因 <span className="text-red-400">*</span>
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:outline-none focus:border-blue-500 transition-colors"
placeholder="请描述变更原因、影响范围、回滚方案等"
/>
</div>
{/* Actions */}
<div className="flex justify-end pt-2">
<button
onClick={handleAudit}
disabled={auditing || !isStep1Valid}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{auditing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
审核中...
</>
) : (
<>
<Shield className="w-4 h-4" />
审核并继续
</>
)}
</button>
</div>
</div>
);
/* ========== Step 2: Audit result ========== */
const renderStep2 = () => {
if (!auditResult) return null;
return (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
{/* Header with score */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--dg-border)]">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
SQL 审核结果
</h3>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ${RISK_COLOR[riskLevel] || ''}`}>
<span className="text-2xl font-bold">{auditResult.score}</span>
<span>分 · {RISK_LABEL[riskLevel] || ''}</span>
</div>
</div>
{/* Findings */}
{auditResult.findings.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-lg font-medium">审核通过,未发现风险</p>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">SQL 语句符合规范要求</p>
</div>
) : (
<div className="space-y-2">
{auditResult.findings.map((f, idx) => (
<div key={idx} className={`flex items-start gap-3 p-3 rounded-lg ${LEVEL_COLOR[f.level] || ''}`}>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="flex items-center gap-2 text-sm font-medium">
<span>{LEVEL_LABEL[f.level] || f.level}</span>
<span className="opacity-60">·</span>
<span className="font-mono text-xs opacity-80">{f.rule}</span>
</div>
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
</div>
</div>
))}
</div>
)}
{/* Summary */}
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-xs text-[var(--dg-text-muted)]">错误</div>
<div className="text-lg font-semibold text-red-400 mt-0.5">
{auditResult.findings.filter((f) => f.level === 'error').length}
</div>
</div>
<div>
<div className="text-xs text-[var(--dg-text-muted)]">警告</div>
<div className="text-lg font-semibold text-amber-400 mt-0.5">
{auditResult.findings.filter((f) => f.level === 'warning').length}
</div>
</div>
<div>
<div className="text-xs text-[var(--dg-text-muted)]">提示</div>
<div className="text-lg font-semibold text-blue-400 mt-0.5">
{auditResult.findings.filter((f) => f.level === 'info').length}
</div>
</div>
</div>
</div>
{/* Navigation */}
<div className="flex justify-between">
<button
onClick={() => setStep(1)}
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
>
上一步
</button>
<button
onClick={() => setStep(3)}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all"
>
下一步:确认提交
</button>
</div>
</div>
);
};
var riskLevel = auditResult
? auditResult.score >= 90 ? 'low'
: auditResult.score >= 60 ? 'medium' : 'high'
: '';
/* ========== Step 3: Confirm ========== */
const renderStep3 = () => {
const inst = instances.find((i) => i.id === Number(instanceId));
var riskLabel: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险' };
var riskColor: Record<string, string> = {
low: 'text-green-400 bg-green-500/15 border-green-500/30',
medium: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
high: 'text-red-400 bg-red-500/15 border-red-500/30',
return (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Check className="w-5 h-5 text-green-400" />
确认工单信息
</h3>
{/* Info grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-[var(--dg-text-muted)] text-xs">标题</span>
<p className="font-medium mt-1">{title}</p>
</div>
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-[var(--dg-text-muted)] text-xs">优先级</span>
<p className="font-medium mt-1">
{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}
</p>
</div>
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-[var(--dg-text-muted)] text-xs">实例</span>
<div className="flex items-center gap-1.5 mt-1">
<Database className="w-3.5 h-3.5 text-[var(--dg-text-secondary)]" />
<span className="font-medium">{inst?.name || instanceId}</span>
<span className="text-xs text-[var(--dg-text-muted)]">
({inst?.host}:{inst?.port})
</span>
</div>
</div>
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-[var(--dg-text-muted)] text-xs">数据库</span>
<p className="font-medium mt-1">{database}</p>
</div>
</div>
{/* SQL Preview */}
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-xs text-[var(--dg-text-muted)]">SQL 语句</span>
<pre className="mt-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
{sqlContent}
</pre>
</div>
{/* Rollback preview */}
{rollbackSql && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
<div className="flex items-center gap-1.5 mb-1">
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
</div>
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap text-purple-300/80">
{rollbackSql}
</pre>
</div>
)}
{/* Reason */}
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<span className="text-xs text-[var(--dg-text-muted)]">变更原因</span>
<p className="mt-1 text-sm">{reason}</p>
</div>
{/* Audit badge */}
{auditResult && (
<div className="flex items-center gap-2 text-sm">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-[var(--dg-text-muted)]">审核评分:</span>
<span className={`font-medium ${RISK_COLOR[riskLevel]?.split(' ')[0] || ''}`}>
{auditResult.score} 分 · {RISK_LABEL[riskLevel]}
</span>
</div>
)}
</div>
{/* Navigation */}
<div className="flex justify-between">
<button
onClick={() => setStep(2)}
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
>
上一步
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
提交中...
</>
) : (
<>确认提交</>
)}
</button>
</div>
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5 max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
<button
onClick={() => navigate('/tickets')}
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all"
aria-label="返回工单列表"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
</div>
</div>
{/* Steps indicator */}
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
{[
{ num: 1, label: '填写信息' },
{ num: 2, label: 'SQL审核' },
{ num: 3, label: '确认提交' },
].map(function (s, idx) {
var isActive = step === s.num;
var isDone = step > s.num;
return (
<div key={s.num} className="flex items-center flex-1">
<div className={'flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-colors ' + (isActive ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : isDone ? 'bg-green-500/10 text-green-400' : 'text-[var(--dg-text-muted)]')}>
{isDone ? <Check className="w-4 h-4" /> : <span className="w-5 h-5 rounded-full border text-xs flex items-center justify-center">{s.num}</span>}
<span className="text-sm font-medium">{s.label}</span>
</div>
{idx < 2 && <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />}
</div>
);
})}
</div>
{/* Steps */}
{renderStepIndicator()}
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
{/* Error */}
{renderError()}
{/* Step 1: Fill info */}
{step === 1 && (
<div className="space-y-4 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div>
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
<input value={title} onChange={function (e) { setTitle(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="请输入工单标题" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<select value={priority} onChange={function (e) { setPriority(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">选择实例 <span className="text-red-400">*</span></label>
<select value={instanceId} onChange={function (e) { setInstanceId(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">请选择实例</option>
{instances.map(function (inst) {
return <option key={inst.id} value={String(inst.id)}>{inst.name} ({inst.host}:{inst.port})</option>;
})}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">数据库 <span className="text-red-400">*</span></label>
<input value={database} onChange={function (e) { setDatabase(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="目标数据库名" />
</div>
<div>
<label className="block text-sm font-medium mb-2">SQL语句 <span className="text-red-400">*</span></label>
<textarea value={sqlContent} onChange={function (e) { setSqlContent(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[180px] resize-y" placeholder="请输入SQL语句" />
</div>
<div>
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
<textarea value={reason} onChange={function (e) { setReason(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y" placeholder="请描述变更原因" />
</div>
<div className="flex justify-end pt-2">
<button onClick={handleAudit} disabled={auditing} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
{auditing ? '审核中...' : '开始审核'}
</button>
</div>
</div>
)}
{/* Step 2: Audit result */}
{step === 2 && auditResult && (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
SQL审核结果
</h3>
<div className={'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ' + riskColor[riskLevel]}>
<span className="text-2xl font-bold">{auditResult.score}</span>
<span>分 · {riskLabel[riskLevel]}</span>
</div>
</div>
{auditResult.findings.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-lg font-medium">审核通过,未发现风险</p>
</div>
) : (
<div className="space-y-2">
{auditResult.findings.map(function (f, idx) {
return (
<div key={idx} className={'flex items-start gap-3 p-3 rounded-lg ' + (levelColor[f.level] || '')}>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<span>{levelLabel[f.level] || f.level}</span>
<span className="opacity-60">·</span>
<span className="font-mono text-xs">{f.rule}</span>
</div>
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-between">
<button onClick={function () { setStep(1); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
上一步
</button>
<button onClick={function () { setStep(3); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
下一步:确认提交
</button>
</div>
</div>
)}
{/* Step 3: Confirm and submit */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
<h3 className="text-lg font-semibold">确认工单信息</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-[var(--dg-text-muted)]">标题</span>
<p className="font-medium mt-1">{title}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">优先级</span>
<p className="font-medium mt-1">{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">实例</span>
<p className="font-medium mt-1">{instances.find(function (i) { return i.id === Number(instanceId); })?.name || instanceId}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">数据库</span>
<p className="font-medium mt-1">{database}</p>
</div>
</div>
<div>
<span className="text-sm text-[var(--dg-text-muted)]">SQL语句</span>
<pre className="mt-1 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{sqlContent}</pre>
</div>
<div>
<span className="text-sm text-[var(--dg-text-muted)]">变更原因</span>
<p className="mt-1 text-sm">{reason}</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={function () { setStep(2); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
上一步
</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
{submitting ? '提交中...' : '确认提交'}
</button>
</div>
</div>
)}
{/* Step content */}
{step === 1 && renderStep1()}
{step === 2 && renderStep2()}
{step === 3 && renderStep3()}
</div>
);
}
+562 -163
View File
@@ -1,18 +1,26 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
Play, RotateCcw, X, Shield,
Play, RotateCcw, X, Shield, User, Calendar,
MessageSquare, Loader2, AlertTriangle, Database,
BarChart3, ChevronDown, ChevronUp, Link2,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { statusMap, statusColorMap } from '@/lib/mock';
import { useAuth } from '@/hooks/useAuth';
import SqlEditor from '@/components/SqlEditor';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface TicketData {
id: number;
title: string;
db_type: string;
instance_id: number;
instance_name?: string;
target_database: string;
sql_content: string;
rollback_sql: string | null;
@@ -25,6 +33,8 @@ interface TicketData {
reason: string | null;
audit_result: string | null;
execute_result: string | null;
affected_rows?: number;
execute_duration_ms?: number;
create_time: string;
review_time: string | null;
execute_time: string | null;
@@ -38,252 +48,641 @@ interface TimelineEntry {
detail: string | null;
}
var actionIcon: Record<string, React.ReactNode> = {
CREATE_TICKET: <CheckCircle className="w-4 h-4 text-blue-400" />,
REVIEW_TICKET: <Shield className="w-4 h-4 text-green-400" />,
EXECUTE_TICKET: <Play className="w-4 h-4 text-purple-400" />,
CANCEL_TICKET: <XCircle className="w-4 h-4 text-red-400" />,
ROLLBACK_TICKET: <RotateCcw className="w-4 h-4 text-orange-400" />,
interface RelatedTicket {
id: number;
title: string;
status: string;
applicant_name: string | null;
create_time: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
EXECUTE_TICKET: { icon: Play, color: 'text-purple-400', bg: 'bg-purple-500/15' },
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
};
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
high: { label: '高', color: 'text-red-400 bg-red-500/15' },
medium: { label: '中', color: 'text-amber-400 bg-amber-500/15' },
low: { label: '低', color: 'text-green-400 bg-green-500/15' },
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function TicketDetailPage() {
var params = useParams();
var navigate = useNavigate();
var auth = useAuth();
var ticketId = Number(params.id);
const params = useParams();
const navigate = useNavigate();
const auth = useAuth();
const ticketId = Number(params.id);
var [ticket, setTicket] = useState<TicketData | null>(null);
var [timeline, setTimeline] = useState<TimelineEntry[]>([]);
var [loading, setLoading] = useState(true);
var [actionLoading, setActionLoading] = useState(false);
var [comment, setComment] = useState('');
var [showRejectInput, setShowRejectInput] = useState(false);
// -- Data --
const [ticket, setTicket] = useState<TicketData | null>(null);
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
var fetchTicket = async function () {
// -- Action states --
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [comment, setComment] = useState('');
const [showCommentInput, setShowCommentInput] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
// -- UI expansion --
const [showFullAudit, setShowFullAudit] = useState(false);
const [showRelated, setShowRelated] = useState(true);
/* ---------------------------------------------------------------- */
/* Fetch ticket data */
/* ---------------------------------------------------------------- */
const fetchTicket = useCallback(async () => {
try {
var data = await ticketApi.get(ticketId) as unknown as TicketData;
const data = await ticketApi.get(ticketId) as unknown as TicketData;
setTicket(data);
setError('');
} catch {
setError('工单不存在或加载失败');
setTicket(null);
} finally {
setLoading(false);
}
};
}, [ticketId]);
var fetchTimeline = async function () {
const fetchTimeline = useCallback(async () => {
try {
var data = await ticketApi.timeline(ticketId) as any;
setTimeline(data.timeline || []);
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
setTimeline(res?.timeline || []);
} catch {
setTimeline([]);
}
};
useEffect(function () {
fetchTicket();
fetchTimeline();
}, [ticketId]);
var handleAction = async function (action: string) {
setActionLoading(true);
// Mock related tickets fetch
const fetchRelated = useCallback(async () => {
// In real implementation: await ticketApi.related(ticketId)
setRelatedTickets([
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
]);
}, [ticketId]);
useEffect(() => {
if (!isNaN(ticketId)) {
setLoading(true);
fetchTicket();
fetchTimeline();
fetchRelated();
}
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
/* ---------------------------------------------------------------- */
/* Action handler */
/* ---------------------------------------------------------------- */
const handleAction = useCallback(async (action: string) => {
if (!ticket) return;
// Reset comment state
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
setActionLoading(action);
setError('');
try {
if (action === 'approve') {
await ticketApi.review(ticketId, { action: 'approve', comment: '审批通过' });
} else if (action === 'reject') {
await ticketApi.review(ticketId, { action: 'reject', comment: comment || '拒绝' });
setShowRejectInput(false);
setComment('');
} else if (action === 'execute') {
await ticketApi.execute(ticketId);
} else if (action === 'rollback') {
await ticketApi.rollback(ticketId);
} else if (action === 'cancel') {
await ticketApi.cancel(ticketId);
switch (action) {
case 'approve':
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
break;
case 'reject':
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
setShowCommentInput(false);
setComment('');
break;
case 'execute':
await ticketApi.execute(ticketId);
break;
case 'rollback':
await ticketApi.rollback(ticketId);
setShowRollbackConfirm(false);
break;
case 'cancel':
await ticketApi.cancel(ticketId);
break;
default:
break;
}
fetchTicket();
fetchTimeline();
} catch {
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || `操作失败: ${action}`);
} finally {
setActionLoading(false);
setActionLoading(null);
setPendingAction(null);
}
};
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
/* ---------------------------------------------------------------- */
/* Permission checks */
/* ---------------------------------------------------------------- */
const { isAdminOrDba, isApplicant, canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
if (!ticket) {
return { isAdminOrDba: false, isApplicant: false, canApprove: false, canExecute: false, canRollback: false, canCancel: false };
}
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
const applicant = ticket.applicant_id === auth.user.id;
return {
isAdminOrDba: adminOrDba,
isApplicant: applicant,
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
canExecute: adminOrDba && ticket.status === 'approved',
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing'),
};
}, [ticket, auth.user.role, auth.user.id]);
/* ---------------------------------------------------------------- */
/* Parse execute result for visualization */
/* ---------------------------------------------------------------- */
const executeStats = useMemo(() => {
if (!ticket?.execute_result) return null;
// Try to extract affected rows and duration from execute result
const result = ticket.execute_result;
const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i);
const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i);
return {
affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined),
durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined),
raw: result,
};
}, [ticket]);
/* ---------------------------------------------------------------- */
/* Loading & Error states */
/* ---------------------------------------------------------------- */
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
}
if (!ticket) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<p>工单不存在</p>
<button onClick={function () { navigate('/tickets'); }} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">返回列表</button>
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-3">
<Loader2 className="w-8 h-8 animate-spin" />
<p className="text-sm">加载中...</p>
</div>
);
}
var isAdminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
var isApplicant = ticket.applicant_id === auth.user.id;
var priorityLabel = ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低';
if (error || !ticket) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-4">
<AlertTriangle className="w-12 h-12 text-amber-400" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{error || '工单不存在'}</p>
<button
onClick={() => navigate('/tickets')}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
>
返回列表
</button>
</div>
);
}
const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium;
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderActionButtons = () => {
if (showRollbackConfirm) {
return (
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
</div>
<button
onClick={() => handleAction('rollback')}
disabled={!!actionLoading}
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
>
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
</button>
<button
onClick={() => setShowRollbackConfirm(false)}
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
);
}
return (
<div className="flex flex-wrap gap-2">
{/* Approve / Reject */}
{canApprove && (
<>
{!showCommentInput ? (
<>
<button
onClick={() => { setPendingAction('approve'); setShowCommentInput(true); }}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
>
<CheckCircle className="w-4 h-4" /> 审批通过
</button>
<button
onClick={() => { setPendingAction('reject'); setShowCommentInput(true); }}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
>
<XCircle className="w-4 h-4" /> 审批拒绝
</button>
</>
) : (
<div className="flex items-center gap-2 w-full">
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
autoFocus
/>
<button
onClick={() => pendingAction && handleAction(pendingAction)}
disabled={!!actionLoading}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
pendingAction === 'reject'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
} disabled:opacity-50`}
>
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
</button>
<button
onClick={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
)}
</>
)}
{/* Execute */}
{canExecute && (
<button
onClick={() => handleAction('execute')}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all"
>
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
执行
</button>
)}
{/* Rollback */}
{canRollback && (
<button
onClick={() => setShowRollbackConfirm(true)}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
>
<RotateCcw className="w-4 h-4" /> 回滚
</button>
)}
{/* Cancel */}
{canCancel && (
<button
onClick={() => handleAction('cancel')}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
>
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
取消工单
</button>
)}
</div>
);
};
const renderTimeline = () => {
if (timeline.length === 0) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
</h3>
<div className="relative pl-8">
{/* Vertical line */}
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
{timeline.map((entry, idx) => {
const config = ACTION_CONFIG[entry.action] || {
icon: CheckCircle,
color: 'text-[var(--dg-text-muted)]',
bg: 'bg-[var(--dg-bg-tertiary)]',
};
const Icon = config.icon;
const isLast = idx === timeline.length - 1;
return (
<div key={idx} className={`relative ${isLast ? '' : 'mb-6'}`}>
{/* Icon node */}
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
</div>
<div className="ml-2">
{/* Label + User */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{entry.label}</span>
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
<User className="w-3 h-3" />
{entry.user}
</span>
</div>
{/* Time */}
{entry.time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(entry.time).toLocaleString()}
</div>
)}
{/* Detail */}
{entry.detail && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
{entry.detail}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
const renderExecuteStats = () => {
if (!executeStats) return null;
return (
<div className="mt-3 grid grid-cols-2 gap-3">
{executeStats.affectedRows !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<BarChart3 className="w-3.5 h-3.5" />
影响行数
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.affectedRows.toLocaleString()}
</div>
</div>
)}
{executeStats.durationMs !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<Clock className="w-3.5 h-3.5" />
执行耗时
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.durationMs >= 1000
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
: `${Math.round(executeStats.durationMs)}ms`}
</div>
</div>
)}
</div>
);
};
const renderRelatedTickets = () => {
if (relatedTickets.length === 0) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowRelated(!showRelated)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
</h3>
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
{showRelated && (
<div className="mt-3 space-y-2">
{relatedTickets.map((rt) => (
<button
key={rt.id}
onClick={() => navigate(`/tickets/${rt.id}`)}
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
TK-{rt.id} · {rt.title}
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
</div>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
{statusMap[rt.status] || rt.status}
</span>
</button>
))}
</div>
)}
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5 max-w-5xl">
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-center gap-4">
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
<button
onClick={() => navigate('/tickets')}
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
aria-label="返回工单列表"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<div>
<h1 className="text-2xl font-semibold flex items-center gap-3">
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
{ticket.title}
<span className={'px-2.5 py-1 rounded-full text-xs font-medium ' + (statusColorMap[ticket.status] || '')}>
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
{priorityInfo.label}优先级
</span>
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
</p>
</div>
</div>
<div className="flex gap-2">
{/* Approve/Reject for admin/dba when pending */}
{isAdminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<>
<button onClick={function () { handleAction('approve'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
<CheckCircle className="w-4 h-4" /> 通过
</button>
{!showRejectInput ? (
<button onClick={function () { setShowRejectInput(true); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
<XCircle className="w-4 h-4" /> 拒绝
</button>
) : (
<div className="flex items-center gap-2">
<input value={comment} onChange={function (e) { setComment(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-48" placeholder="拒绝原因..." />
<button onClick={function () { handleAction('reject'); }} className="px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400">确认拒绝</button>
<button onClick={function () { setShowRejectInput(false); }} className="px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm">取消</button>
</div>
)}
</>
)}
{/* Execute for admin/dba when approved */}
{isAdminOrDba && ticket.status === 'approved' && (
<button onClick={function () { handleAction('execute'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
<Play className="w-4 h-4" /> 执行
</button>
)}
{/* Rollback for admin when completed/success */}
{isAdminOrDba && (ticket.status === 'success' || ticket.status === 'failed') && (
<button onClick={function () { handleAction('rollback'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all">
<RotateCcw className="w-4 h-4" /> 回滚
</button>
)}
{/* Cancel for applicant when pending */}
{isApplicant && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button onClick={function () { handleAction('cancel'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all">
<X className="w-4 h-4" /> 取消工单
</button>
)}
</div>
{/* Action buttons */}
{renderActionButtons()}
</div>
{/* Info cards */}
<div className="grid grid-cols-2 gap-5">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Ticket Info */}
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
{[
{ label: '工单号', value: 'TK-' + ticket.id },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityLabel },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ label: '变更原因', value: ticket.reason || '-' },
].map(function (item, idx) {
return (
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
<span className="text-sm font-medium">{item.value}</span>
</div>
);
})}
<div className="space-y-1">
{[
{ label: '工单号', value: `TK-${ticket.id}` },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database, icon: Database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityInfo.label },
{
label: '创建时间',
value: new Date(ticket.create_time).toLocaleString(),
},
ticket.review_time && {
label: '审批时间',
value: new Date(ticket.review_time).toLocaleString(),
},
ticket.execute_time && {
label: '执行时间',
value: new Date(ticket.execute_time).toLocaleString(),
},
{ label: '变更原因', value: ticket.reason || '-' },
]
.filter(Boolean)
.map((item, idx) => {
if (!item) return null;
const ItemIcon = (item as { icon?: React.ElementType }).icon;
return (
<div
key={idx}
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
>
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
{item.label}
</span>
<span className="text-sm font-medium">{item.value}</span>
</div>
);
})}
</div>
</div>
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
{/* SQL Content (SqlEditor read-only) */}
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.sql_content}</pre>
<SqlEditor
value={ticket.sql_content}
onChange={() => { /* read-only */ }}
language="sql"
height="280px"
readOnly
/>
</div>
</div>
{/* Audit result */}
{ticket.audit_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
<div className="mt-3 text-sm">{ticket.audit_result}</div>
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
{ticket.audit_result}
</div>
</div>
)}
{/* Rollback SQL */}
{ticket.rollback_sql && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.rollback_sql}</pre>
<SqlEditor
value={ticket.rollback_sql}
onChange={() => { /* read-only */ }}
language="sql"
height="120px"
readOnly
/>
</div>
)}
{/* Execute result */}
{ticket.execute_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap">{ticket.execute_result}</pre>
{ticket.execute_time && <div className="text-xs text-[var(--dg-text-muted)] mt-2">执行时间: {new Date(ticket.execute_time).toLocaleString()}</div>}
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{renderExecuteStats()}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
{/* Timeline */}
{timeline.length > 0 && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
</h3>
<div className="mt-4 relative pl-8">
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
{timeline.map(function (entry, idx) {
return (
<div key={idx} className="relative mb-6 last:mb-0">
<div className="absolute -left-8 top-0.5 w-5 h-5 rounded-full bg-[var(--dg-bg-secondary)] border-2 border-[var(--dg-border)] flex items-center justify-center z-10">
{actionIcon[entry.action] || <div className="w-2 h-2 rounded-full bg-[var(--dg-text-muted)]" />}
</div>
<div className="ml-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="text-xs text-[var(--dg-text-muted)]">by {entry.user}</span>
</div>
{entry.time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{new Date(entry.time).toLocaleString()}
</div>
)}
{entry.detail && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-primary)] rounded-lg">
{entry.detail}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{renderTimeline()}
{/* Related tickets */}
{renderRelatedTickets()}
</div>
);
}
File diff suppressed because it is too large. Load diff
+724 -90
View File
@@ -1,9 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { Plus, Search, Edit2, Trash2, XCircle } from 'lucide-react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
UserCheck, UserX, Filter, MoreHorizontal, Lock,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { roles } from '@/lib/mock';
import { roles, users as mockUsers } from '@/lib/mock';
import { userApi } from '@/api/user';
/* ───────── Types ───────── */
interface UserItem {
id: number;
username: string;
@@ -12,8 +18,25 @@ interface UserItem {
role: string;
is_active: boolean;
created_at: string;
last_login?: string | null;
}
interface UserActivity {
id: string;
action: string;
target: string;
created_at: string;
status: 'success' | 'failed';
}
interface FormErrors {
username?: string;
password?: string;
email?: string;
}
/* ───────── Constants ───────── */
const roleNameMap: Record<string, string> = {
admin: '超级管理员',
dba: '数据库管理员',
@@ -25,25 +48,121 @@ const roleTagStyle: Record<string, string> = {
admin: 'bg-purple-500/15 text-purple-400',
dba: 'bg-blue-500/15 text-blue-400',
developer: 'bg-green-500/15 text-green-400',
guest: 'bg-gray-500/15 text-gray-400',
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
};
const roleOptions = [
{ value: '', label: '全部角色' },
{ value: 'admin', label: '超级管理员' },
{ value: 'dba', label: '数据库管理员' },
{ value: 'developer', label: '开发人员' },
{ value: 'guest', label: '访客' },
];
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const BATCH_OPTIONS = [
{ key: 'enable', label: '批量启用', icon: UserCheck },
{ key: 'disable', label: '批量禁用', icon: UserX },
{ key: 'delete', label: '批量删除', icon: Trash2 },
] as const;
/* ───────── Validation ───────── */
function validateUserForm(data: { username: string; password: string; email: string }): FormErrors {
const errors: FormErrors = {};
if (!data.username.trim()) {
errors.username = '用户名不能为空';
} else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) {
errors.username = '用户名只能包含字母、数字和下划线,长度3-30';
}
if (!data.password) {
errors.password = '密码不能为空';
} else if (data.password.length < 6) {
errors.password = '密码长度至少6位';
}
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = '邮箱格式不正确';
}
return errors;
}
/* ───────── Mock Activities ───────── */
function generateMockActivities(userId: number): UserActivity[] {
const actions = [
{ action: '登录系统', target: 'Web Console' },
{ action: '执行查询', target: 'users 表' },
{ action: '提交工单', target: 'TK-20240115-001' },
{ action: '审批工单', target: 'TK-20240115-002' },
{ action: '导出数据', target: 'orders 表' },
{ action: '修改密码', target: '账户安全' },
];
return actions.map((a, i) => ({
id: `act-${userId}-${i}`,
action: a.action,
target: a.target,
created_at: new Date(Date.now() - i * 3600000 * 2).toISOString(),
status: Math.random() > 0.2 ? 'success' : 'failed',
}));
}
/* ───────── CSV Export Helper ───────── */
function exportUsersToCSV(users: UserItem[]) {
const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间'];
const rows = users.map(u => [
u.username,
u.display_name || '',
u.email || '',
roleNameMap[u.role] || u.role,
u.is_active ? '启用' : '禁用',
u.created_at,
]);
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/* ───────── Main Component ───────── */
export default function UsersPage() {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [loginTimeFilter, setLoginTimeFilter] = useState('');
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
const [showEdit, setShowEdit] = useState<UserItem | null>(null);
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBatchMenu, setShowBatchMenu] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await userApi.list();
const data = (res as any)?.items || res;
setUsers(Array.isArray(data) ? data : []);
// Merge with mock user data to enrich with last_login
const merged = (Array.isArray(data) ? data : []).map((u: UserItem) => {
const mockU = mockUsers.find(m => m.id === u.id);
return { ...u, last_login: mockU?.lastLogin || u.created_at };
});
setUsers(merged);
} catch {
// keep existing data
setToast({ message: '获取用户列表失败', type: 'error' });
} finally {
setLoading(false);
}
@@ -51,174 +170,468 @@ export default function UsersPage() {
useEffect(() => { fetchUsers(); }, [fetchUsers]);
const filtered = users.filter(u => {
if (roleFilter && u.role !== roleFilter) return false;
if (statusFilter === 'active' && !u.is_active) return false;
if (statusFilter === 'disabled' && u.is_active) return false;
if (search && !u.username.includes(search) && !(u.email || '').includes(search)) return false;
return true;
});
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(t);
}, [toast]);
const filtered = useMemo(() => {
return users.filter(u => {
if (roleFilter && u.role !== roleFilter) return false;
if (statusFilter === 'active' && !u.is_active) return false;
if (statusFilter === 'disabled' && u.is_active) return false;
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
if (loginTimeFilter && u.last_login) {
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
if (loginTimeFilter === '7d' && days > 7) return false;
if (loginTimeFilter === '30d' && days > 30) return false;
if (loginTimeFilter === '90d' && days > 90) return false;
}
return true;
});
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
const toggleSelectAll = () => {
if (allSelected) {
const next = new Set(selectedIds);
filtered.forEach(u => next.delete(u.id));
setSelectedIds(next);
} else {
const next = new Set(selectedIds);
filtered.forEach(u => next.add(u.id));
setSelectedIds(next);
}
};
const toggleSelect = (id: number) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const handleToggleActive = async (user: UserItem) => {
try {
await userApi.update(user.id, { is_active: !user.is_active });
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
fetchUsers();
} catch {}
} catch {
setToast({ message: '操作失败', type: 'error' });
}
};
const handleDelete = async (id: number) => {
if (!confirm('确定删除该用户?')) return;
if (!confirm('确定删除该用户?此操作不可恢复。')) return;
try {
await userApi.delete(id);
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
setToast({ message: '用户已删除', type: 'success' });
fetchUsers();
} catch {}
} catch {
setToast({ message: '删除失败', type: 'error' });
}
};
const handleBatchAction = async (action: string) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (action === 'delete' && !confirm(`确定删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return;
try {
if (action === 'enable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
} else if (action === 'disable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
} else if (action === 'delete') {
await Promise.all(ids.map(id => userApi.delete(id)));
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
}
setSelectedIds(new Set());
fetchUsers();
} catch {
setToast({ message: '批量操作失败', type: 'error' });
}
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
// Simulate import
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
fetchUsers();
} catch {
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">用户管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
</div>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Plus className="w-4 h-4" /> 新建用户
</button>
</div>
{/* 筛选 */}
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索用户名/邮箱" value={search} onChange={e => setSearch(e.target.value)} />
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all cursor-pointer">
<Upload className="w-4 h-4" /> 导入
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
</label>
<button
onClick={() => exportUsersToCSV(filtered)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Download className="w-4 h-4" /> 导出
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" /> 新建用户
</button>
</div>
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
<option value="">全部角色</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
{/* 用户表格 */}
{/* Filters */}
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
placeholder="搜索用户名/邮箱/显示名称"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
value={roleFilter}
onChange={e => setRoleFilter(e.target.value)}
>
{roleOptions.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
value={loginTimeFilter}
onChange={e => setLoginTimeFilter(e.target.value)}
>
<option value="">全部登录时间</option>
<option value="7d">7天内登录</option>
<option value="30d">30天内登录</option>
<option value="90d">90天内登录</option>
</select>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<RotateCcw className="w-3 h-3" /> 重置
</button>
)}
</div>
{/* Batch Actions Bar */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between px-4 py-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<span className="text-sm text-blue-400">
已选择 <strong>{selectedIds.size}</strong> 项
</span>
<div className="relative">
<button
onClick={() => setShowBatchMenu(!showBatchMenu)}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm text-blue-400 hover:bg-blue-500/25 transition-colors"
>
批量操作 <ChevronDown className="w-3 h-3" />
</button>
{showBatchMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowBatchMenu(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[160px]">
{BATCH_OPTIONS.map(opt => {
const Icon = opt.icon;
return (
<button
key={opt.key}
onClick={() => { handleBatchAction(opt.key); setShowBatchMenu(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
{opt.label}
</button>
);
})}
</div>
</>
)}
</div>
</div>
)}
{/* User Table */}
<Card className="dg-card">
<CardContent className="p-0">
{loading && users.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
加载中...
</div>
) : filtered.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">没有找到匹配的用户</p>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
>
清除筛选条件
</button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold">用户</th>
<th className="text-left px-5 py-3 font-semibold">角色</th>
<th className="text-left px-5 py-3 font-semibold">邮箱</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">操作</th>
<th className="text-left px-3 py-3 w-10">
<button onClick={toggleSelectAll} className="hover:opacity-70 transition-opacity">
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
</th>
<th className="text-left px-4 py-3 font-semibold">用户</th>
<th className="text-left px-4 py-3 font-semibold">角色</th>
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(user => (
<tr key={user.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
{(user.display_name || user.username).charAt(0)}
{filtered.map(user => {
const isSelected = selectedIds.has(user.id);
return (
<tr
key={user.id}
className={`border-b border-[var(--dg-border-light)] transition-colors ${
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
>
<td className="px-3 py-3.5">
<button onClick={() => toggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div>
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
</div>
</div>
<div>
<div className="font-medium">{user.display_name || user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
{roleNameMap[user.role] || user.role}
</span>
</td>
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
{user.last_login
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '-'}
</td>
<td className="px-4 py-3.5">
<button
onClick={() => handleToggleActive(user)}
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
title={user.is_active ? '点击禁用' : '点击启用'}
aria-label={user.is_active ? '禁用用户' : '启用用户'}
>
<div
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
/>
</button>
</td>
<td className="px-4 py-3.5">
<div className="flex gap-1.5">
<button
onClick={() => setShowDetail(user)}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="查看详情"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => setShowEdit(user)}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setShowResetPwd(user)}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400 transition-colors"
title="重置密码"
>
<KeyRound className="w-4 h-4" />
</button>
<button
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
onClick={() => handleDelete(user.id)}
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-gray-500/15 text-gray-400'}`}>
{roleNameMap[user.role] || user.role}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
<td className="px-5 py-3.5">
<button onClick={() => handleToggleActive(user)} className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all`} style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }} />
</button>
</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Edit2 className="w-4 h-4" /></button>
<button className="text-red-400 hover:text-red-300 transition-colors" onClick={() => handleDelete(user.id)}><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
<span className="text-sm text-[var(--dg-text-muted)]">
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
</span>
</div>
</>
)}
</CardContent>
</Card>
{/* 新建用户弹窗 */}
{/* Create Modal */}
{showCreate && (
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
)}
{/* Detail Modal */}
{showDetail && (
<UserDetailModal user={showDetail} onClose={() => setShowDetail(null)} />
)}
{/* Edit Modal */}
{showEdit && (
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
)}
{/* Reset Password Modal */}
{showResetPwd && (
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
)}
</div>
);
}
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
/* ───────── Create User Modal ───────── */
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState('developer');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const handleSubmit = async () => {
if (!username || !password) { setError('用户名和密码必填'); return; }
const validation = validateUserForm({ username, password, email });
setErrors(validation);
if (Object.keys(validation).length > 0) return;
setSubmitting(true);
setError('');
try {
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
setToast({ message: '用户创建成功', type: 'success' });
onCreated();
} catch (e: any) {
setError(e?.response?.data?.detail || '创建失败');
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold">新建用户</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">用户名 *</label>
<input value={username} onChange={e => setUsername(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
<input
value={username}
onChange={e => { setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }}
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
placeholder="3-30位字母、数字或下划线"
/>
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">显示名称</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="用户的显示名称" />
</div>
<div>
<label className="block text-sm font-medium mb-1">密码 *</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
<input
type="password"
value={password}
onChange={e => { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }}
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
placeholder="至少6位"
/>
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">邮箱</label>
<input value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
<input
type="email"
value={email}
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
placeholder="example@company.com"
/>
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">角色</label>
@@ -229,10 +642,9 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
<option value="guest">访客</option>
</select>
</div>
{error && <div className="text-sm text-red-400">{error}</div>}
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50">
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '创建中...' : '创建'}
</button>
</div>
@@ -241,3 +653,225 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
</div>
);
}
/* ───────── Edit User Modal ───────── */
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
const [displayName, setDisplayName] = useState(user.display_name || '');
const [email, setEmail] = useState(user.email || '');
const [role, setRole] = useState(user.role);
const [isActive, setIsActive] = useState(user.is_active);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
try {
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
setToast({ message: '用户更新成功', type: 'success' });
onUpdated();
} catch {
setToast({ message: '更新失败', type: 'error' });
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold">编辑用户</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div>
<div className="font-medium">{user.display_name || user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">显示名称</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
</div>
<div>
<label className="block text-sm font-medium mb-1">邮箱</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
</div>
<div>
<label className="block text-sm font-medium mb-1">角色</label>
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<option value="admin">超级管理员</option>
<option value="dba">数据库管理员</option>
<option value="developer">开发人员</option>
<option value="guest">访客</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsActive(!isActive)}
className={`relative w-11 h-6 rounded-full transition-colors ${isActive ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
>
<div className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" style={{ transform: isActive ? 'translateX(20px)' : 'translateX(2px)' }} />
</button>
<span className="text-sm">{isActive ? '已启用' : '已禁用'}</span>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
</div>
);
}
/* ───────── Reset Password Modal ───────── */
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
setError('');
if (!newPassword) { setError('请输入新密码'); return; }
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
setSubmitting(true);
try {
await userApi.update(user.id, { password: newPassword });
setToast({ message: '密码重置成功', type: 'success' });
onClose();
} catch {
setToast({ message: '密码重置失败', type: 'error' });
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><KeyRound className="w-5 h-5 text-yellow-400" /> 重置密码</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
</div>
<div className="p-3 bg-yellow-500/5 border border-yellow-500/15 rounded-lg mb-4">
<p className="text-sm text-[var(--dg-text-secondary)]">为用户 <strong className="text-[var(--dg-text-primary)]">{user.display_name || user.username}</strong> 设置新密码</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">新密码</label>
<input type="password" value={newPassword} onChange={e => { setNewPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="至少6位" />
</div>
<div>
<label className="block text-sm font-medium mb-1">确认密码</label>
<input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="再次输入" />
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-yellow-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '重置中...' : '确认重置'}
</button>
</div>
</div>
</div>
</div>
);
}
/* ───────── User Detail Modal ───────── */
function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
const activities = useMemo(() => generateMockActivities(user.id), [user.id]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold">用户详情</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
</div>
{/* User Info Card */}
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-lg font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold">{user.display_name || user.username}</div>
<div className="text-sm text-[var(--dg-text-muted)]">@{user.username}</div>
<div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${roleTagStyle[user.role] || ''}`}>
{roleNameMap[user.role] || user.role}
</span>
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${user.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{user.is_active ? '启用' : '禁用'}
</span>
</div>
</div>
</div>
{/* Detail Fields */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">邮箱</div>
<div className="text-sm font-medium truncate">{user.email || '-'}</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">创建时间</div>
<div className="text-sm font-medium">{new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">最后登录</div>
<div className="text-sm font-medium">{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
<div className="text-sm font-medium">#{user.id}</div>
</div>
</div>
{/* Activity Log */}
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
最近活动
</h3>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{activities.map(act => (
<div key={act.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors">
<div className={`w-2 h-2 rounded-full ${act.status === 'success' ? 'bg-green-400' : 'bg-red-400'}`} />
<div className="flex-1 min-w-0">
<div className="text-sm">{act.action}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{act.target}</div>
</div>
<div className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{new Date(act.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
/* ───────── Need Clock icon for detail modal ───────── */
function Clock(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
}
File diff suppressed because it is too large. Load diff