sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
1 parent
6fcd9465cf
commit
4c9427e546
105 files changed
+23680
-471
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Clock, X,
|
||||
AlertTriangle, CheckCircle, Play, Loader2,
|
||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||
ChevronDown, Clock, FileText, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
@@ -9,6 +9,9 @@ import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
@@ -22,32 +25,46 @@ interface QueryHistoryItem {
|
||||
sql: string;
|
||||
instance_id: number;
|
||||
database: string;
|
||||
db_type: string;
|
||||
status: string;
|
||||
execution_time_ms: number;
|
||||
result_preview: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
export default function QueriesPage() {
|
||||
const canCreate = useActionPermission('query.create');
|
||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
||||
|
||||
// 查询历史暂时用本地状态(后端没有查询历史列表 API)
|
||||
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
|
||||
|
||||
if (showNewQuery) {
|
||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} onExecuted={(item) => { setHistory(prev => [item, ...prev]); }} />;
|
||||
return (
|
||||
<NewQueryPanel
|
||||
onCancel={() => setShowNewQuery(false)}
|
||||
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="查询审计"
|
||||
description="在线 SQL 查询工具,支持 SELECT 查询和结果预览"
|
||||
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
|
||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 查询历史 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{history.length === 0 ? (
|
||||
@@ -57,7 +74,8 @@ export default function QueriesPage() {
|
||||
<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">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>
|
||||
@@ -66,8 +84,13 @@ export default function QueriesPage() {
|
||||
<tbody>
|
||||
{history.map((q, i) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
{q.db_type && dbTypeIcon[q.db_type] && (
|
||||
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 max-w-md">
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 100)}</code>
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
|
||||
@@ -90,7 +113,7 @@ export default function QueriesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- NewQueryPanel ---------- */
|
||||
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
|
||||
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
|
||||
const [sql, setSql] = useState('');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
@@ -99,6 +122,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
@@ -107,8 +131,30 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const selectedInstance = useMemo(() => {
|
||||
return instances.find(inst => String(inst.id) === instanceId);
|
||||
}, [instances, instanceId]);
|
||||
|
||||
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
|
||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||
|
||||
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
||||
|
||||
const placeholderMap: Record<DBType, string> = {
|
||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
|
||||
const templateSQL = getTemplateSQL(tpl, dbType);
|
||||
setSql(templateSQL);
|
||||
setShowTemplates(false);
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入 SQL'); return; }
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
||||
setExecuting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
@@ -126,6 +172,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
db_type: dbType,
|
||||
status: 'success',
|
||||
execution_time_ms: execTime,
|
||||
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
|
||||
@@ -139,6 +186,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
db_type: dbType,
|
||||
status: 'failed',
|
||||
execution_time_ms: 0,
|
||||
result_preview: msg,
|
||||
@@ -158,59 +206,124 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
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">
|
||||
<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 className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||
{selectedInstance && (
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
|
||||
{dbTypeLabel[dbType] || dbType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instance + Database selector */}
|
||||
<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={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">
|
||||
<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>
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.host}:{inst.port}) — {dbTypeLabel[inst.db_type] || inst.db_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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="可选,默认数据库" />
|
||||
<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>
|
||||
|
||||
{/* Template selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
查询语句 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
快捷模板 ({templates.length})
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Templates dropdown */}
|
||||
{showTemplates && (
|
||||
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map(tpl => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => handleApplyTemplate(tpl)}
|
||||
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
|
||||
<span className="text-sm font-medium">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CodeMirror Editor */}
|
||||
<div className="dg-card overflow-hidden">
|
||||
<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={"-- 仅支持 SELECT 查询\n-- DDL/DML 操作请通过工单系统提交\n\nSELECT id, name, email, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"}
|
||||
<SqlEditor
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
onKeyDown={e => { if (e.ctrlKey && e.key === 'Enter') handleExecute(); }}
|
||||
onChange={setSql}
|
||||
placeholder={placeholderMap[dbType]}
|
||||
language={editorLang}
|
||||
height="220px"
|
||||
onExecute={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>
|
||||
<span>Ctrl + Enter 执行查询</span>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式
|
||||
</span>
|
||||
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security notice */}
|
||||
<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)]">查询结果中的敏感字段(密码、手机号等)将自动脱敏显示。仅支持 SELECT 查询,DML/DDL 请走工单审批。</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">
|
||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 查询结果 */}
|
||||
{/* Query result */}
|
||||
{result && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
@@ -264,11 +377,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<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 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