feat: 查询页增强 + 权限体系优化

- Redis DB0~DB15 下拉选择,默认DB0
- 查询历史粘贴按钮(一键填入SQL编辑器)
- 去掉导出历史按钮
- SchemaSidebar 按目标数据库筛选
- SQL自动补全 schema 双层注册(db→tables, table→columns)
- 后端 db-schema 接口(INFORMATION_SCHEMA.COLUMNS 一次加载)
- autocompletion 扩展激活
- Redis key 树形结构(冒号分隔层级)
- Redis 查询模板 GET/HGETALL/HGET
- guest 角色彻底移除,改为 auditor
- 登录页优化:去掉演示账号、placeholder 中文提示
This commit is contained in:
logan committed 2026-05-24 18:50:08 +08:00
1 parent aa2e26c827
commit 35a014e571
13 files changed
+345 -111

No files matched your search

+65 -29
View File
@@ -4,7 +4,7 @@ import {
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap,
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
@@ -294,6 +294,7 @@ function NewQueryPanel({
const [databases, setDatabases] = useState<string[]>([]);
const [loadingDbs, setLoadingDbs] = useState(false);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
const [editorSchema, setEditorSchema] = useState<Record<string, Record<string, string[]>>>({});
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
@@ -314,19 +315,40 @@ function NewQueryPanel({
}).catch(() => { setInstances([]); });
}, []);
// 选实例后加载数据库列表
// 选实例后加载数据库列表 + 表名(用于 SQL 自动补全)
useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); return; }
const inst = instances.find(i => String(i.id) === instanceId);
if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; }
const inst = instances.find(i => i.id === Number(instanceId));
if (inst?.db_type === 'redis') {
setDatabases([]); setDatabase('0'); setEditorSchema({}); return;
}
const type = inst?.db_type || '';
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
setLoadingDbs(true);
instanceDetailApi.listDatabases(Number(instanceId))
.then(data => setDatabases(Array.isArray(data) ? data : []))
.catch(() => setDatabases([]))
.then(async data => {
const dbs = Array.isArray(data) ? data : [];
setDatabases(dbs);
// 快速加载所有库的表名(不含列名)
const schema: Record<string, Record<string, string[]>> = {};
await Promise.all(dbs.map(async db => {
try {
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
if (Array.isArray(tables)) {
schema[db] = {};
for (const t of tables) {
schema[db][t.name] = [];
}
}
} catch { /* ignore */ }
}));
setEditorSchema(schema);
})
.catch(() => { setDatabases([]); setEditorSchema({}); })
.finally(() => setLoadingDbs(false));
} else {
setDatabases([]);
setEditorSchema({});
}
}, [instanceId, instances]);
@@ -337,6 +359,23 @@ function NewQueryPanel({
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
// 选了目标数据库后,加载该库的完整列名用于补全
useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
// 排除 Redis 残留的纯数字 database(如 "0")
if (/^\d+$/.test(database)) return;
instanceDetailApi.getDbSchema(Number(instanceId), database)
.then(data => {
if (data && typeof data === 'object') {
setEditorSchema(prev => ({
...prev,
[database]: data as Record<string, string[]>,
}));
}
})
.catch(() => { /* ignore */ });
}, [instanceId, database, dbType]);
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
const templateCategories = useMemo(() => {
const cats = new Set(templates.map(t => t.category));
@@ -459,20 +498,6 @@ function NewQueryPanel({
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [result]);
const handleExportHistory = useCallback(() => {
const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间'];
const rows = history.map((h, i) => ({
序号: i + 1,
SQL: h.sql,
数据库类型: h.db_type,
'执行时长(ms)': h.execution_time_ms,
状态: h.status === 'success' ? '成功' : '失败',
时间: new Date(h.created_at).toLocaleString(),
}));
const csv = exportToCSV(columns, rows);
downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [history]);
const catLabel: Record<string, string> = {
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
};
@@ -490,15 +515,6 @@ function NewQueryPanel({
)}
</div>
<div className="flex items-center gap-2">
{history.length > 0 && (
<button
onClick={handleExportHistory}
className="flex items-center gap-2 px-3 py-2 text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Download className="w-3.5 h-3.5" />
导出历史
</button>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-2 px-3 py-2 text-xs rounded-lg border transition-colors ${
@@ -549,6 +565,13 @@ function NewQueryPanel({
>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
<button
onClick={() => setSql(h.sql)}
className="p-1 rounded text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="粘贴到编辑器"
>
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
))}
</div>
@@ -576,6 +599,17 @@ function NewQueryPanel({
</div>
<div>
<label className="block text-sm font-medium mb-2">目标数据库</label>
{dbType === 'redis' ? (
<select
value={database || '0'}
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 transition-colors"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={String(i)}>DB{i}</option>
))}
</select>
) : (
<select
value={database}
onChange={e => setDatabase(e.target.value)}
@@ -586,6 +620,7 @@ function NewQueryPanel({
<option key={db} value={db}>{db}</option>
))}
</select>
)}
</div>
</div>
@@ -696,6 +731,7 @@ function NewQueryPanel({
language={editorLang}
height="240px"
onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined}
/>
<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">