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:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16333
-2225
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ? '禁用' : '启用'}规则 "{rule.name}"?
|
||||
</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>匹配 "{searchQuery}"</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
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1053
-337
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">-></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+793
-165
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
+825
-148
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user