feat: Phase 2 — frontend-backend integration complete
- All 5 core pages now use real API instead of mock data - Dashboard: /api/v1/dashboard/stats - Tickets: list/create/detail/approve/execute/statistics - Users: list/create/toggle-active/delete - Instances: list/create/test-connection/delete - Queries: execute with result display + sensitive field masking - Updated DashboardStats type to match backend schema - Fixed InstancePage to work with InstanceResponse (no qps/cpu/metrics yet) - Added create forms for Tickets, Users, Instances - Added connection test in InstanceDetail - Updated TDD_PLAN.md: Phase 1 ✅ (44 tests), Phase 2 ✅ - Updated ROADMAP.md: v0.2.0 ✅ - Updated PROJECT_EVALUATION.md: score 5.7 → 6.2 - Frontend build passes with zero TS errors - 44 backend tests still passing
This commit is contained in:
1 parent
c0c731acfc
commit
27989274f5
10 files changed
+1074
-840
No files matched your search
@@ -1,14 +1,14 @@
|
||||
import type { Ticket, DBInstance, QueryAudit, AuditRule, Workflow, NotificationChannel, Role, User, DataPermission, PermissionNode, DashboardStats } from '@/types';
|
||||
|
||||
export const dashboardStats: DashboardStats = {
|
||||
todayTickets: 128,
|
||||
todayTicketsTrend: 12,
|
||||
pendingApproval: 23,
|
||||
pendingTrend: -5,
|
||||
queryAuditCount: 15234,
|
||||
queryTrend: 8,
|
||||
slowQueries: 47,
|
||||
slowQueryTrend: -12,
|
||||
ticket_total: 128,
|
||||
ticket_pending: 23,
|
||||
ticket_month: 342,
|
||||
ticket_executing: 3,
|
||||
instance_total: 11,
|
||||
instance_online: 8,
|
||||
user_total: 7,
|
||||
queries_today: 15234,
|
||||
};
|
||||
|
||||
export const menuSections = [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||
TrendingUp, TrendingDown, Database, Megaphone, ChevronRight,
|
||||
Database, ChevronRight,
|
||||
X, AlertCircle, Info, Bell
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dashboardStats, dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||
import type { Announcement } from '@/lib/mock';
|
||||
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
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 }> = {
|
||||
@@ -20,19 +21,23 @@ const priorityStyle: Record<string, { border: string; bg: string; icon: React.El
|
||||
|
||||
const priorityLabel: Record<string, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||
|
||||
const stats = [
|
||||
{ title: '今日工单', value: dashboardStats.todayTickets, trend: dashboardStats.todayTicketsTrend, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ title: '待审批', value: dashboardStats.pendingApproval, trend: dashboardStats.pendingTrend, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ title: '查询审计', value: dashboardStats.queryAuditCount.toLocaleString(), trend: dashboardStats.queryTrend, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ title: '慢查询', value: dashboardStats.slowQueries, trend: dashboardStats.slowQueryTrend, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
dashboardApi.getStats()
|
||||
.then(data => setStats(data))
|
||||
.catch(() => {
|
||||
// fallback: keep stats null, show skeleton
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const visibleAnnouncements = announcements
|
||||
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
||||
@@ -128,6 +133,14 @@ export default function Dashboard() {
|
||||
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
||||
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
|
||||
|
||||
// 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' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 公告通知栏 */}
|
||||
@@ -192,26 +205,34 @@ export default function Dashboard() {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-5">
|
||||
{stats.map((s, i) => (
|
||||
<Card key={i} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-5">
|
||||
<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`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
{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>
|
||||
))
|
||||
) : (
|
||||
statsCards.map((s, i) => (
|
||||
<Card key={i} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-5">
|
||||
<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`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)] mb-2">{s.value}</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`flex items-center gap-1 font-medium ${s.trend >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{s.trend >= 0 ? <TrendingUp className="w-3.5 h-3.5" /> : <TrendingDown className="w-3.5 h-3.5" />}
|
||||
{Math.abs(s.trend)}%
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">较昨日</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<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} 用户
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图表 + 实例状态 */}
|
||||
|
||||
+263
-168
@@ -1,16 +1,31 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
||||
Activity, HardDrive, Cpu, MemoryStick, Clock, ChevronRight, Trash2, Edit2,
|
||||
Undo2,
|
||||
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dbInstances } from '@/lib/mock';
|
||||
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { DBInstance } from '@/types';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
interface InstanceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
database: string | null;
|
||||
env: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
version: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||
mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||
@@ -19,6 +34,10 @@ const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg:
|
||||
mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||
};
|
||||
|
||||
const envLabel: Record<string, string> = {
|
||||
production: '生产', staging: '预发', development: '开发',
|
||||
};
|
||||
|
||||
export default function InstancePage() {
|
||||
const { type = 'mysql' } = useParams<{ type: string }>();
|
||||
const canAdd = useActionPermission('instance.add');
|
||||
@@ -28,54 +47,75 @@ export default function InstancePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterEnv, setFilterEnv] = useState('');
|
||||
const [selectedInstance, setSelectedInstance] = useState<DBInstance | null>(null);
|
||||
const [selectedInstance, setSelectedInstance] = useState<InstanceItem | null>(null);
|
||||
const [view, setView] = useState<'grid' | 'detail'>('grid');
|
||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const config: { icon: React.ElementType; color: string; bg: string; label: string } = dbTypeConfig[type] || dbTypeConfig.mysql;
|
||||
const config = dbTypeConfig[type] || dbTypeConfig.mysql;
|
||||
|
||||
const instances = useMemo(() => {
|
||||
return dbInstances.filter(i => {
|
||||
if (i.dbType !== type) return false;
|
||||
if (filterStatus && i.status !== filterStatus) return false;
|
||||
if (filterEnv && i.env !== filterEnv) return false;
|
||||
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [type, filterStatus, filterEnv, searchQuery]);
|
||||
const fetchInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await instanceApi.list({ db_type: type });
|
||||
const data = Array.isArray(res) ? res : [];
|
||||
setInstances(data);
|
||||
} catch {
|
||||
// keep existing
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
online: instances.filter(i => i.status === 'online').length,
|
||||
warning: instances.filter(i => i.status === 'warning').length,
|
||||
offline: instances.filter(i => i.status === 'offline').length,
|
||||
}), [instances]);
|
||||
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
||||
|
||||
const filtered = instances.filter(i => {
|
||||
if (filterStatus && i.status !== filterStatus) return false;
|
||||
if (filterEnv && i.env !== filterEnv) return false;
|
||||
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const statusCounts = {
|
||||
online: filtered.filter(i => i.status === 'online').length,
|
||||
offline: filtered.filter(i => i.status === 'offline').length,
|
||||
total: filtered.length,
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该实例?')) return;
|
||||
try {
|
||||
await instanceApi.delete(id);
|
||||
fetchInstances();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (showCreate) {
|
||||
return <CreateInstanceForm defaultType={type} onCancel={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} />;
|
||||
}
|
||||
|
||||
if (view === 'detail' && selectedInstance) {
|
||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} />;
|
||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title={`${config.label} 实例管理`}
|
||||
description={`管理 ${config.label} 数据库实例,监控连接状态和性能指标`}
|
||||
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: () => {} }}
|
||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => {}, permission: 'instance.add' } : undefined}
|
||||
description={`管理 ${config.label} 数据库实例,监控连接状态`}
|
||||
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }}
|
||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center"><Activity className="w-5 h-5 text-green-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.online}</div><div className="text-xs text-[var(--dg-text-muted)]">在线实例</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-yellow-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.warning}</div><div className="text-xs text-[var(--dg-text-muted)]">告警实例</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-red-400" /></div>
|
||||
@@ -85,7 +125,7 @@ export default function InstancePage() {
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center"><Server className="w-5 h-5 text-blue-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{instances.length}</div><div className="text-xs text-[var(--dg-text-muted)]">总实例数</div></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.total}</div><div className="text-xs text-[var(--dg-text-muted)]">总实例数</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -94,95 +134,73 @@ export default function InstancePage() {
|
||||
<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 text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
||||
placeholder="搜索实例名称/地址"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<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" placeholder="搜索实例名称/地址" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<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={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="warning">告警</option>
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterEnv} onChange={e => setFilterEnv(e.target.value)}>
|
||||
<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={filterEnv} onChange={e => setFilterEnv(e.target.value)}>
|
||||
<option value="">全部环境</option>
|
||||
<option value="prod">生产</option>
|
||||
<option value="test">测试</option>
|
||||
<option value="dev">开发</option>
|
||||
<option value="production">生产</option>
|
||||
<option value="staging">预发</option>
|
||||
<option value="development">开发</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 实例卡片网格 */}
|
||||
{instances.length === 0 ? (
|
||||
{/* 实例卡片 */}
|
||||
{loading && instances.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{instances.map(instance => (
|
||||
<div
|
||||
key={instance.id}
|
||||
className="dg-card p-5 cursor-pointer dg-card-hover group"
|
||||
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}>
|
||||
<config.icon className={`w-6 h-6 ${config.color}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{instance.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{tag}</span>
|
||||
))}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${instance.status === 'online' ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : instance.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-1">{instance.name}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{config.label} {instance.version} · {instance.env === 'prod' ? '生产' : instance.env === 'test' ? '测试' : '开发'}</p>
|
||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-[var(--dg-border)]">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.connections}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">连接数</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.qps.toLocaleString()}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">QPS</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.latency}<span className="text-xs font-normal text-[var(--dg-text-muted)]">ms</span></div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">延迟</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Cpu className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-400 rounded-full transition-all" style={{ width: `${instance.cpu}%` }} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{filtered.map(instance => (
|
||||
<div key={instance.id} className="dg-card p-5 cursor-pointer dg-card-hover group" onClick={() => { setSelectedInstance(instance); setView('detail'); }}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}>
|
||||
<config.icon className={`w-6 h-6 ${config.color}`} />
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.cpu}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<MemoryStick className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-400 rounded-full transition-all" style={{ width: `${instance.memory}%` }} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{envLabel[instance.env] || instance.env}</span>
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${instance.status === 'online' ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : 'bg-red-400'}`} />
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.memory}%</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-1">{instance.name}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">{config.label} {instance.version || '-'} · {envLabel[instance.env] || instance.env}</p>
|
||||
{instance.description && <p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2">{instance.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete }: {
|
||||
instance: DBInstance; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string };
|
||||
canEdit: boolean; canDelete: boolean;
|
||||
/* ---------- InstanceDetail ---------- */
|
||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }: {
|
||||
instance: InstanceItem; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string };
|
||||
canEdit: boolean; canDelete: boolean; onDelete: () => void;
|
||||
}) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||
|
||||
const DetailIcon = config.icon;
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await instanceApi.testConnection(instance.id);
|
||||
setTestResult(res as any);
|
||||
} catch (e: any) {
|
||||
setTestResult({ success: false, message: e?.response?.data?.detail || '连接失败' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -196,94 +214,171 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete }: {
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
{instance.name}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${instance.status === 'online' ? 'bg-green-500/15 text-green-400' : instance.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{instance.status === 'online' ? '在线' : instance.status === 'warning' ? '告警' : '离线'}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${instance.status === 'online' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{instance.status === 'online' ? '在线' : '离线'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{instance.host}:{instance.port} · 版本 {instance.version}</p>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{instance.host}:{instance.port} · {instance.version || '版本未知'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleTest} disabled={testing} 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 disabled:opacity-50">
|
||||
<Zap className="w-4 h-4" /> {testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button 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 text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Edit2 className="w-4 h-4" /> 编辑
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button 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 text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all">
|
||||
<button onClick={onDelete} 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 text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all">
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 性能指标 */}
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[
|
||||
{ label: '连接数', value: instance.connections, icon: Activity },
|
||||
{ label: 'QPS', value: instance.qps.toLocaleString(), icon: Zap },
|
||||
{ label: '延迟', value: `${instance.latency}ms`, icon: Clock },
|
||||
{ label: 'CPU', value: `${instance.cpu}%`, icon: Cpu },
|
||||
{ label: '内存', value: `${instance.memory}%`, icon: MemoryStick },
|
||||
{ label: '磁盘', value: `${instance.disk}%`, icon: HardDrive },
|
||||
].map((m, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 text-center">
|
||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
||||
<div className="text-xl font-bold">{m.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{/* 连接测试结果 */}
|
||||
{testResult && (
|
||||
<Card className={`dg-card ${testResult.success ? 'border-green-500/30' : 'border-red-500/30'}`}>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${testResult.success ? 'bg-green-500/15' : 'bg-red-500/15'}`}>
|
||||
{testResult.success ? <Activity className="w-4 h-4 text-green-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>{testResult.message}</div>
|
||||
{testResult.version && <div className="text-xs text-[var(--dg-text-muted)]">版本: {testResult.version}</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 连接信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '实例名称', value: instance.name },
|
||||
{ label: '主机地址', value: instance.host },
|
||||
{ label: '端口', value: instance.port.toString() },
|
||||
{ label: '用户名', value: instance.username },
|
||||
{ label: '默认数据库', value: instance.database || '-' },
|
||||
{ label: '运行环境', value: envLabel[instance.env] || instance.env },
|
||||
{ label: '状态', value: instance.status === 'online' ? '在线' : '离线' },
|
||||
{ label: '版本', value: instance.version || '-' },
|
||||
{ label: '描述', value: instance.description || '-' },
|
||||
{ label: '创建时间', value: new Date(instance.created_at).toLocaleString() },
|
||||
].map(item => (
|
||||
<div key={item.label} 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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- CreateInstanceForm ---------- */
|
||||
function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [dbType, setDbType] = useState(defaultType);
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('3306');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [database, setDatabase] = useState('');
|
||||
const [env, setEnv] = useState('development');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; }
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await instanceApi.create({
|
||||
name, db_type: dbType, host, port: Number(port),
|
||||
username, password, database: database || undefined,
|
||||
env, description: description || undefined,
|
||||
});
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">添加实例</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-6 h-6" /></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 连接信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '主机地址', value: instance.host },
|
||||
{ label: '端口', value: instance.port.toString() },
|
||||
{ label: '数据库版本', value: instance.version },
|
||||
{ label: '运行环境', value: instance.env === 'prod' ? '生产环境' : instance.env === 'test' ? '测试环境' : '开发环境' },
|
||||
{ label: '运行时间', value: instance.uptime },
|
||||
{ label: '标签', value: instance.tags.join(', ') },
|
||||
...(instance.dbType === 'mysql' ? [
|
||||
{ label: 'Binlog 格式', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ROW</span> },
|
||||
{ label: 'Binlog 状态', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ON</span> },
|
||||
{ label: '回滚支持', value: <span className="flex items-center gap-1"><Undo2 className="w-3 h-3 text-green-400" /> <span className="text-green-400 text-xs">支持</span></span> },
|
||||
] : []),
|
||||
].map(item => (
|
||||
<div key={item.label} 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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">实例名称 <span className="text-red-400">*</span></label>
|
||||
<input value={name} onChange={e => setName(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" placeholder="例如:生产 MySQL 主库" />
|
||||
</div>
|
||||
|
||||
{/* 资源使用趋势 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">资源使用</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-5">
|
||||
{[
|
||||
{ label: 'CPU 使用率', value: instance.cpu, color: 'bg-blue-400' },
|
||||
{ label: '内存使用率', value: instance.memory, color: 'bg-purple-400' },
|
||||
{ label: '磁盘使用率', value: instance.disk, color: 'bg-orange-400' },
|
||||
].map(item => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="font-medium">{item.value}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className={`h-full ${item.color} rounded-full transition-all`} style={{ width: `${item.value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label>
|
||||
<select value={dbType} onChange={e => setDbType(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">
|
||||
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">环境 <span className="text-red-400">*</span></label>
|
||||
<select value={env} onChange={e => setEnv(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">
|
||||
<option value="development">开发</option><option value="staging">预发</option><option value="production">生产</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">主机地址 <span className="text-red-400">*</span></label>
|
||||
<input value={host} onChange={e => setHost(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" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">端口 <span className="text-red-400">*</span></label>
|
||||
<input value={port} onChange={e => setPort(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" placeholder="3306" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">默认数据库</label>
|
||||
<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" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">用户名 <span className="text-red-400">*</span></label>
|
||||
<input value={username} onChange={e => setUsername(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" placeholder="root" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">密码 <span className="text-red-400">*</span></label>
|
||||
<input type="password" value={password} onChange={e => setPassword(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" placeholder="数据库密码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(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 min-h-[80px] resize-y focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<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>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="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">
|
||||
{submitting ? '创建中...' : '添加实例'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+174
-246
@@ -1,148 +1,83 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Search, Download, Plus, Clock, Eye, Copy, X, Database, FileText,
|
||||
AlertTriangle, CheckCircle, XCircle, ChevronRight, Play
|
||||
Plus, Clock, X,
|
||||
AlertTriangle, CheckCircle, Play, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { queryAudits, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { QueryAudit } from '@/types';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Record<string, any>[];
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
}
|
||||
|
||||
interface QueryHistoryItem {
|
||||
id: number;
|
||||
sql: string;
|
||||
instance_id: number;
|
||||
database: string;
|
||||
status: string;
|
||||
execution_time_ms: number;
|
||||
result_preview: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function QueriesPage() {
|
||||
const canCreate = useActionPermission('query.create');
|
||||
const canExport = useActionPermission('query.export');
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryAudit | null>(null);
|
||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return queryAudits.filter(q => {
|
||||
if (dbTypeFilter && q.dbType !== dbTypeFilter) return false;
|
||||
if (statusFilter && q.status !== statusFilter) return false;
|
||||
if (search && !q.sql.includes(search) && !q.user.includes(search) && !q.instance.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, dbTypeFilter, statusFilter]);
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
total: queryAudits.length,
|
||||
success: queryAudits.filter(q => q.status === 'success').length,
|
||||
slow: queryAudits.filter(q => q.status === 'slow').length,
|
||||
failed: queryAudits.filter(q => q.status === 'failed').length,
|
||||
}), []);
|
||||
// 查询历史暂时用本地状态(后端没有查询历史列表 API)
|
||||
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
|
||||
|
||||
if (showNewQuery) {
|
||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} />;
|
||||
}
|
||||
|
||||
if (selectedQuery) {
|
||||
return <QueryDetail query={selectedQuery} onBack={() => setSelectedQuery(null)} />;
|
||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} onExecuted={(item) => { setHistory(prev => [item, ...prev]); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="查询审计"
|
||||
description="记录所有 SQL 查询操作,支持敏感数据脱敏和性能分析"
|
||||
secondaryAction={canExport ? { label: '导出日志', icon: Download, onClick: () => {} } : undefined}
|
||||
description="在线 SQL 查询工具,支持 SELECT 查询和结果预览"
|
||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总查询数', value: statusCounts.total, icon: FileText, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '成功', value: statusCounts.success, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '慢查询', value: statusCounts.slow, icon: Clock, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
{ label: '失败', value: statusCounts.failed, icon: XCircle, color: 'text-red-400', bg: 'bg-red-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`}><s.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 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="搜索 SQL 关键词/用户/实例" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</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={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
||||
<option value="">全部数据库</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</option>
|
||||
<option value="mongo">MongoDB</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="success">成功</option>
|
||||
<option value="slow">慢查询</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 查询表格 */}
|
||||
{/* 查询历史 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{filtered.length === 0 ? (
|
||||
{history.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<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">查询ID</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">SQL 语句</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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(q => (
|
||||
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer" onClick={() => setSelectedQuery(q)}>
|
||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{q.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs">
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sqlPreview}</code>
|
||||
{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 max-w-md">
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 100)}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[q.dbType] || ''}`}>{q.dbType.toUpperCase()}</span>
|
||||
<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">{q.user}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={q.executeTime > 1000 ? 'text-red-400 font-medium' : ''}>{q.executeTime}ms</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{q.rowCount}</td>
|
||||
<td className="px-5 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' : q.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${q.status === 'slow' ? 'animate-pulse' : ''}`} />
|
||||
{q.status === 'success' ? '成功' : q.status === 'slow' ? '慢查询' : '失败'}
|
||||
<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">
|
||||
{q.hasSensitive ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400">
|
||||
<AlertTriangle className="w-3 h-3" /> 已脱敏
|
||||
</span>
|
||||
) : <span className="text-[var(--dg-text-muted)] text-xs">-</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{new Date(q.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -155,173 +90,109 @@ export default function QueriesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function QueryDetail({ query, onBack }: { query: QueryAudit; onBack: () => void }) {
|
||||
/* ---------- NewQueryPanel ---------- */
|
||||
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
|
||||
const [sql, setSql] = useState('');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
const [database, setDatabase] = useState('');
|
||||
const [instances, setInstances] = useState<any[]>([]);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
const data = Array.isArray(res) ? res : [];
|
||||
setInstances(data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入 SQL'); return; }
|
||||
setExecuting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await queryApi.execute({
|
||||
instance_id: Number(instanceId),
|
||||
database: database || undefined,
|
||||
sql,
|
||||
}) as any;
|
||||
|
||||
const execTime = res.execution_time_ms || 0;
|
||||
setResult(res);
|
||||
onExecuted({
|
||||
id: Date.now(),
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
status: 'success',
|
||||
execution_time_ms: execTime,
|
||||
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || '查询失败';
|
||||
setError(msg);
|
||||
onExecuted({
|
||||
id: Date.now(),
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
status: 'failed',
|
||||
execution_time_ms: 0,
|
||||
result_preview: msg,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const maskData = (value: string) => {
|
||||
if (!value) return '***';
|
||||
if (value.includes('@')) {
|
||||
if (typeof value === 'string' && value.includes('@')) {
|
||||
const [name, domain] = value.split('@');
|
||||
return name.substring(0, 3) + '***@' + domain;
|
||||
}
|
||||
if (value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
||||
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
||||
return '***';
|
||||
};
|
||||
|
||||
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
查询详情
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-medium ${query.status === 'success' ? 'bg-green-500/15 text-green-400' : query.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{query.status === 'success' ? '成功' : query.status === 'slow' ? '慢查询' : '失败'}
|
||||
</span>
|
||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 包含敏感数据</span>}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{query.id} · {query.user} · {query.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button 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 hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Copy className="w-4 h-4" /> 复制 SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 执行指标 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '执行时长', value: `${query.executeTime}ms`, isSlow: query.executeTime > 1000, icon: Clock },
|
||||
{ label: '返回行数', value: query.rowCount.toString(), icon: FileText },
|
||||
{ label: '扫描行数', value: query.scanRows.toLocaleString(), icon: Database },
|
||||
{ label: '影响行数', value: query.affectedRows.toString(), icon: CheckCircle },
|
||||
].map((m, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 text-center">
|
||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
||||
<div className={`text-xl font-bold ${m.isSlow ? 'text-red-400' : ''}`}>{m.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* SQL 详情 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">SQL 语句</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{query.sql}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 连接信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '数据库类型', value: query.dbType.toUpperCase() },
|
||||
{ label: '目标实例', value: query.instance },
|
||||
{ label: '执行用户', value: query.user },
|
||||
{ label: '客户端 IP', value: query.clientIp },
|
||||
{ label: '来源', value: query.source },
|
||||
{ label: '执行时间', value: query.timestamp },
|
||||
].map((item, idx) => (
|
||||
<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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 查询结果 */}
|
||||
{query.resultSet && query.resultColumns && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold">查询结果</CardTitle>
|
||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 敏感字段已脱敏</span>}
|
||||
</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)]">
|
||||
{query.resultColumns.map(col => (
|
||||
<th key={col.prop} className="text-left px-4 py-3 font-semibold text-xs">
|
||||
{col.label}
|
||||
{col.sensitive && <span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.resultSet.map((row, i) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
{query.resultColumns!.map(col => (
|
||||
<td key={col.prop} className="px-4 py-3 text-xs">
|
||||
{col.sensitive ? (
|
||||
<span className="text-yellow-400 italic">{maskData(String(row[col.prop]))}</span>
|
||||
) : (
|
||||
String(row[col.prop])
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewQueryPanel({ onCancel }: { onCancel: () => void }) {
|
||||
const [sql, setSql] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||
<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 className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库类型</label>
|
||||
<select 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">
|
||||
<option>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标实例</label>
|
||||
<select 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">
|
||||
<option>生产 MySQL 主库</option><option>测试库</option><option>开发库</option>
|
||||
<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">
|
||||
<option value="">请选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host}:{inst.port})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标数据库</label>
|
||||
<select 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">
|
||||
<option>user_db</option><option>order_db</option><option>log_db</option>
|
||||
</select>
|
||||
<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" placeholder="可选,默认数据库" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句</label>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<textarea
|
||||
className="w-full min-h-[250px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder="-- 请输入 SQL 查询语句\n-- 查询结果将自动脱敏敏感字段\n\nSELECT id, name, email, phone, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"
|
||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder={"-- 仅支持 SELECT 查询\n-- DDL/DML 操作请通过工单系统提交\n\nSELECT id, name, email, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"}
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
onKeyDown={e => { if (e.ctrlKey && e.key === 'Enter') handleExecute(); }}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span>{sql.length} 字符</span>
|
||||
@@ -332,13 +203,70 @@ function NewQueryPanel({ onCancel }: { onCancel: () => void }) {
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(邮箱、手机号、身份证号等)将自动脱敏显示。完整数据需要额外权限。</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(密码、手机号等)将自动脱敏显示。仅支持 SELECT 查询,DML/DDL 请走工单审批。</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 查询结果 */}
|
||||
{result && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{result.columns && result.columns.length > 0 ? (
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0">
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-[var(--dg-text-muted)]">无结果数据</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)] transition-colors">取消</button>
|
||||
<button 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 transition-opacity">
|
||||
<Play className="w-4 h-4" /> 执行查询
|
||||
<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>
|
||||
<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">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} {executing ? '执行中...' : '执行查询'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+275
-220
@@ -1,18 +1,58 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, Copy,
|
||||
ChevronRight, Shield, FileCode, History, CircleDot, AlertTriangle,
|
||||
Database, Key, Lock, Undo2, Info,
|
||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
|
||||
ChevronRight, FileCode, History,
|
||||
Undo2, XCircle as XIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { tickets, statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useAuth, useActionPermission } from '@/hooks/useAuth';
|
||||
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { Ticket } from '@/types';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
interface TicketItem {
|
||||
id: number;
|
||||
title: string;
|
||||
db_type: string;
|
||||
instance_id: number;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
rollback_sql: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string | null;
|
||||
reason: string | null;
|
||||
audit_result: string | null;
|
||||
execute_result: string | null;
|
||||
create_time: string;
|
||||
review_time: string | null;
|
||||
execute_time: string | null;
|
||||
}
|
||||
|
||||
interface TicketListResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TicketItem[];
|
||||
}
|
||||
|
||||
interface TicketStatistics {
|
||||
total: number;
|
||||
pending: number;
|
||||
reviewing: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
executing: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export default function TicketsPage() {
|
||||
const { isDeveloper } = useAuth();
|
||||
const canCreate = useActionPermission('ticket.create');
|
||||
const canApprove = useActionPermission('ticket.approve');
|
||||
const canExecute = useActionPermission('ticket.execute');
|
||||
@@ -21,35 +61,71 @@ export default function TicketsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
|
||||
const [showNewTicket, setShowNewTicket] = useState(false);
|
||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<TicketStatistics | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return tickets.filter(t => {
|
||||
if (statusFilter && t.status !== statusFilter) return false;
|
||||
if (dbTypeFilter && t.dbType !== dbTypeFilter) return false;
|
||||
if (priorityFilter && t.priority !== priorityFilter) return false;
|
||||
if (search && !t.title.includes(search) && !t.id.includes(search) && !t.applicant.includes(search)) return false;
|
||||
// developer 只能看到自己的工单
|
||||
if (isDeveloper && t.applicant !== '王五' && t.applicant !== 'developer') return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, statusFilter, dbTypeFilter, priorityFilter, isDeveloper]);
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ticketApi.list({
|
||||
page, page_size: pageSize,
|
||||
status: statusFilter || undefined,
|
||||
db_type: dbTypeFilter || undefined,
|
||||
keyword: search || undefined,
|
||||
}) as unknown as TicketListResponse;
|
||||
setTickets(res.items);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
// keep existing data on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, statusFilter, dbTypeFilter, search]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
||||
return counts;
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await ticketApi.statistics() as unknown as TicketStatistics;
|
||||
setStats(res);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTickets(); }, [fetchTickets]);
|
||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
if (showNewTicket) {
|
||||
return <NewTicketForm onCancel={() => setShowNewTicket(false)} />;
|
||||
return <NewTicketForm
|
||||
onCancel={() => setShowNewTicket(false)}
|
||||
onCreated={() => { setShowNewTicket(false); fetchTickets(); fetchStats(); }}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (selectedTicket) {
|
||||
return <TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} canApprove={canApprove} canExecute={canExecute} />;
|
||||
return <TicketDetail
|
||||
ticket={selectedTicket}
|
||||
onBack={() => setSelectedTicket(null)}
|
||||
canApprove={canApprove}
|
||||
canExecute={canExecute}
|
||||
onActionDone={() => { setSelectedTicket(null); fetchTickets(); fetchStats(); }}
|
||||
/>;
|
||||
}
|
||||
|
||||
const statusCards = [
|
||||
{ key: 'pending', label: '待审批', count: stats?.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
|
||||
{ key: 'reviewing', label: '审批中', count: stats?.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
|
||||
{ key: 'approved', label: '已通过', count: stats?.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'executing', label: '执行中', count: stats?.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
|
||||
{ key: 'success', label: '成功', count: stats?.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'failed', label: '失败', count: stats?.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
@@ -62,17 +138,10 @@ export default function TicketsPage() {
|
||||
|
||||
{/* 状态快捷筛选 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[
|
||||
{ key: 'pending', label: '待审批', count: statusCounts.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
|
||||
{ key: 'reviewing', label: '审批中', count: statusCounts.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
|
||||
{ key: 'approved', label: '已通过', count: statusCounts.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'executing', label: '执行中', count: statusCounts.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
|
||||
{ key: 'success', label: '成功', count: statusCounts.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'failed', label: '失败', count: statusCounts.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
|
||||
].map(s => (
|
||||
{statusCards.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setStatusFilter(statusFilter === s.key ? '' : s.key)}
|
||||
onClick={() => { setStatusFilter(statusFilter === s.key ? '' : s.key); setPage(1); }}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${statusFilter === s.key ? s.color : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{s.label}
|
||||
@@ -85,19 +154,19 @@ export default function TicketsPage() {
|
||||
<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)} />
|
||||
<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="搜索工单标题/SQL内容" value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
|
||||
</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)}>
|
||||
<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); setPage(1); }}>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</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={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
||||
<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={dbTypeFilter} onChange={e => { setDbTypeFilter(e.target.value); setPage(1); }}>
|
||||
<option value="">全部数据库</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</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={priorityFilter} onChange={e => setPriorityFilter(e.target.value)}>
|
||||
<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={priorityFilter} onChange={e => { setPriorityFilter(e.target.value); setPage(1); }}>
|
||||
<option value="">全部优先级</option>
|
||||
<option value="high">高</option>
|
||||
<option value="medium">中</option>
|
||||
@@ -108,7 +177,9 @@ export default function TicketsPage() {
|
||||
{/* 工单表格 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{filtered.length === 0 ? (
|
||||
{loading && tickets.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<>
|
||||
@@ -119,7 +190,6 @@ export default function TicketsPage() {
|
||||
<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-5 py-3 font-semibold">优先级</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
@@ -128,13 +198,12 @@ export default function TicketsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(ticket => (
|
||||
{tickets.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 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{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 px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span></td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.instance}</td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||
<td className="px-5 py-3.5"><span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>{ticket.db_type.toUpperCase()}</span></td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant_name || '-'}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>
|
||||
{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}
|
||||
@@ -143,15 +212,18 @@ export default function TicketsPage() {
|
||||
<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' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
|
||||
{statusMap[ticket.status]}
|
||||
{statusMap[ticket.status] || 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 text-[var(--dg-text-secondary)]">{new Date(ticket.create_time).toLocaleString()}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setSelectedTicket(ticket)} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
{canApprove && ticket.status === 'pending' && <button className="text-green-400 hover:text-green-300 transition-colors"><CheckCircle className="w-4 h-4" /></button>}
|
||||
{canApprove && ticket.status === 'pending' && <button className="text-red-400 hover:text-red-300 transition-colors"><XCircle className="w-4 h-4" /></button>}
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
const detail = await ticketApi.get(ticket.id) as unknown as TicketItem;
|
||||
setSelectedTicket(detail);
|
||||
} catch {}
|
||||
}} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -160,11 +232,14 @@ export default function TicketsPage() {
|
||||
</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)]">共 {total} 条记录</span>
|
||||
<div className="flex gap-1">
|
||||
{['上一页', '1', '2', '3', '下一页'].map((p, i) => (
|
||||
<button key={i} className={`px-3 py-1.5 rounded-md text-sm ${i === 1 ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>{p}</button>
|
||||
))}
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50">上一页</button>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const p = i + 1;
|
||||
return <button key={p} onClick={() => setPage(p)} className={`px-3 py-1.5 rounded-md text-sm ${p === page ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>{p}</button>;
|
||||
})}
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} className="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -175,107 +250,161 @@ export default function TicketsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewTicketForm({ onCancel }: { onCancel: () => void }) {
|
||||
/* ---------- NewTicketForm ---------- */
|
||||
function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreated: () => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
const [targetDatabase, setTargetDatabase] = useState('');
|
||||
const [sqlContent, setSqlContent] = useState('');
|
||||
const [rollbackSql, setRollbackSql] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
|
||||
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
|
||||
const [sql, setSql] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [instances, setInstances] = useState<any[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
const data = (res as any)?.items || res;
|
||||
setInstances(Array.isArray(data) ? data : []);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title || !sqlContent || !reason || !instanceId || !targetDatabase) {
|
||||
setError('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await ticketApi.create({
|
||||
title,
|
||||
db_type: dbType,
|
||||
instance_id: Number(instanceId),
|
||||
target_database: targetDatabase,
|
||||
sql_content: sqlContent,
|
||||
rollback_sql: rollbackSql || undefined,
|
||||
reason,
|
||||
priority,
|
||||
});
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-6 h-6" /></button>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XIcon className="w-6 h-6" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
|
||||
<input 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 focus:ring-1 focus:ring-blue-400/20" placeholder="请输入工单标题,如:更新用户状态字段" />
|
||||
<input value={title} onChange={e => setTitle(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 focus:ring-1 focus:ring-blue-400/20" placeholder="请输入工单标题" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label>
|
||||
<select 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">
|
||||
<option>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
||||
<select value={dbType} onChange={e => setDbType(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">
|
||||
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
|
||||
<select 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">
|
||||
<option>生产 MySQL 主库</option><option>测试 MySQL 从库</option>
|
||||
<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">
|
||||
<option value="">请选择实例</option>
|
||||
{instances.map((inst: any) => <option key={inst.id} value={inst.id}>{inst.name} ({inst.host}:{inst.port})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标数据库 <span className="text-red-400">*</span></label>
|
||||
<input value={targetDatabase} onChange={e => setTargetDatabase(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" placeholder="数据库名" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
<div className="flex gap-3">
|
||||
{(['high', 'medium', 'low'] as const).map(p => (
|
||||
<button key={p} onClick={() => setPriority(p)} className={`px-4 py-2 rounded-lg text-sm border transition-colors ${priority === p ? (p === 'high' ? 'bg-red-500/15 text-red-400 border-red-500/30' : p === 'medium' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : 'bg-blue-500/15 text-blue-400 border-blue-500/30') : 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)]'}`}>
|
||||
{p === 'high' ? '高' : p === 'medium' ? '中' : '低'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<button onClick={() => setSqlTab('change')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
||||
<button onClick={() => setSqlTab('change')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)]'}`}>
|
||||
<FileCode className="w-3.5 h-3.5" /> 变更 SQL
|
||||
</button>
|
||||
<button onClick={() => setSqlTab('rollback')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
||||
<button onClick={() => setSqlTab('rollback')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)]'}`}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> 回滚 SQL
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder={sqlTab === 'change' ? "-- 请输入 SQL 语句,支持多条 SQL,使用分号分隔\n\nUPDATE users SET status = 'active' WHERE id = 1;\nINSERT INTO logs (action, user_id) VALUES ('update', 1);" : "-- 请输入回滚 SQL\n\nUPDATE users SET status = (SELECT status FROM users_backup WHERE id = users.id) WHERE id = 1;"}
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
placeholder={sqlTab === 'change' ? "UPDATE users SET status = 'active' WHERE id = 1;" : "-- 请输入回滚 SQL"}
|
||||
value={sqlTab === 'change' ? sqlContent : rollbackSql}
|
||||
onChange={e => sqlTab === 'change' ? setSqlContent(e.target.value) : setRollbackSql(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5" />已输入 {sql.length} 个字符</span>
|
||||
<span>Ctrl + Enter 快速提交</span>
|
||||
<span>已输入 {(sqlTab === 'change' ? sqlContent : rollbackSql).length} 个字符</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
||||
<textarea className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 placeholder:text-[var(--dg-text-muted)]" placeholder="请描述本次数据变更的原因和背景,便于审批人理解" />
|
||||
<textarea value={reason} onChange={e => setReason(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 min-h-[80px] resize-y focus:border-blue-400 focus:outline-none" placeholder="请描述本次数据变更的原因" />
|
||||
</div>
|
||||
|
||||
{/* SQL 审核预览 */}
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2"><Shield className="w-4 h-4 text-blue-400" /><span className="text-sm font-semibold">SQL 审核预览</span></div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/15 rounded-full">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" /><span className="text-sm font-semibold text-green-400">92 分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
{[
|
||||
{ icon: XCircle, color: 'text-red-400 bg-red-400/10', title: '禁止无 WHERE 条件的 UPDATE/DELETE', desc: '检测到 UPDATE 语句缺少 WHERE 条件', suggestion: '建议: 添加 WHERE 条件限制影响范围' },
|
||||
{ icon: FileCode, color: 'text-yellow-400 bg-yellow-400/10', title: '大表变更警告', desc: '目标表 users 数据量超过 100 万行', suggestion: '建议: 考虑分批执行或使用 pt-online-schema-change' },
|
||||
{ icon: CheckCircle, color: 'text-green-400 bg-green-400/10', title: 'SQL 语法检查通过', desc: 'SQL 语法正确,可以正常解析' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-4">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${item.color}`}>
|
||||
<item.icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{item.desc}</div>
|
||||
{item.suggestion && <div className="text-xs text-blue-400 mt-1">{item.suggestion}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<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 className="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 transition-opacity">提交审核</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="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-opacity">
|
||||
{submitting ? '提交中...' : '提交审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
|
||||
ticket: Ticket; onBack: () => void; canApprove: boolean; canExecute: boolean;
|
||||
/* ---------- TicketDetail ---------- */
|
||||
function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }: {
|
||||
ticket: TicketItem; onBack: () => void; canApprove: boolean; canExecute: boolean; onActionDone: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleReview = async (action: 'approve' | 'reject') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
|
||||
onActionDone();
|
||||
} catch {} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.execute(ticket.id);
|
||||
onActionDone();
|
||||
} catch {} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -286,67 +415,44 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
{ticket.title}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>{statusMap[ticket.status]}</span>
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>{statusMap[ticket.status] || ticket.status}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{ticket.id} · 由 {ticket.applicant} 创建于 {ticket.createTime}</p>
|
||||
<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">
|
||||
{canApprove && ticket.status === 'pending' && (
|
||||
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button 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">
|
||||
<button onClick={() => handleReview('approve')} disabled={loading} 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 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">
|
||||
<button onClick={() => handleReview('reject')} disabled={loading} 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>
|
||||
</>
|
||||
)}
|
||||
{canExecute && ticket.status === 'approved' && (
|
||||
<button 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">
|
||||
<button onClick={handleExecute} disabled={loading} 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批流程 */}
|
||||
{ticket.workflowSteps && (
|
||||
<div className="dg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{ticket.workflowSteps.map((step, i) => (
|
||||
<div key={i} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium ${step.status === 'completed' ? 'bg-green-500' : step.status === 'active' ? 'bg-blue-500 ring-4 ring-blue-500/20' : 'bg-[var(--dg-bg-tertiary)] border-2 border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}>
|
||||
{step.status === 'completed' ? <CheckCircle className="w-5 h-5" /> : step.status === 'active' ? <CircleDot className="w-5 h-5" /> : <span className="text-xs">{i + 1}</span>}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">{step.time || '等待中'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < ticket.workflowSteps!.length - 1 && (
|
||||
<div className={`flex-1 h-[2px] mx-3 mb-6 ${step.status === 'completed' ? 'bg-green-500' : 'bg-[var(--dg-border)]'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 工单信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">工单信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '工单号', value: ticket.id },
|
||||
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span> },
|
||||
{ label: '目标实例', value: ticket.instance },
|
||||
{ label: '申请人', value: ticket.applicant },
|
||||
{ label: '工单号', value: `TK-${ticket.id}` },
|
||||
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>{ticket.db_type.toUpperCase()}</span> },
|
||||
{ label: '目标数据库', value: ticket.target_database },
|
||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||
{ label: '审批人', value: ticket.approver_name || '-' },
|
||||
{ label: '优先级', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}</span> },
|
||||
{ label: '创建时间', value: ticket.createTime },
|
||||
{ label: '变更原因', value: ticket.reason },
|
||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||
{ label: '变更原因', value: ticket.reason || '-' },
|
||||
].map((item, idx) => (
|
||||
<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>
|
||||
@@ -360,104 +466,53 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><FileCode className="w-4 h-4 text-blue-400" /> SQL 内容</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.sql}</pre>
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.sql_content}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 回滚 SQL */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
{ticket.rollback_sql && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Undo2 className="w-4 h-4 text-purple-400" /> 回滚 SQL</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Database className="w-3 h-3" /> Binlog ROW</span>
|
||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Key className="w-3 h-3" /> 有主键</span>
|
||||
<span className="px-2 py-0.5 bg-blue-500/15 text-blue-400 text-xs rounded">支持回滚</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{ticket.rollbackSql ? (
|
||||
<div>
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.rollbackSql}</pre>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Info className="w-3 h-3" /> 基于 Binlog ROW 格式解析生成</span>
|
||||
<span className="flex items-center gap-1"><Lock className="w-3 h-3" /> 备份库: backup_mysql_01</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/15 text-purple-400 text-xs rounded hover:bg-purple-500/25 transition-colors">
|
||||
<Copy className="w-3 h-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] mb-1">暂无回滚 SQL</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">工单执行后将自动生成基于 Binlog 的回滚语句</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.rollback_sql}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 回滚限制说明 */}
|
||||
<div className="dg-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
<h3 className="text-sm font-semibold">回滚功能限制说明</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ icon: Database, title: 'Binlog 配置', status: '已满足', desc: 'MySQL 必须开启 Binlog 且格式为 ROW', color: 'text-green-400 bg-green-400/10' },
|
||||
{ icon: Key, title: '主键要求', status: '已满足', desc: '操作的表必须包含主键,用于精确定位行', color: 'text-green-400 bg-green-400/10' },
|
||||
{ icon: Lock, title: '权限要求', status: '已满足', desc: '账号需有 REPLICATION SLAVE/CLIENT 权限', color: 'text-green-400 bg-green-400/10' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className={`w-8 h-8 rounded-lg ${item.color} flex items-center justify-center flex-shrink-0`}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium flex items-center gap-2">{item.title}<span className={`text-[11px] ${item.color.split(' ')[0]}`}>{item.status}</span></div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-medium text-yellow-400">重要提示:</span>
|
||||
DDL操作(DROP/TRUNCATE)的回滚支持有限,仅能重建表结构无法恢复数据;
|
||||
DML和DDL不可混合执行;WHERE条件可能导致非唯一标识冲突,恢复时务必仔细核对。
|
||||
参考 Yearning 2.0 实现:AST语法解析 + Binlog逆向操作。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批记录 */}
|
||||
{ticket.approvalRecords && (
|
||||
{/* 审批信息 */}
|
||||
{ticket.audit_result && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><History className="w-4 h-4 text-blue-400" /> 审批记录</CardTitle></CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="relative pl-6 space-y-6">
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||
{ticket.approvalRecords.map((record, i) => (
|
||||
<div key={i} className="relative">
|
||||
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${record.action === 'approve' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{record.action === 'approve' ? <CheckCircle className="w-3 h-3 text-white" /> : <XCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<div className="text-sm font-medium">{record.approver} {record.action === 'approve' ? '通过审批' : '拒绝审批'}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{record.time}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{record.comment}</div>
|
||||
<div className="relative">
|
||||
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${ticket.status === 'rejected' ? 'bg-red-500' : 'bg-green-500'}`}>
|
||||
{ticket.status === 'rejected' ? <XCircle className="w-3 h-3 text-white" /> : <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm font-medium">{ticket.approver_name || '审批人'} {ticket.status === 'rejected' ? '拒绝' : '通过'}</div>
|
||||
{ticket.review_time && <div className="text-xs text-[var(--dg-text-muted)]">{new Date(ticket.review_time).toLocaleString()}</div>}
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{ticket.audit_result}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 执行结果 */}
|
||||
{ticket.execute_result && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Play className="w-4 h-4 text-purple-400" /> 执行结果</CardTitle></CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<pre className="text-xs font-mono text-[var(--dg-text-primary)] 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>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, Search, Edit2, Lock, Trash2, } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { users, roles } from '@/lib/mock';
|
||||
// type User is used implicitly
|
||||
import { roles } from '@/lib/mock';
|
||||
import { userApi } from '@/api/user';
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
guest: '访客',
|
||||
};
|
||||
|
||||
const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-purple-500/15 text-purple-400',
|
||||
@@ -15,15 +32,47 @@ export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter && u.status !== statusFilter) return false;
|
||||
if (search && !u.username.includes(search) && !u.email.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, roleFilter, statusFilter]);
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.list();
|
||||
const data = (res as any)?.items || res;
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
// keep existing data
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const handleToggleActive = async (user: UserItem) => {
|
||||
try {
|
||||
await userApi.update(user.id, { is_active: !user.is_active });
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该用户?')) return;
|
||||
try {
|
||||
await userApi.delete(id);
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -32,7 +81,7 @@ export default function UsersPage() {
|
||||
<h1 className="text-2xl font-semibold">用户管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</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">
|
||||
<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>
|
||||
@@ -57,63 +106,138 @@ export default function UsersPage() {
|
||||
{/* 用户表格 */}
|
||||
<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-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-5 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.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.email}</div>
|
||||
</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 as any)[user.role] || ''}`}>
|
||||
{user.roleName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.department}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.lastLogin}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.status === '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-transform ${user.status === 'active' ? 'translate-x-5.5' : 'translate-x-0.5'}`} style={{ transform: user.status === '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-purple-400 hover:text-purple-300 transition-colors"><Lock className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="p-10 text-center 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>
|
||||
</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)}
|
||||
</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>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新建用户弹窗 */}
|
||||
{showCreate && (
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => 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 handleSubmit = async () => {
|
||||
if (!username || !password) { setError('用户名和密码必填'); return; }
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '创建失败');
|
||||
} 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="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>
|
||||
</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" />
|
||||
</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="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" />
|
||||
</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" />
|
||||
</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>
|
||||
{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">
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,14 +159,14 @@ export interface PermissionNode {
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
todayTickets: number;
|
||||
todayTicketsTrend: number;
|
||||
pendingApproval: number;
|
||||
pendingTrend: number;
|
||||
queryAuditCount: number;
|
||||
queryTrend: number;
|
||||
slowQueries: number;
|
||||
slowQueryTrend: number;
|
||||
ticket_total: number;
|
||||
ticket_pending: number;
|
||||
ticket_month: number;
|
||||
ticket_executing: number;
|
||||
instance_total: number;
|
||||
instance_online: number;
|
||||
user_total: number;
|
||||
queries_today: number;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
|
||||
Reference in new issue
Block a user