feat: TicketCreatePage tab化 + SchemaSidebar + Redis快速搜索 + Dashboard导航修复

TicketCreatePage 重构:
- SQL编辑器右侧新增数据库浏览器面板(tab化)
- 表结构Tab: 表列表→字段展开(PK/索引标记)→点击插入SQL
- 索引Tab: 选中表的索引详情(索引名/类型/字段/基数)
- Redis Keys Tab: DB选择器+Key模式搜索+Key列表+类型/TTL/值查看

SchemaSidebar 新组件(365行):
- MySQL/PostgreSQL: 左侧数据库表树→字段→点击插入SQL
- Redis: Key树(DB选择→模式搜索→Key值查看, 类似Another Redis Desktop Manager)
- 用于 QueriesPage 查询页面

Dashboard:
- 快捷按钮改用 useNavigate + onClick 替代 <a href>

API增强:
- redis.ts: 新增 getKeys/createKey/deleteKey 方法
- instanceDetail.ts: 导出 TableMeta/TableDetail/ColumnInfo/IndexInfo 类型接口
This commit is contained in:
logan committed 2026-05-24 15:10:31 +08:00
1 parent 8471f368eb
commit 0fb947dcb5
6 files changed
+943 -399

No files matched your search

+72 -37
View File
@@ -1,48 +1,83 @@
import request from './request'; import request from './request';
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
export const instanceDetailApi = { export const instanceDetailApi = {
// Databases /** 获取 MySQL 数据库列表 */
listDatabases: (id: number) => listDatabases: (instanceId: number) =>
request.get<any, string[]>('/instances/' + id + '/detail/databases'), request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
// Tables /** 获取表列表(含引擎、行数等) */
listTables: (id: number, database: string) => listTables: (instanceId: number, database: string) =>
request.get<any, any[]>('/instances/' + id + '/detail/tables', { params: { database: database } }), request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
params: { database },
}),
// Table Detail /** 获取表详情(DDL + 字段 + 索引) */
getTableDetail: (id: number, database: string, table: string) => getTableDetail: (instanceId: number, database: string, table: string) =>
request.get<any, any>('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }), request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
params: { database, table },
}),
// Users /** SHOW PROCESSLIST */
listUsers: (id: number) => getProcesslist: (instanceId: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/users'), request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
// Grants /** SHOW GLOBAL STATUS */
getGrants: (id: number, user: string, host: string) => getStatus: (instanceId: number) =>
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }), request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
`/instances/${instanceId}/detail/status`
),
// Processlist /** 终止进程 */
getProcesslist: (id: number) => killProcess: (instanceId: number, pid: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/processlist'), request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
// Kill Process /** 获取数据库用户列表 */
killProcess: (id: number, pid: number) => listUsers: (instanceId: number) =>
request.post<any, any>('/instances/' + id + '/detail/processlist/' + pid + '/kill'), request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
// Status
getStatus: (id: number) =>
request.get<any, any>('/instances/' + id + '/detail/status'),
// Variables
getVariables: (id: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/variables'),
}; };
export const backupApi = { /** 表元信息 */
list: (instanceId?: number) => export interface TableMeta {
request.get<any, any[]>('/backups', { params: instanceId ? { instance_id: instanceId } : {} }), name: string;
create: (data: any) => engine: string;
request.post<any, any>('/backups', data), rows: number;
get: (id: number) => data_length: number;
request.get<any, any>('/backups/' + id), index_length: number;
}; comment: string;
collation: string;
create_time: string;
update_time: string;
}
/** 表详情 */
export interface TableDetail {
ddl: string;
columns: ColumnInfo[];
indexes: IndexInfo[];
}
export interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
}
export interface IndexInfo {
Table: string;
Non_unique: number;
Key_name: string;
Seq_in_index: number;
Column_name: string;
Collation: string;
Cardinality: number;
Sub_part: string | null;
Packed: string | null;
Null: string;
Index_type: string;
Comment: string;
Index_comment: string;
}
+12
View File
@@ -28,4 +28,16 @@ export const redisApi = {
// 执行命令 // 执行命令
executeCommand: (instanceId: number, data: { db: number; command: string }) => executeCommand: (instanceId: number, data: { db: number; command: string }) =>
request.post<any, any>('/redis/' + instanceId + '/command', data), request.post<any, any>('/redis/' + instanceId + '/command', data),
// 获取键列表(兼容旧接口)
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 创建键
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
request.post<any, any>('/redis/' + instanceId + '/keys', data),
// 删除键
deleteKey: (instanceId: number, key: string, db?: number) =>
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
}; };
+365
View File
@@ -0,0 +1,365 @@
import { useState, useEffect, useCallback } from 'react';
import {
ChevronDown, ChevronRight, Database, Table2, Columns3,
KeyRound, Search, Loader2, FolderOpen, Server,
} from 'lucide-react';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Props {
instanceId: string;
database: string;
dbType: string;
onInsert: (text: string) => void;
}
interface RedisKeyItem {
key: string;
type: string;
ttl: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatTTL(ttl: number): string {
if (ttl < 0) return '永不过期';
if (ttl < 60) return `${ttl}s`;
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`;
return `${Math.floor(ttl / 86400)}d`;
}
/* ================================================================== */
/* COMPONENT */
/* ================================================================== */
export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) {
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
const isRedis = dbType === 'redis';
// -- MySQL State --
const [tables, setTables] = useState<TableMeta[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [tableSearch, setTableSearch] = useState('');
// -- Redis State --
const [redisDb, setRedisDb] = useState(0);
const [redisPattern, setRedisPattern] = useState('*');
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
const [loadingRedis, setLoadingRedis] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// -- Tab --
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
/* ---------------------------------------------------------------- */
/* Fetch tables */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!instanceId || !database || !isMySQL) { setTables([]); return; }
setLoadingTables(true);
instanceDetailApi.listTables(Number(instanceId), database)
.then((data) => setTables(Array.isArray(data) ? data : []))
.catch(() => setTables([]))
.finally(() => setLoadingTables(false));
}, [instanceId, database, isMySQL]);
/* ---------------------------------------------------------------- */
/* Fetch columns on expand */
/* ---------------------------------------------------------------- */
const handleExpandTable = useCallback(async (tableName: string) => {
if (expandedTable === tableName) { setExpandedTable(null); return; }
setExpandedTable(tableName);
setTableColumns([]);
setLoadingColumns(true);
try {
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName);
setTableColumns(detail?.columns || []);
} catch { /* ignore */ }
finally { setLoadingColumns(false); }
}, [expandedTable, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Fetch Redis keys */
/* ---------------------------------------------------------------- */
const fetchRedisKeys = useCallback(async () => {
if (!instanceId) return;
setLoadingRedis(true);
try {
const res = await redisApi.scanKeys(Number(instanceId), {
db: redisDb, pattern: redisPattern || '*', count: 100,
});
const keys = (res?.keys || []).map((k: any) => ({
key: typeof k === 'string' ? k : k.key,
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
ttl: k.ttl ?? -1,
}));
setRedisKeys(keys);
} catch { setRedisKeys([]); }
finally { setLoadingRedis(false); }
}, [instanceId, redisDb, redisPattern]);
useEffect(() => {
if (isRedis && instanceId) fetchRedisKeys();
}, [isRedis, instanceId, fetchRedisKeys]);
/* ---------------------------------------------------------------- */
/* Fetch Redis key detail */
/* ---------------------------------------------------------------- */
const handleSelectKey = useCallback(async (key: string) => {
setSelectedKey(key);
setLoadingDetail(true);
try {
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
setKeyDetail(detail);
} catch { setKeyDetail(null); }
finally { setLoadingDetail(false); }
}, [instanceId, redisDb]);
/* ---------------------------------------------------------------- */
/* Filtered tables */
/* ---------------------------------------------------------------- */
const filteredTables = tables.filter(t =>
!tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase())
);
/* ================================================================== */
/* RENDER */
/* ================================================================== */
if (!instanceId) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
</div>
{/* Tabs */}
{(isMySQL || isRedis) && (
<div className="flex border-b border-[var(--dg-border)]">
{isMySQL && (
<button
onClick={() => setActiveTab('tables')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
activeTab === 'tables'
? 'text-blue-400 border-blue-400 bg-blue-500/5'
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
}`}
>
<Table2 className="w-3 h-3" /> 表
</button>
)}
{isRedis && (
<button
onClick={() => setActiveTab('redis')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
activeTab === 'redis'
? 'text-blue-400 border-blue-400 bg-blue-500/5'
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
}`}
>
<KeyRound className="w-3 h-3" /> Keys
</button>
)}
</div>
)}
{/* Content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'tables' && isMySQL && (
<div className="h-full flex flex-col">
{/* Search */}
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={tableSearch}
onChange={(e) => setTableSearch(e.target.value)}
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="搜索表..."
/>
</div>
</div>
{/* Table List */}
<div className="flex-1 overflow-y-auto">
{loadingTables ? (
<div className="flex items-center gap-2 p-3 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" /> 加载中...
</div>
) : filteredTables.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无表</div>
) : (
<div className="divide-y divide-[var(--dg-border-light)]">
{filteredTables.map(t => (
<div key={t.name}>
<button
onClick={() => handleExpandTable(t.name)}
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors group"
>
{expandedTable === t.name
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
}
<Table2 className="w-3 h-3 text-blue-400" />
<span
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
title="点击插入"
>
{t.name}
</span>
{t.rows !== undefined && (
<span className="text-[9px] text-[var(--dg-text-muted)]">{t.rows.toLocaleString()}</span>
)}
</button>
{/* Columns */}
{expandedTable === t.name && (
<div className="ml-5 border-l border-[var(--dg-border-light)]">
{loadingColumns ? (
<div className="flex items-center gap-1 p-2 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : (
tableColumns.map(col => (
<button
key={col.Field}
onClick={() => onInsert(col.Field)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
>
<Columns3 className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span>
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto">{col.Type}</span>
{col.Key === 'PRI' && <span className="text-[8px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
</button>
))
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'redis' && isRedis && (
<div className="h-full flex flex-col">
{/* DB + Search */}
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
<select
value={redisDb}
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={i}>DB{i}</option>
))}
</select>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={redisPattern}
onChange={(e) => setRedisPattern(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="*"
/>
</div>
<button
onClick={fetchRedisKeys}
disabled={loadingRedis}
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
>
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
</button>
</div>
{/* Key List + Detail */}
<div className="flex-1 overflow-y-auto">
{redisKeys.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">
{loadingRedis ? '扫描中...' : '无 Keys'}
</div>
) : (
<div className="divide-y divide-[var(--dg-border-light)]">
{redisKeys.map(k => (
<div key={k.key}>
<button
onClick={() => handleSelectKey(k.key)}
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs transition-colors ${
selectedKey === k.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
>
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="font-mono truncate flex-1 text-left">{k.key}</span>
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
</button>
{/* Key Detail */}
{selectedKey === k.key && (
<div className="px-2 pb-2">
{loadingDetail ? (
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : keyDetail ? (
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
{typeof keyDetail.value === 'object'
? JSON.stringify(keyDetail.value, null, 2)
: String(keyDetail.value)}
</pre>
</div>
) : null}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
+8 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
ClipboardList, Clock, CheckCircle, AlertTriangle, ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight, X, AlertCircle, Info, Bell, Database, ChevronRight, X, AlertCircle, Info, Bell,
@@ -125,6 +126,7 @@ export default function Dashboard() {
const chartInstanceRef = useRef<echarts.ECharts | null>(null); const chartInstanceRef = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme(); const { theme } = useTheme();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set()); const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [readIds, setReadIds] = useState<Set<string>>(new Set()); const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -338,9 +340,9 @@ export default function Dashboard() {
}, [stats]); }, [stats]);
const quickActions = [ const quickActions = [
{ label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' }, { label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
{ label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' }, { label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
{ label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' }, { label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
]; ];
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => { const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
@@ -371,15 +373,15 @@ export default function Dashboard() {
{/* ---- Quick Actions ---- */} {/* ---- Quick Actions ---- */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{quickActions.map(action => ( {quickActions.map(action => (
<a <button
key={action.label} key={action.label}
href={action.href} onClick={action.onClick}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`} className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
> >
<action.icon className="w-4 h-4" /> <action.icon className="w-4 h-4" />
{action.label} {action.label}
<ArrowUpRight className="w-3 h-3 opacity-50" /> <ArrowUpRight className="w-3 h-3 opacity-50" />
</a> </button>
))} ))}
</div> </div>
+40 -4
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
Plus, X, AlertTriangle, CheckCircle, Play, Loader2, Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, Clock, FileText, Sparkles, Star, ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, ArrowUpDown, StarOff, Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth'; import { useActionPermission } from '@/hooks/useAuth';
@@ -12,7 +13,10 @@ import EmptyState from '@/components/EmptyState';
import { queryApi } from '@/api/query'; import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance'; import { instanceApi } from '@/api/instance';
import SqlEditor from '@/components/SqlEditor'; import SqlEditor from '@/components/SqlEditor';
import SchemaSidebar from '@/components/SchemaSidebar';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -299,6 +303,7 @@ function NewQueryPanel({
const [rowHeight] = useState(36); const [rowHeight] = useState(36);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const resultContainerRef = useRef<HTMLDivElement>(null); const resultContainerRef = useRef<HTMLDivElement>(null);
const [showSidebar, setShowSidebar] = useState(true);
useEffect(() => { useEffect(() => {
instanceApi.list().then(res => { instanceApi.list().then(res => {
@@ -564,6 +569,20 @@ function NewQueryPanel({
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" /> <FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
查询语句 <span className="text-red-400">*</span> 查询语句 <span className="text-red-400">*</span>
</label> </label>
<div className="flex items-center gap-2">
{instanceId && (
<button
onClick={() => setShowSidebar(!showSidebar)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${
showSidebar
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Database className="w-3.5 h-3.5" />
{showSidebar ? '隐藏导航' : '显示导航'}
</button>
)}
<button <button
onClick={() => setShowTemplates(!showTemplates)} 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)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
@@ -573,6 +592,7 @@ function NewQueryPanel({
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</button> </button>
</div> </div>
</div>
{showTemplates && ( {showTemplates && (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg border border-[var(--dg-border)]"> <div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg border border-[var(--dg-border)]">
@@ -626,7 +646,21 @@ function NewQueryPanel({
</div> </div>
)} )}
{/* CodeMirror Editor */} {/* CodeMirror Editor + Schema Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* Schema Sidebar */}
{showSidebar && instanceId && (
<div className="lg:col-span-1 h-[280px]">
<SchemaSidebar
instanceId={instanceId}
database={database}
dbType={dbType}
onInsert={(text) => setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')}
/>
</div>
)}
{/* Editor */}
<div className={showSidebar && instanceId ? 'lg:col-span-3' : 'lg:col-span-4'}>
<div className="dg-card overflow-hidden"> <div className="dg-card overflow-hidden">
<SqlEditor <SqlEditor
value={sql} value={sql}
@@ -656,6 +690,8 @@ function NewQueryPanel({
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
{/* Security notice */} {/* Security notice */}
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"> <div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
File diff suppressed because it is too large. Load diff