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:
1 parent
8471f368eb
commit
0fb947dcb5
6 files changed
+943
-399
No files matched your search
@@ -1,48 +1,83 @@
|
||||
import request from './request';
|
||||
|
||||
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
|
||||
export const instanceDetailApi = {
|
||||
// Databases
|
||||
listDatabases: (id: number) =>
|
||||
request.get<any, string[]>('/instances/' + id + '/detail/databases'),
|
||||
/** 获取 MySQL 数据库列表 */
|
||||
listDatabases: (instanceId: number) =>
|
||||
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
|
||||
|
||||
// Tables
|
||||
listTables: (id: number, database: string) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/tables', { params: { database: database } }),
|
||||
/** 获取表列表(含引擎、行数等) */
|
||||
listTables: (instanceId: number, database: string) =>
|
||||
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
|
||||
params: { database },
|
||||
}),
|
||||
|
||||
// Table Detail
|
||||
getTableDetail: (id: number, database: string, table: string) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }),
|
||||
/** 获取表详情(DDL + 字段 + 索引) */
|
||||
getTableDetail: (instanceId: number, database: string, table: string) =>
|
||||
request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
|
||||
params: { database, table },
|
||||
}),
|
||||
|
||||
// Users
|
||||
listUsers: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/users'),
|
||||
/** SHOW PROCESSLIST */
|
||||
getProcesslist: (instanceId: number) =>
|
||||
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
|
||||
|
||||
// Grants
|
||||
getGrants: (id: number, user: string, host: string) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
|
||||
/** SHOW GLOBAL STATUS */
|
||||
getStatus: (instanceId: number) =>
|
||||
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
|
||||
`/instances/${instanceId}/detail/status`
|
||||
),
|
||||
|
||||
// Processlist
|
||||
getProcesslist: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
||||
/** 终止进程 */
|
||||
killProcess: (instanceId: number, pid: number) =>
|
||||
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
|
||||
|
||||
// Kill Process
|
||||
killProcess: (id: number, pid: number) =>
|
||||
request.post<any, any>('/instances/' + id + '/detail/processlist/' + pid + '/kill'),
|
||||
|
||||
// Status
|
||||
getStatus: (id: number) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/status'),
|
||||
|
||||
// Variables
|
||||
getVariables: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/variables'),
|
||||
/** 获取数据库用户列表 */
|
||||
listUsers: (instanceId: number) =>
|
||||
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
|
||||
};
|
||||
|
||||
export const backupApi = {
|
||||
list: (instanceId?: number) =>
|
||||
request.get<any, any[]>('/backups', { params: instanceId ? { instance_id: instanceId } : {} }),
|
||||
create: (data: any) =>
|
||||
request.post<any, any>('/backups', data),
|
||||
get: (id: number) =>
|
||||
request.get<any, any>('/backups/' + id),
|
||||
};
|
||||
/** 表元信息 */
|
||||
export interface TableMeta {
|
||||
name: string;
|
||||
engine: string;
|
||||
rows: number;
|
||||
data_length: number;
|
||||
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;
|
||||
}
|
||||
@@ -28,4 +28,16 @@ export const redisApi = {
|
||||
// 执行命令
|
||||
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||
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 } }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
||||
@@ -125,6 +126,7 @@ export default function Dashboard() {
|
||||
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -338,9 +340,9 @@ export default function Dashboard() {
|
||||
}, [stats]);
|
||||
|
||||
const quickActions = [
|
||||
{ label: '新建工单', icon: FileText, href: '/tickets', 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: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-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, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-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' }) => {
|
||||
@@ -371,15 +373,15 @@ export default function Dashboard() {
|
||||
{/* ---- Quick Actions ---- */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quickActions.map(action => (
|
||||
<a
|
||||
<button
|
||||
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`}
|
||||
>
|
||||
<action.icon className="w-4 h-4" />
|
||||
{action.label}
|
||||
<ArrowUpRight className="w-3 h-3 opacity-50" />
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||
ChevronDown, Clock, FileText, Sparkles, Star,
|
||||
StarOff, Download, Search, ArrowUpDown,
|
||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose,
|
||||
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
|
||||
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||
KeyRound, FolderOpen, Server, Zap,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
@@ -12,7 +13,10 @@ import EmptyState from '@/components/EmptyState';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||
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';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -299,6 +303,7 @@ function NewQueryPanel({
|
||||
const [rowHeight] = useState(36);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
@@ -564,6 +569,20 @@ function NewQueryPanel({
|
||||
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||
查询语句 <span className="text-red-400">*</span>
|
||||
</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
|
||||
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"
|
||||
@@ -573,6 +592,7 @@ function NewQueryPanel({
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTemplates && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<SqlEditor
|
||||
value={sql}
|
||||
@@ -656,6 +690,8 @@ function NewQueryPanel({
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user