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
+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>
|
||||
|
||||
Reference in new issue
Block a user