feat(redis): shared key browser with grouped tree + cursor pagination
New src/features/redis/keyBrowser/ shared by RedisDetailPage keys tab and SchemaSidebar redis branch (both previously flat one-shot lists): - keyTree: ':'-separated folder tree with recursive key counts, numeric sort, exact-hit pinned at root, local key removal (no full refetch), type color dots, readable TTL (45s/5m30s/2d5h) - useRedisKeyBrowser: cursor state machine + request-sequence guard + Map merge on load-more; setter-synced args (Enter right after typing no longer races stale render), auto-search on db/instance change - RedisKeyBrowser: enter-to-scan search bar (SCAN has cost - no debounce, opsxdb-style) with clear button, effective type filter, load-more button with cursor hint, '已全部加载' end state - useRedisDbs: real db list from /redis/:id/dbs (cluster -> DB0 banner) - RedisDetailPage: hash row edits now use field-level HSET/HDEL endpoint (preserves other fields + TTL); delete/add update tree locally; CLI output renders arrays/objects/errors properly - SchemaSidebar: redis branch swaps to shared browser (compact mode, inline detail preview + insert-command preserved), auto-switches to Keys tab for redis instances - vite dev proxy target overridable via API_PROXY_TARGET (local testing) Browser-verified: 200/page with load-more (200->400->600), exact hit badge, keyword auto-expand, key detail panel, queries sidebar
This commit is contained in:
1 parent
6604fdf48e
commit
ff539b47e0
8 files changed
+1762
-651
No files matched your search
@@ -1,17 +1,36 @@
|
|||||||
import request from '../request';
|
import request from '../request';
|
||||||
|
|
||||||
|
/** 键摘要(TTL 毫秒,-1 永久) */
|
||||||
|
export interface RedisKeySummary {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 游标分页扫描响应 */
|
||||||
|
export interface RedisScanPage {
|
||||||
|
keys: RedisKeySummary[];
|
||||||
|
/** 不透明游标;空串表示已扫完 */
|
||||||
|
next_cursor: string;
|
||||||
|
exact: RedisKeySummary | null;
|
||||||
|
cluster: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const redisApi = {
|
export const redisApi = {
|
||||||
// Redis 信息概览
|
// Redis 信息概览
|
||||||
getInfo: (instanceId: number) =>
|
getInfo: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/info'),
|
||||||
request.get<any, any>('/redis/' + instanceId + '/info'),
|
|
||||||
|
// 库列表(集群恒 [0])
|
||||||
|
getDbs: (instanceId: number) =>
|
||||||
|
request.get<any, { dbs: number[]; cluster: boolean }>('/redis/' + instanceId + '/dbs'),
|
||||||
|
|
||||||
// DBSIZE
|
// DBSIZE
|
||||||
getDbsize: (instanceId: number, db?: number) =>
|
getDbsize: (instanceId: number, db?: number) =>
|
||||||
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
||||||
|
|
||||||
// 键列表
|
// 键列表(游标分页;无通配符自动按 *关键词* 包含匹配并做精确命中探测)
|
||||||
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
|
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; type?: string; cursor?: string }) =>
|
||||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
request.get<any, RedisScanPage>('/redis/' + instanceId + '/keys', { params }),
|
||||||
|
|
||||||
// 键详情
|
// 键详情
|
||||||
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
||||||
@@ -22,24 +41,31 @@ export const redisApi = {
|
|||||||
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
||||||
|
|
||||||
// 客户端列表
|
// 客户端列表
|
||||||
getClients: (instanceId: number) =>
|
getClients: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||||
request.get<any, any>('/redis/' + instanceId + '/clients'),
|
|
||||||
|
|
||||||
// 执行命令
|
// 执行只读命令(整串,引号感知;白名单外的命令会被拒绝)
|
||||||
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),
|
||||||
|
|
||||||
// 获取键列表(兼容旧接口)
|
// Hash 行级编辑(op: set/del,不整键重写)
|
||||||
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
|
updateKeyField: (
|
||||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
instanceId: number,
|
||||||
|
key: string,
|
||||||
|
data: { db?: number; op: 'set' | 'del'; field: string; value?: string },
|
||||||
|
) =>
|
||||||
|
request.post<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key) + '/field', data, {
|
||||||
|
params: { db: data.db ?? 0 },
|
||||||
|
}),
|
||||||
|
|
||||||
// 创建键
|
// 创建键
|
||||||
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
|
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
|
||||||
request.post<any, any>('/redis/' + instanceId + '/keys', data),
|
request.post<any, any>('/redis/' + instanceId + '/keys', data),
|
||||||
|
|
||||||
// 更新键值(整体替换 value,并可设置 TTL)
|
// 更新键值(整体替换 value;ttl 省略时保留原 TTL)
|
||||||
updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) =>
|
updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) =>
|
||||||
request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }),
|
request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, {
|
||||||
|
params: { db: data.db ?? 0 },
|
||||||
|
}),
|
||||||
|
|
||||||
// 删除键
|
// 删除键
|
||||||
deleteKey: (instanceId: number, key: string, db?: number) =>
|
deleteKey: (instanceId: number, key: string, db?: number) =>
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronDown, ChevronRight, Database, Table2, Columns3,
|
ChevronDown,
|
||||||
KeyRound, Search, Loader2, FolderOpen, Server,
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
Table2,
|
||||||
|
Columns3,
|
||||||
|
KeyRound,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
FolderOpen,
|
||||||
|
Server,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail';
|
import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail';
|
||||||
import { redisApi } from '@/api/redis';
|
import { redisApi } from '@/api/redis';
|
||||||
|
import { RedisKeyBrowser } from '@/features/redis/keyBrowser/RedisKeyBrowser';
|
||||||
|
import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -22,156 +32,6 @@ interface Props {
|
|||||||
singleDb?: boolean;
|
singleDb?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RedisKeyItem {
|
|
||||||
key: string;
|
|
||||||
type: string;
|
|
||||||
ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redis key 树节点 */
|
|
||||||
interface RedisTreeNode {
|
|
||||||
name: string;
|
|
||||||
fullPrefix: string;
|
|
||||||
isLeaf: boolean;
|
|
||||||
keyItem?: RedisKeyItem; // 叶子节点才有
|
|
||||||
children: Map<string, RedisTreeNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 flat keys 按 ':' 构建成树 */
|
|
||||||
function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode {
|
|
||||||
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() };
|
|
||||||
for (const item of keys) {
|
|
||||||
const parts = item.key.split(':');
|
|
||||||
let node = root;
|
|
||||||
let prefix = '';
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
prefix += (i === 0 ? '' : ':') + parts[i];
|
|
||||||
if (!node.children.has(parts[i])) {
|
|
||||||
node.children.set(parts[i], {
|
|
||||||
name: parts[i],
|
|
||||||
fullPrefix: prefix,
|
|
||||||
isLeaf: i === parts.length - 1,
|
|
||||||
keyItem: i === parts.length - 1 ? item : undefined,
|
|
||||||
children: new Map(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
node = node.children.get(parts[i])!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redis 树形渲染组件 */
|
|
||||||
/** 根据 key type 生成查询命令 */
|
|
||||||
function redisCommand(key: string, type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'hash': return `HGETALL ${key}`;
|
|
||||||
case 'list': return `LRANGE ${key} 0 -1`;
|
|
||||||
case 'set': return `SMEMBERS ${key}`;
|
|
||||||
case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`;
|
|
||||||
default: return `GET ${key}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: {
|
|
||||||
tree: RedisTreeNode;
|
|
||||||
expanded: Set<string>;
|
|
||||||
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
||||||
selectedKey: string | null;
|
|
||||||
onSelectKey: (key: string) => void;
|
|
||||||
keyDetail: any;
|
|
||||||
loadingDetail: boolean;
|
|
||||||
onInsert?: (cmd: string) => void;
|
|
||||||
}) {
|
|
||||||
const toggle = (prefix: string) => {
|
|
||||||
setExpanded(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.has(prefix) ? next.delete(prefix) : next.add(prefix);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderNode = (node: RedisTreeNode, depth: number) => {
|
|
||||||
const sorted = [...node.children.values()].sort((a, b) => {
|
|
||||||
// 文件夹优先,叶子在后
|
|
||||||
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted.map(child => {
|
|
||||||
const indent = depth * 12;
|
|
||||||
|
|
||||||
if (child.isLeaf && child.keyItem) {
|
|
||||||
// 叶子节点:实际 Redis key
|
|
||||||
return (
|
|
||||||
<div key={child.fullPrefix}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onSelectKey(child.keyItem!.key);
|
|
||||||
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
|
|
||||||
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: 8 + indent }}
|
|
||||||
>
|
|
||||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
||||||
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
|
|
||||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
|
|
||||||
</button>
|
|
||||||
{selectedKey === child.keyItem.key && (
|
|
||||||
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件夹节点
|
|
||||||
const isOpen = expanded.has(child.fullPrefix);
|
|
||||||
const childCount = child.children.size;
|
|
||||||
return (
|
|
||||||
<div key={child.fullPrefix}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggle(child.fullPrefix)}
|
|
||||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
|
||||||
style={{ paddingLeft: 8 + indent }}
|
|
||||||
>
|
|
||||||
{isOpen
|
|
||||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
|
||||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
|
||||||
}
|
|
||||||
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
|
||||||
<span className="truncate flex-1 text-left">{child.name}</span>
|
|
||||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
|
|
||||||
</button>
|
|
||||||
{isOpen && renderNode(child, depth + 1)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div>{renderNode(tree, 0)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 每个数据库节点缓存的表列表 */
|
|
||||||
interface DbNode {
|
interface DbNode {
|
||||||
name: string;
|
name: string;
|
||||||
tables: TableMeta[];
|
tables: TableMeta[];
|
||||||
@@ -195,7 +55,15 @@ function formatTTL(ttl: number): string {
|
|||||||
/* COMPONENT */
|
/* COMPONENT */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
|
|
||||||
export default function SchemaSidebar({ instanceId, database, dbType, clusterMode, onInsert, onDatabaseChange, singleDb }: Props) {
|
export default function SchemaSidebar({
|
||||||
|
instanceId,
|
||||||
|
database,
|
||||||
|
dbType,
|
||||||
|
clusterMode,
|
||||||
|
onInsert,
|
||||||
|
onDatabaseChange,
|
||||||
|
singleDb,
|
||||||
|
}: Props) {
|
||||||
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
|
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
|
||||||
const isRedis = dbType === 'redis';
|
const isRedis = dbType === 'redis';
|
||||||
|
|
||||||
@@ -211,12 +79,9 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns');
|
const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// -- Redis State --
|
// -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
|
||||||
const [redisDb, setRedisDb] = useState(0);
|
const [redisDb, setRedisDb] = useState(0);
|
||||||
const [redisPattern, setRedisPattern] = useState('*');
|
const redisDbs = useRedisDbs(Number(instanceId));
|
||||||
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
|
|
||||||
const [loadingRedis, setLoadingRedis] = useState(false);
|
|
||||||
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
const [keyDetail, setKeyDetail] = useState<any>(null);
|
const [keyDetail, setKeyDetail] = useState<any>(null);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
@@ -224,14 +89,24 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
// -- Tab --
|
// -- Tab --
|
||||||
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
|
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
|
||||||
|
|
||||||
|
// 驱动类型切换时落到对应 tab:redis → Keys,SQL 库 → 库表
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRedis) setActiveTab('redis');
|
||||||
|
else setActiveTab('tables');
|
||||||
|
}, [isRedis]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Fetch databases (level 1) */
|
/* Fetch databases (level 1) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !isMySQL) { setDatabases([]); return; }
|
if (!instanceId || !isMySQL) {
|
||||||
|
setDatabases([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingDbs(true);
|
setLoadingDbs(true);
|
||||||
instanceDetailApi.listDatabases(Number(instanceId))
|
instanceDetailApi
|
||||||
|
.listDatabases(Number(instanceId))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const list = Array.isArray(data) ? data : [];
|
const list = Array.isArray(data) ? data : [];
|
||||||
setDatabases(list);
|
setDatabases(list);
|
||||||
@@ -255,7 +130,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
/* Expand database → load tables (level 2) */
|
/* Expand database → load tables (level 2) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleExpandDb = useCallback(async (dbName: string) => {
|
const handleExpandDb = useCallback(
|
||||||
|
async (dbName: string) => {
|
||||||
if (expandedDb === dbName) {
|
if (expandedDb === dbName) {
|
||||||
setExpandedDb(null);
|
setExpandedDb(null);
|
||||||
return;
|
return;
|
||||||
@@ -266,23 +142,29 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
// 已加载过则跳过
|
// 已加载过则跳过
|
||||||
if (dbNodes[dbName]?.loaded) return;
|
if (dbNodes[dbName]?.loaded) return;
|
||||||
|
|
||||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } }));
|
setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } }));
|
||||||
try {
|
try {
|
||||||
const data = await instanceDetailApi.listTables(Number(instanceId), dbName);
|
const data = await instanceDetailApi.listTables(Number(instanceId), dbName);
|
||||||
const tables = Array.isArray(data) ? data : [];
|
const tables = Array.isArray(data) ? data : [];
|
||||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } }));
|
setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } }));
|
||||||
} catch {
|
} catch {
|
||||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } }));
|
setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } }));
|
||||||
}
|
}
|
||||||
}, [expandedDb, instanceId, dbNodes]);
|
},
|
||||||
|
[expandedDb, instanceId, dbNodes],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Expand table → load columns (level 3) */
|
/* Expand table → load columns (level 3) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleExpandTable = useCallback(async (dbName: string, tableName: string) => {
|
const handleExpandTable = useCallback(
|
||||||
|
async (dbName: string, tableName: string) => {
|
||||||
const key = `${dbName}.${tableName}`;
|
const key = `${dbName}.${tableName}`;
|
||||||
if (expandedTable === key) { setExpandedTable(null); return; }
|
if (expandedTable === key) {
|
||||||
|
setExpandedTable(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExpandedTable(key);
|
setExpandedTable(key);
|
||||||
setTableColumns([]);
|
setTableColumns([]);
|
||||||
setTableIndexes([]);
|
setTableIndexes([]);
|
||||||
@@ -292,79 +174,51 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
|
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
|
||||||
setTableColumns(detail?.columns || []);
|
setTableColumns(detail?.columns || []);
|
||||||
setTableIndexes(detail?.indexes || []);
|
setTableIndexes(detail?.indexes || []);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
finally { setLoadingColumns(false); }
|
/* ignore */
|
||||||
}, [expandedTable, instanceId]);
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedTable, instanceId],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Fetch Redis keys */
|
/* 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const keyword = redisPattern.trim();
|
|
||||||
if (!isRedis || redisKeys.length === 0 || !keyword || keyword === '*') return;
|
|
||||||
|
|
||||||
setRedisExpanded(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
redisKeys.forEach(item => {
|
|
||||||
const parts = item.key.split(':');
|
|
||||||
let prefix = '';
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
prefix += (i === 0 ? '' : ':') + parts[i];
|
|
||||||
next.add(prefix);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [isRedis, redisKeys, redisPattern]);
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Fetch Redis key detail */
|
/* Fetch Redis key detail */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleSelectKey = useCallback(async (key: string) => {
|
const handleSelectKey = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
try {
|
try {
|
||||||
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
|
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
|
||||||
setKeyDetail(detail);
|
setKeyDetail(detail);
|
||||||
} catch { setKeyDetail(null); }
|
} catch {
|
||||||
finally { setLoadingDetail(false); }
|
setKeyDetail(null);
|
||||||
}, [instanceId, redisDb]);
|
} finally {
|
||||||
|
setLoadingDetail(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, redisDb],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Filtered databases (search matches db name or table name) */
|
/* Filtered databases (search matches db name or table name) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const filteredDatabases = databases.filter(dbName => {
|
const filteredDatabases = databases.filter((dbName) => {
|
||||||
if (singleDb && database && dbName !== database) return false;
|
if (singleDb && database && dbName !== database) return false;
|
||||||
if (!search) return true;
|
if (!search) return true;
|
||||||
const kw = search.toLowerCase();
|
const kw = search.toLowerCase();
|
||||||
if (dbName.toLowerCase().includes(kw)) return true;
|
if (dbName.toLowerCase().includes(kw)) return true;
|
||||||
const node = dbNodes[dbName];
|
const node = dbNodes[dbName];
|
||||||
if (node?.loaded) {
|
if (node?.loaded) {
|
||||||
return node.tables.some(t => t.name.toLowerCase().includes(kw));
|
return node.tables.some((t) => t.name.toLowerCase().includes(kw));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -374,7 +228,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
if (!node?.loaded) return [];
|
if (!node?.loaded) return [];
|
||||||
if (!search) return node.tables;
|
if (!search) return node.tables;
|
||||||
const kw = search.toLowerCase();
|
const kw = search.toLowerCase();
|
||||||
return node.tables.filter(t => t.name.toLowerCase().includes(kw));
|
return node.tables.filter((t) => t.name.toLowerCase().includes(kw));
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
@@ -465,7 +319,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
|
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{filteredDatabases.map(dbName => {
|
{filteredDatabases.map((dbName) => {
|
||||||
const isExpanded = expandedDb === dbName;
|
const isExpanded = expandedDb === dbName;
|
||||||
const node = dbNodes[dbName];
|
const node = dbNodes[dbName];
|
||||||
const tables = getFilteredTables(dbName);
|
const tables = getFilteredTables(dbName);
|
||||||
@@ -483,14 +337,17 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
isActive ? 'text-blue-400' : ''
|
isActive ? 'text-blue-400' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isExpanded
|
{isExpanded ? (
|
||||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
<ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
) : (
|
||||||
}
|
<ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
|
)}
|
||||||
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||||
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
|
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
|
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">
|
||||||
|
当前
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -509,7 +366,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
) : tables.length === 0 ? (
|
) : tables.length === 0 ? (
|
||||||
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
|
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
|
||||||
) : (
|
) : (
|
||||||
tables.map(t => {
|
tables.map((t) => {
|
||||||
const tableKey = `${dbName}.${t.name}`;
|
const tableKey = `${dbName}.${t.name}`;
|
||||||
const isTableExpanded = expandedTable === tableKey;
|
const isTableExpanded = expandedTable === tableKey;
|
||||||
|
|
||||||
@@ -517,22 +374,28 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
<div key={t.name}>
|
<div key={t.name}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.altKey) { onInsert(`${dbName}.${t.name}`); }
|
if (e.altKey) {
|
||||||
else { handleExpandTable(dbName, t.name); }
|
onInsert(`${dbName}.${t.name}`);
|
||||||
|
} else {
|
||||||
|
handleExpandTable(dbName, t.name);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||||
title={`单击展开/折叠 · Alt+点击插入表名`}
|
title={`单击展开/折叠 · Alt+点击插入表名`}
|
||||||
>
|
>
|
||||||
{isTableExpanded
|
{isTableExpanded ? (
|
||||||
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
) : (
|
||||||
}
|
<ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
|
)}
|
||||||
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
||||||
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
|
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
|
||||||
{t.name}
|
{t.name}
|
||||||
</span>
|
</span>
|
||||||
{t.rows !== undefined && (
|
{t.rows !== undefined && (
|
||||||
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span>
|
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">
|
||||||
|
{t.rows.toLocaleString()}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -572,7 +435,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 字段列表 */}
|
{/* 字段列表 */}
|
||||||
{detailTab === 'columns' && tableColumns.map(col => (
|
{detailTab === 'columns' &&
|
||||||
|
tableColumns.map((col) => (
|
||||||
<button
|
<button
|
||||||
key={col.Field}
|
key={col.Field}
|
||||||
onClick={() => onInsert(`${t.name}.${col.Field}`)}
|
onClick={() => onInsert(`${t.name}.${col.Field}`)}
|
||||||
@@ -580,40 +444,74 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||||
>
|
>
|
||||||
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
|
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span>
|
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">
|
||||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span>
|
{col.Field}
|
||||||
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>}
|
</span>
|
||||||
|
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
|
||||||
|
{col.Type}
|
||||||
|
</span>
|
||||||
|
{col.Key === 'PRI' && (
|
||||||
|
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
|
||||||
|
PK
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 索引列表 */}
|
{/* 索引列表 */}
|
||||||
{detailTab === 'indexes' && (
|
{detailTab === 'indexes' &&
|
||||||
tableIndexes.length === 0 ? (
|
(tableIndexes.length === 0 ? (
|
||||||
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">无索引</div>
|
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">
|
||||||
|
无索引
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(
|
Object.entries(
|
||||||
tableIndexes.reduce((acc, idx) => {
|
tableIndexes.reduce(
|
||||||
|
(acc, idx) => {
|
||||||
if (!acc[idx.Key_name]) acc[idx.Key_name] = [];
|
if (!acc[idx.Key_name]) acc[idx.Key_name] = [];
|
||||||
acc[idx.Key_name].push(idx);
|
acc[idx.Key_name].push(idx);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, IndexInfo[]>)
|
},
|
||||||
|
{} as Record<string, IndexInfo[]>,
|
||||||
|
),
|
||||||
).map(([keyName, cols]) => (
|
).map(([keyName, cols]) => (
|
||||||
<div key={keyName} className="px-2 py-1">
|
<div key={keyName} className="px-2 py-1">
|
||||||
<div className="flex items-center gap-1 text-[10px]">
|
<div className="flex items-center gap-1 text-[10px]">
|
||||||
<KeyRound className="w-2 h-2 flex-shrink-0" style={{ color: cols[0].Non_unique ? 'var(--dg-text-muted)' : '#facc15' }} />
|
<KeyRound
|
||||||
<span className="font-mono text-[var(--dg-text-secondary)] truncate">{keyName}</span>
|
className="w-2 h-2 flex-shrink-0"
|
||||||
{!cols[0].Non_unique && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">UNI</span>}
|
style={{
|
||||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{cols[0].Index_type}</span>
|
color: cols[0].Non_unique
|
||||||
|
? 'var(--dg-text-muted)'
|
||||||
|
: '#facc15',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[var(--dg-text-secondary)] truncate">
|
||||||
|
{keyName}
|
||||||
|
</span>
|
||||||
|
{!cols[0].Non_unique && (
|
||||||
|
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
|
||||||
|
UNI
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
|
||||||
|
{cols[0].Index_type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-wrap gap-x-2">
|
<div className="ml-4 flex flex-wrap gap-x-2">
|
||||||
{cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => (
|
{cols
|
||||||
<span key={c.Column_name} className="text-[9px] font-mono text-[var(--dg-text-muted)]">{c.Column_name}</span>
|
.sort((a, b) => a.Seq_in_index - b.Seq_in_index)
|
||||||
|
.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.Column_name}
|
||||||
|
className="text-[9px] font-mono text-[var(--dg-text-muted)]"
|
||||||
|
>
|
||||||
|
{c.Column_name}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -635,68 +533,63 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
|||||||
|
|
||||||
{activeTab === 'redis' && isRedis && (
|
{activeTab === 'redis' && isRedis && (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* DB + Search */}
|
{clusterMode || redisDbs.cluster ? (
|
||||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
<div className="px-2 py-1.5 text-[10px] text-amber-400 bg-amber-500/10 border-b border-[var(--dg-border-light)] text-center">
|
||||||
{clusterMode ? (
|
|
||||||
<div className="px-2 py-1 text-[10px] text-amber-400 bg-amber-500/10 rounded text-center">
|
|
||||||
集群模式 · DB0
|
集群模式 · DB0
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={redisDb}
|
value={redisDb}
|
||||||
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
onChange={(e) => {
|
||||||
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"
|
setRedisDb(Number(e.target.value));
|
||||||
|
setSelectedKey(null);
|
||||||
|
}}
|
||||||
|
className="mx-2 mt-2 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) => (
|
{redisDbs.dbs.map((i) => (
|
||||||
<option key={i} value={i}>DB{i}</option>
|
<option key={i} value={i}>
|
||||||
|
DB{i}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<div className="relative">
|
<div className="flex-1 min-h-0">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
<RedisKeyBrowser
|
||||||
<input
|
compact
|
||||||
value={redisPattern}
|
instanceId={Number(instanceId)}
|
||||||
onChange={(e) => setRedisPattern(e.target.value)}
|
db={redisDb}
|
||||||
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 Tree + Detail */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loadingRedis ? (
|
|
||||||
<div className="space-y-1 p-2">
|
|
||||||
{Array.from({ length: 6 }, (_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
|
||||||
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
|
|
||||||
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
|
||||||
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-10" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : redisKeys.length === 0 ? (
|
|
||||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
|
|
||||||
) : (
|
|
||||||
<RedisTree
|
|
||||||
tree={buildRedisTree(redisKeys)}
|
|
||||||
expanded={redisExpanded}
|
|
||||||
setExpanded={setRedisExpanded}
|
|
||||||
selectedKey={selectedKey}
|
selectedKey={selectedKey}
|
||||||
onSelectKey={handleSelectKey}
|
onKeySelect={(key) => {
|
||||||
keyDetail={keyDetail}
|
handleSelectKey(key);
|
||||||
loadingDetail={loadingDetail}
|
}}
|
||||||
onInsert={onInsert}
|
onInsertCommand={onInsert}
|
||||||
|
renderDetail={(key) => {
|
||||||
|
if (selectedKey !== key) return null;
|
||||||
|
if (loadingDetail) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!keyDetail) return null;
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* Redis 键浏览复合组件(搜索 + 类型筛选 + 分组树 + 游标加载更多)。
|
||||||
|
* 用途:RedisDetailPage 键管理 tab(标准模式)与 SchemaSidebar Redis 分支(compact 模式)共用。
|
||||||
|
* 必需 props:instanceId / db / onKeySelect;已知限制:树按 `:` 分组,分隔符不可配。
|
||||||
|
*/
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { Search, X, ChevronDown, ChevronRight, FolderOpen, KeyRound, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { redisApi } from '@/api/datasource/redis';
|
||||||
|
import {
|
||||||
|
buildRedisKeyTree,
|
||||||
|
removeRedisKeyFromTree,
|
||||||
|
sortTreeChildren,
|
||||||
|
redisTypeColor,
|
||||||
|
formatRedisTtl,
|
||||||
|
redisReadCommand,
|
||||||
|
type RedisTreeNode,
|
||||||
|
} from './keyTree';
|
||||||
|
import { useRedisKeyBrowser } from './useRedisKeyBrowser';
|
||||||
|
|
||||||
|
export interface RedisKeyBrowserHandle {
|
||||||
|
/** 重新执行当前搜索(新建键后调用) */
|
||||||
|
refresh: () => void;
|
||||||
|
/** 本地移除一个键(删除成功后调用) */
|
||||||
|
removeKey: (keyName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: number;
|
||||||
|
db: number;
|
||||||
|
selectedKey?: string | null;
|
||||||
|
onKeySelect: (key: string, type: string) => void;
|
||||||
|
/** 叶子点击时额外回调(如向编辑器插入读命令) */
|
||||||
|
onInsertCommand?: (cmd: string) => void;
|
||||||
|
/** 选中键的内联详情预览(SchemaSidebar 模式) */
|
||||||
|
renderDetail?: (key: string) => ReactNode;
|
||||||
|
/** 搜索区上方的额外工具(如「新建」按钮) */
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'string', label: 'String' },
|
||||||
|
{ value: 'hash', label: 'Hash' },
|
||||||
|
{ value: 'list', label: 'List' },
|
||||||
|
{ value: 'set', label: 'Set' },
|
||||||
|
{ value: 'zset', label: 'ZSet' },
|
||||||
|
{ value: 'stream', label: 'Stream' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RedisKeyBrowser = forwardRef<RedisKeyBrowserHandle, Props>(function RedisKeyBrowser(
|
||||||
|
{ instanceId, db, selectedKey, onKeySelect, onInsertCommand, renderDetail, toolbar, compact = false },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const browser = useRedisKeyBrowser(instanceId, db);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const lastSearchKeyRef = useRef('');
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
refresh: browser.search,
|
||||||
|
removeKey: browser.removeKey,
|
||||||
|
}),
|
||||||
|
[browser.search, browser.removeKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildRedisKeyTree(browser.items, browser.exact), [browser.items, browser.exact]);
|
||||||
|
|
||||||
|
// 新搜索完成且带关键字时自动展开文件夹(加载更多不触发)
|
||||||
|
useEffect(() => {
|
||||||
|
const kw = browser.pattern.trim();
|
||||||
|
const searchKey = `${kw}|${browser.typeFilter}|${db}`;
|
||||||
|
const isFreshSearch = lastSearchKeyRef.current !== searchKey;
|
||||||
|
lastSearchKeyRef.current = searchKey;
|
||||||
|
if (!isFreshSearch || browser.items.length === 0) return;
|
||||||
|
if (!kw || kw === '*') return;
|
||||||
|
setExpanded(() => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
browser.items.forEach((item) => {
|
||||||
|
const parts = item.key.split(':');
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
prefix += (i === 0 ? '' : ':') + parts[i];
|
||||||
|
next.add(prefix);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [browser.items]);
|
||||||
|
|
||||||
|
const toggle = (prefix: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(prefix)) next.delete(prefix);
|
||||||
|
else next.add(prefix);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls = compact
|
||||||
|
? 'w-full bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500'
|
||||||
|
: 'h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] font-mono text-sm';
|
||||||
|
const selectCls = compact
|
||||||
|
? 'px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500'
|
||||||
|
: 'h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm';
|
||||||
|
const searchBtnCls = compact
|
||||||
|
? 'px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50'
|
||||||
|
: 'h-9 px-4 rounded-md bg-[var(--dg-blue)] text-sm font-medium text-white hover:opacity-90 disabled:opacity-50';
|
||||||
|
|
||||||
|
const renderNode = (node: RedisTreeNode, depth: number): ReactNode => {
|
||||||
|
const sorted = sortTreeChildren([...node.children.values()]);
|
||||||
|
const indent = depth * 12;
|
||||||
|
|
||||||
|
return sorted.map((child) => {
|
||||||
|
if (child.isLeaf && child.keyItem) {
|
||||||
|
const item = child.keyItem;
|
||||||
|
const active = selectedKey === item.key;
|
||||||
|
return (
|
||||||
|
<div key={`leaf:${child.fullPrefix}`}>
|
||||||
|
<button
|
||||||
|
title={`${item.key} · ${item.type} · TTL ${formatRedisTtl(item.ttl)}`}
|
||||||
|
onClick={() => {
|
||||||
|
onKeySelect(item.key, item.type);
|
||||||
|
onInsertCommand?.(redisReadCommand(item.key, item.type));
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--dg-blue)]/10 text-blue-400'
|
||||||
|
: 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: 8 + indent }}
|
||||||
|
>
|
||||||
|
{node === tree && browser.exact?.key === item.key && (
|
||||||
|
<span className="rounded bg-emerald-500/15 px-1 text-[9px] text-emerald-400 flex-shrink-0">精确</span>
|
||||||
|
)}
|
||||||
|
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
|
<span className="font-mono truncate flex-1 text-left" title={item.key}>
|
||||||
|
{child.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${redisTypeColor(item.type)}`}
|
||||||
|
title={item.type}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{formatRedisTtl(item.ttl)}</span>
|
||||||
|
</button>
|
||||||
|
{active && renderDetail && (
|
||||||
|
<div className={compact ? 'px-2 pb-2' : 'px-3 pb-2'} style={{ paddingLeft: 12 + indent }}>
|
||||||
|
{renderDetail(item.key)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = expanded.has(child.fullPrefix);
|
||||||
|
return (
|
||||||
|
<div key={`dir:${child.fullPrefix}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(child.fullPrefix)}
|
||||||
|
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors`}
|
||||||
|
style={{ paddingLeft: 8 + indent }}
|
||||||
|
title={child.fullPrefix}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
||||||
|
<span className="truncate flex-1 text-left font-mono">{child.name}</span>
|
||||||
|
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{child.leafCount}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && renderNode(child, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasQuery = browser.pattern.trim() !== '' || browser.typeFilter !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 h-full flex-col">
|
||||||
|
{/* 搜索区 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? 'border-b border-[var(--dg-border-light)] px-2 py-2 space-y-1.5'
|
||||||
|
: 'border-b border-[var(--dg-border)] p-3 space-y-2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolbar}
|
||||||
|
<div className={compact ? 'relative' : 'relative mb-0'}>
|
||||||
|
<Search
|
||||||
|
className={`absolute ${compact ? 'left-2 w-3 h-3' : 'left-3 h-4 w-4'} top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={browser.pattern}
|
||||||
|
onChange={(e) => browser.setPattern(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') browser.search();
|
||||||
|
}}
|
||||||
|
className={`${inputCls} ${compact ? 'pl-7 pr-7 py-1' : 'pl-9 pr-9'}`}
|
||||||
|
placeholder={compact ? '搜索 key,回车扫描;* 全部' : '输入关键词或 pattern,回车扫描(* 扫全部)'}
|
||||||
|
/>
|
||||||
|
{browser.pattern && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
browser.setPattern('');
|
||||||
|
}}
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 ${compact ? 'right-2' : 'right-3'} text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]`}
|
||||||
|
title="清空"
|
||||||
|
>
|
||||||
|
<X className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={compact ? 'flex gap-1.5' : 'grid grid-cols-[1fr_auto] gap-2'}>
|
||||||
|
<select
|
||||||
|
value={browser.typeFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
browser.setTypeFilter(e.target.value);
|
||||||
|
}}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={browser.search}
|
||||||
|
disabled={browser.loading}
|
||||||
|
className={`${searchBtnCls} inline-flex items-center justify-center gap-1.5`}
|
||||||
|
title="从当前游标继续扫描,不会一次读取全部 Key"
|
||||||
|
>
|
||||||
|
{browser.loading ? (
|
||||||
|
<Loader2 className={compact ? 'w-3 h-3' : 'w-4 h-4'} animate-spin />
|
||||||
|
) : (
|
||||||
|
<Search className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||||
|
)}
|
||||||
|
{!compact && '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 计数行 */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between border-b border-[var(--dg-border-light)] ${compact ? 'px-2 py-1.5 text-[10px]' : 'px-3 py-2 text-xs'} text-[var(--dg-text-muted)]`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
共 {browser.total} 键{browser.exact ? ' · 精确命中置顶' : ''}
|
||||||
|
</span>
|
||||||
|
{browser.total > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={browser.search}
|
||||||
|
disabled={browser.loading}
|
||||||
|
className="inline-flex items-center gap-1 hover:text-[var(--dg-text-primary)]"
|
||||||
|
title="重新扫描"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${browser.loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 树 */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
|
{browser.loading ? (
|
||||||
|
<div className={`space-y-2 ${compact ? 'p-2' : 'p-3'}`}>
|
||||||
|
{Array.from({ length: compact ? 6 : 8 }, (_, i) => (
|
||||||
|
<div key={i} className="h-6 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !hasQuery && browser.items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
|
||||||
|
>
|
||||||
|
输入关键词搜索;留空回车可浏览全部
|
||||||
|
</div>
|
||||||
|
) : browser.items.length === 0 && !browser.exact ? (
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
|
||||||
|
>
|
||||||
|
无匹配 Keys
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderNode(tree, 0)}
|
||||||
|
{browser.hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={browser.loadMore}
|
||||||
|
disabled={browser.loadingMore}
|
||||||
|
className={`w-full ${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-blue-400 hover:bg-blue-500/5 disabled:opacity-50`}
|
||||||
|
title="继续从当前游标扫描,不会一次读取全部 Key"
|
||||||
|
>
|
||||||
|
{browser.loadingMore ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '加载更多'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!browser.hasMore && browser.total > 0 && (
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-center text-[var(--dg-text-muted)]`}
|
||||||
|
>
|
||||||
|
已全部加载
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Redis 键分组树(对标 opsxdb src/redis.ts):
|
||||||
|
* - 按 `:` 分隔符把键编成 folder 树,文件夹带递归 key 计数
|
||||||
|
* - 无分隔符的键留根层;精确命中(exact)置顶根层
|
||||||
|
* - 支持本地移除(删键后不整树重拉)
|
||||||
|
*/
|
||||||
|
import type { RedisKeySummary } from '@/api/datasource/redis';
|
||||||
|
|
||||||
|
export interface RedisTreeNode {
|
||||||
|
name: string;
|
||||||
|
fullPrefix: string;
|
||||||
|
isLeaf: boolean;
|
||||||
|
keyItem?: RedisKeySummary;
|
||||||
|
children: Map<string, RedisTreeNode>;
|
||||||
|
/** 该子树下(递归)的叶子键数 */
|
||||||
|
leafCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REDIS_KEY_SEPARATOR = ':';
|
||||||
|
|
||||||
|
/** 类型色点(string 绿/hash 蓝/list 琥珀/set 紫/zset 青/stream 粉) */
|
||||||
|
export function redisTypeColor(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return 'bg-emerald-400';
|
||||||
|
case 'hash':
|
||||||
|
return 'bg-blue-400';
|
||||||
|
case 'list':
|
||||||
|
return 'bg-amber-400';
|
||||||
|
case 'set':
|
||||||
|
return 'bg-purple-400';
|
||||||
|
case 'zset':
|
||||||
|
return 'bg-cyan-400';
|
||||||
|
case 'stream':
|
||||||
|
return 'bg-pink-400';
|
||||||
|
default:
|
||||||
|
return 'bg-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTL 毫秒 → 可读(-1 永久 / 45s / 5m30s / 3h20m / 2d5h) */
|
||||||
|
export function formatRedisTtl(ttlMs: number | undefined): string {
|
||||||
|
if (ttlMs == null || ttlMs < 0) return '永久';
|
||||||
|
if (ttlMs === 0) return '即将过期';
|
||||||
|
const seconds = Math.ceil(ttlMs / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60 ? ' ' + (seconds % 60) + 's' : ''}`;
|
||||||
|
if (seconds < 86400)
|
||||||
|
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60) ? ' ' + Math.floor((seconds % 3600) / 60) + 'm' : ''}`;
|
||||||
|
return `${Math.floor(seconds / 86400)}d${Math.floor((seconds % 86400) / 3600) ? ' ' + Math.floor((seconds % 86400) / 3600) + 'h' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLeaves(node: RedisTreeNode): number {
|
||||||
|
if (node.isLeaf) return 1;
|
||||||
|
let n = 0;
|
||||||
|
for (const child of node.children.values()) n += countLeaves(child);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建树:exact 命中(若有)置顶根层 */
|
||||||
|
export function buildRedisKeyTree(keys: RedisKeySummary[], exact?: RedisKeySummary | null): RedisTreeNode {
|
||||||
|
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map(), leafCount: 0 };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
if (exact) {
|
||||||
|
root.children.set(exact.key, {
|
||||||
|
name: exact.key,
|
||||||
|
fullPrefix: exact.key,
|
||||||
|
isLeaf: true,
|
||||||
|
keyItem: exact,
|
||||||
|
children: new Map(),
|
||||||
|
leafCount: 1,
|
||||||
|
});
|
||||||
|
seen.add(exact.key);
|
||||||
|
}
|
||||||
|
for (const item of keys) {
|
||||||
|
if (seen.has(item.key)) continue;
|
||||||
|
seen.add(item.key);
|
||||||
|
const parts = item.key.split(REDIS_KEY_SEPARATOR);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// 无分隔符:留根层
|
||||||
|
root.children.set(item.key, {
|
||||||
|
name: item.key,
|
||||||
|
fullPrefix: item.key,
|
||||||
|
isLeaf: true,
|
||||||
|
keyItem: item,
|
||||||
|
children: new Map(),
|
||||||
|
leafCount: 1,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let node = root;
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
prefix += (i === 0 ? '' : REDIS_KEY_SEPARATOR) + parts[i];
|
||||||
|
let child = node.children.get(parts[i]);
|
||||||
|
if (!child || child.isLeaf) {
|
||||||
|
child = { name: parts[i], fullPrefix: prefix, isLeaf: false, children: new Map(), leafCount: 0 };
|
||||||
|
node.children.set(parts[i], child);
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
const leafName = parts[parts.length - 1];
|
||||||
|
node.children.set(leafName, {
|
||||||
|
name: leafName,
|
||||||
|
fullPrefix: item.key,
|
||||||
|
isLeaf: true,
|
||||||
|
keyItem: item,
|
||||||
|
children: new Map(),
|
||||||
|
leafCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.leafCount = countLeaves(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地移除一个键(含空文件夹剪枝);返回树是否变化 */
|
||||||
|
export function removeRedisKeyFromTree(root: RedisTreeNode, keyName: string): boolean {
|
||||||
|
const parts = keyName.split(REDIS_KEY_SEPARATOR);
|
||||||
|
const path: RedisTreeNode[] = [root];
|
||||||
|
let node = root;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const child = node.children.get(parts[i]);
|
||||||
|
if (!child) return false;
|
||||||
|
path.push(child);
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
const leafName = parts[parts.length - 1];
|
||||||
|
if (!node.children.delete(leafName)) return false;
|
||||||
|
// 从深处向根剪空文件夹并重算计数
|
||||||
|
for (let i = path.length - 1; i >= 1; i--) {
|
||||||
|
const parent = path[i - 1];
|
||||||
|
const child = path[i];
|
||||||
|
child.leafCount = countLeaves(child);
|
||||||
|
if (!child.isLeaf && child.children.size === 0) {
|
||||||
|
parent.children.delete(child.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.leafCount = countLeaves(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件夹优先、同层 numeric 感知排序 */
|
||||||
|
export function sortTreeChildren(children: RedisTreeNode[]): RedisTreeNode[] {
|
||||||
|
return [...children].sort((a, b) => {
|
||||||
|
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
|
||||||
|
const na = Number(a.name);
|
||||||
|
const nb = Number(b.name);
|
||||||
|
const aNum = !Number.isNaN(na) && a.name.trim() !== '';
|
||||||
|
const bNum = !Number.isNaN(nb) && b.name.trim() !== '';
|
||||||
|
if (aNum && bNum) return na - nb;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 key type 生成查询命令 */
|
||||||
|
export function redisReadCommand(key: string, type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'hash':
|
||||||
|
return `HGETALL ${key}`;
|
||||||
|
case 'list':
|
||||||
|
return `LRANGE ${key} 0 -1`;
|
||||||
|
case 'set':
|
||||||
|
return `SMEMBERS ${key}`;
|
||||||
|
case 'zset':
|
||||||
|
return `ZRANGE ${key} 0 -1 WITHSCORES`;
|
||||||
|
default:
|
||||||
|
return `GET ${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Redis 键浏览数据 hook(游标分页状态机):
|
||||||
|
* - 请求序号守卫:过期响应直接丢弃(快速切库/改筛选不竞态)
|
||||||
|
* - 增量合并:加载更多按键名 Map 追加,不整表重拉
|
||||||
|
* - db / pattern / type 任一变化都重置游标
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { redisApi, type RedisKeySummary } from '@/api/datasource/redis';
|
||||||
|
|
||||||
|
export interface UseRedisKeyBrowserResult {
|
||||||
|
items: RedisKeySummary[];
|
||||||
|
exact: RedisKeySummary | null;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
pattern: string;
|
||||||
|
typeFilter: string;
|
||||||
|
setPattern: (v: string) => void;
|
||||||
|
setTypeFilter: (v: string) => void;
|
||||||
|
/** 用当前 pattern/typeFilter 发起新搜索 */
|
||||||
|
search: () => void;
|
||||||
|
loadMore: () => void;
|
||||||
|
/** 本地移除一个键(删除成功后调用,不重拉) */
|
||||||
|
removeKey: (keyName: string) => void;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRedisKeyBrowser(instanceId: number, db: number): UseRedisKeyBrowserResult {
|
||||||
|
const [items, setItems] = useState<RedisKeySummary[]>([]);
|
||||||
|
const [exact, setExact] = useState<RedisKeySummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const [pattern, setPatternState] = useState('');
|
||||||
|
const [typeFilter, setTypeFilterState] = useState('');
|
||||||
|
|
||||||
|
const cursorRef = useRef<string>('');
|
||||||
|
const seqRef = useRef(0);
|
||||||
|
const mergedRef = useRef<Map<string, RedisKeySummary>>(new Map());
|
||||||
|
// 输入后立即回车时组件尚未重渲染,参数必须由 setter 即时同步(不能等渲染期赋值)
|
||||||
|
const argsRef = useRef({ pattern: '', typeFilter: '' });
|
||||||
|
const setPattern = useCallback((v: string) => {
|
||||||
|
argsRef.current.pattern = v;
|
||||||
|
setPatternState(v);
|
||||||
|
}, []);
|
||||||
|
const setTypeFilter = useCallback((v: string) => {
|
||||||
|
argsRef.current.typeFilter = v;
|
||||||
|
setTypeFilterState(v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const search = useCallback(() => {
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
cursorRef.current = '';
|
||||||
|
mergedRef.current = new Map();
|
||||||
|
setLoading(true);
|
||||||
|
setHasMore(false);
|
||||||
|
|
||||||
|
redisApi
|
||||||
|
.scanKeys(instanceId, {
|
||||||
|
db,
|
||||||
|
pattern: argsRef.current.pattern.trim(),
|
||||||
|
type: argsRef.current.typeFilter || undefined,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
const keys = res?.keys || [];
|
||||||
|
keys.forEach((k) => mergedRef.current.set(k.key, k));
|
||||||
|
setItems([...mergedRef.current.values()]);
|
||||||
|
setExact(res?.exact || null);
|
||||||
|
cursorRef.current = res?.next_cursor || '';
|
||||||
|
setHasMore(Boolean(res?.next_cursor));
|
||||||
|
setTotal(mergedRef.current.size);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems([]);
|
||||||
|
setExact(null);
|
||||||
|
setTotal(0);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [instanceId, db]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!cursorRef.current || loadingMore || loading) return;
|
||||||
|
const seq = seqRef.current;
|
||||||
|
setLoadingMore(true);
|
||||||
|
redisApi
|
||||||
|
.scanKeys(instanceId, {
|
||||||
|
db,
|
||||||
|
pattern: argsRef.current.pattern.trim(),
|
||||||
|
type: argsRef.current.typeFilter || undefined,
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
(res?.keys || []).forEach((k) => mergedRef.current.set(k.key, k));
|
||||||
|
setItems([...mergedRef.current.values()]);
|
||||||
|
cursorRef.current = res?.next_cursor || '';
|
||||||
|
setHasMore(Boolean(res?.next_cursor));
|
||||||
|
setTotal(mergedRef.current.size);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 拦截器已提示 */
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setLoadingMore(false);
|
||||||
|
});
|
||||||
|
}, [instanceId, db, loading, loadingMore]);
|
||||||
|
|
||||||
|
const removeKey = useCallback((keyName: string) => {
|
||||||
|
if (mergedRef.current.delete(keyName)) {
|
||||||
|
setItems([...mergedRef.current.values()]);
|
||||||
|
setTotal(mergedRef.current.size);
|
||||||
|
setExact((prev) => (prev?.key === keyName ? null : prev));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切库 / 换实例:清空并按当前条件重搜
|
||||||
|
useEffect(() => {
|
||||||
|
seqRef.current++;
|
||||||
|
cursorRef.current = '';
|
||||||
|
mergedRef.current = new Map();
|
||||||
|
setItems([]);
|
||||||
|
setExact(null);
|
||||||
|
setHasMore(false);
|
||||||
|
setTotal(0);
|
||||||
|
if (instanceId) search();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceId, db]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
exact,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
pattern,
|
||||||
|
typeFilter,
|
||||||
|
setPattern,
|
||||||
|
setTypeFilter,
|
||||||
|
search,
|
||||||
|
loadMore,
|
||||||
|
removeKey,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 库列表(集群恒 [0]);实例变化自动重拉 */
|
||||||
|
export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boolean } {
|
||||||
|
const [dbs, setDbs] = useState<number[]>([0]);
|
||||||
|
const [cluster, setCluster] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
redisApi
|
||||||
|
.getDbs(instanceId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDbs(res?.dbs?.length ? res.dbs : [0]);
|
||||||
|
setCluster(Boolean(res?.cluster));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 集群探测失败按单机 0-15 处理 */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
return { dbs, cluster };
|
||||||
|
}
|
||||||
@@ -17,8 +17,13 @@ import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePan
|
|||||||
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
|
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
|
||||||
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
|
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
|
||||||
import type {
|
import type {
|
||||||
QueryHistoryItem, QueryInstanceOption, QueryResult,
|
QueryHistoryItem,
|
||||||
RedisKeyDetail, RedisKeyInfo, SortDirection, SortState,
|
QueryInstanceOption,
|
||||||
|
QueryResult,
|
||||||
|
RedisKeyDetail,
|
||||||
|
RedisKeyInfo,
|
||||||
|
SortDirection,
|
||||||
|
SortState,
|
||||||
} from '@/features/query/types';
|
} from '@/features/query/types';
|
||||||
import api from '@/api/request';
|
import api from '@/api/request';
|
||||||
import { readStorage, writeStorage } from '@/lib/storage';
|
import { readStorage, writeStorage } from '@/lib/storage';
|
||||||
@@ -35,7 +40,12 @@ export default function QueriesPage() {
|
|||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState<QueryResult | null>(null);
|
const [result, setResult] = useState<QueryResult | null>(null);
|
||||||
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
|
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({
|
||||||
|
open: false,
|
||||||
|
field: '',
|
||||||
|
value: '',
|
||||||
|
format: 'Json',
|
||||||
|
});
|
||||||
const [redisPattern, setRedisPattern] = useState('');
|
const [redisPattern, setRedisPattern] = useState('');
|
||||||
const [redisType, setRedisType] = useState('');
|
const [redisType, setRedisType] = useState('');
|
||||||
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
||||||
@@ -66,8 +76,10 @@ export default function QueriesPage() {
|
|||||||
const saved = readStorage('queryFavorites');
|
const saved = readStorage('queryFavorites');
|
||||||
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
||||||
const initialHistory: QueryHistoryItem[] = [];
|
const initialHistory: QueryHistoryItem[] = [];
|
||||||
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
|
return initialHistory.map((h) => ({ ...h, favorite: favIds.has(h.id) }));
|
||||||
} catch { return []; }
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const [favFilter, setFavFilter] = useState(false);
|
const [favFilter, setFavFilter] = useState(false);
|
||||||
const [searchHistory, setSearchHistory] = useState('');
|
const [searchHistory, setSearchHistory] = useState('');
|
||||||
@@ -75,39 +87,74 @@ export default function QueriesPage() {
|
|||||||
|
|
||||||
/* ---- Data loading ---- */
|
/* ---- Data loading ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
instanceApi.accessible('readonly').then(res => {
|
instanceApi
|
||||||
|
.accessible('readonly')
|
||||||
|
.then((res) => {
|
||||||
setInstances(Array.isArray(res) ? res : []);
|
setInstances(Array.isArray(res) ? res : []);
|
||||||
}).catch(() => { setInstances([]); });
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setInstances([]);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; }
|
if (!instanceId) {
|
||||||
const inst = instances.find(i => i.id === Number(instanceId));
|
setDatabases([]);
|
||||||
if (inst?.db_type === 'redis') { setDatabases([]); setDatabase('0'); setEditorSchema({}); return; }
|
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 || '';
|
const type = inst?.db_type || '';
|
||||||
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
|
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
|
||||||
setLoadingDbs(true);
|
setLoadingDbs(true);
|
||||||
instanceDetailApi.listDatabases(Number(instanceId))
|
instanceDetailApi
|
||||||
.then(async data => {
|
.listDatabases(Number(instanceId))
|
||||||
|
.then(async (data) => {
|
||||||
const dbs = Array.isArray(data) ? data : [];
|
const dbs = Array.isArray(data) ? data : [];
|
||||||
setDatabases(dbs);
|
setDatabases(dbs);
|
||||||
const schema: Record<string, Record<string, string[]>> = {};
|
const schema: Record<string, Record<string, string[]>> = {};
|
||||||
await Promise.all(dbs.map(async db => {
|
await Promise.all(
|
||||||
|
dbs.map(async (db) => {
|
||||||
try {
|
try {
|
||||||
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
|
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
|
||||||
if (Array.isArray(tables)) { schema[db] = {}; for (const t of tables) { schema[db][t.name] = []; } }
|
if (Array.isArray(tables)) {
|
||||||
} catch { /* ignore */ }
|
schema[db] = {};
|
||||||
}));
|
for (const t of tables) {
|
||||||
|
schema[db][t.name] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
setEditorSchema(schema);
|
setEditorSchema(schema);
|
||||||
})
|
})
|
||||||
.catch(() => { setDatabases([]); setEditorSchema({}); })
|
.catch(() => {
|
||||||
|
setDatabases([]);
|
||||||
|
setEditorSchema({});
|
||||||
|
})
|
||||||
.finally(() => setLoadingDbs(false));
|
.finally(() => setLoadingDbs(false));
|
||||||
} else { setDatabases([]); setEditorSchema({}); }
|
} else {
|
||||||
|
setDatabases([]);
|
||||||
|
setEditorSchema({});
|
||||||
|
}
|
||||||
}, [instanceId, instances]);
|
}, [instanceId, instances]);
|
||||||
|
|
||||||
const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]);
|
const selectedInstance = useMemo(
|
||||||
|
() => instances.find((inst) => String(inst.id) === instanceId),
|
||||||
|
[instances, instanceId],
|
||||||
|
);
|
||||||
const dbType = normalizeDbType(selectedInstance?.db_type);
|
const dbType = normalizeDbType(selectedInstance?.db_type);
|
||||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
const editorLang =
|
||||||
|
dbType === 'redis' ? ('redis' as const) : dbType === 'mongodb' ? ('mongodb' as const) : ('sql' as const);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRedisKeys([]);
|
setRedisKeys([]);
|
||||||
@@ -125,11 +172,10 @@ export default function QueriesPage() {
|
|||||||
}
|
}
|
||||||
setRedisLoading(true);
|
setRedisLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await redisApi.getKeys(Number(instanceId), {
|
const res = await redisApi.scanKeys(Number(instanceId), {
|
||||||
db: Number(database || 0),
|
db: Number(database || 0),
|
||||||
pattern,
|
pattern,
|
||||||
type: redisType || undefined,
|
type: redisType || undefined,
|
||||||
count: 80,
|
|
||||||
});
|
});
|
||||||
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
|
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -139,7 +185,8 @@ export default function QueriesPage() {
|
|||||||
}
|
}
|
||||||
}, [instanceId, database, redisPattern, redisType]);
|
}, [instanceId, database, redisPattern, redisType]);
|
||||||
|
|
||||||
const loadRedisKeyDetail = useCallback(async (keyName: string) => {
|
const loadRedisKeyDetail = useCallback(
|
||||||
|
async (keyName: string) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setRedisDetailLoading(true);
|
setRedisDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -151,51 +198,94 @@ export default function QueriesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRedisDetailLoading(false);
|
setRedisDetailLoading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, database]);
|
},
|
||||||
|
[instanceId, database],
|
||||||
|
);
|
||||||
|
|
||||||
const copyRedisText = useCallback((text: string) => {
|
const copyRedisText = useCallback((text: string) => {
|
||||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
|
const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
|
||||||
setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' });
|
setRedisLineViewer({
|
||||||
|
open: true,
|
||||||
|
field,
|
||||||
|
value: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
||||||
|
format: 'Json',
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
||||||
if (/^\d+$/.test(database)) return;
|
if (/^\d+$/.test(database)) return;
|
||||||
instanceDetailApi.getDbSchema(Number(instanceId), database)
|
instanceDetailApi
|
||||||
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); })
|
.getDbSchema(Number(instanceId), database)
|
||||||
.catch(() => { /* ignore */ });
|
.then((data) => {
|
||||||
|
if (data && typeof data === 'object')
|
||||||
|
setEditorSchema((prev) => ({ ...prev, [database]: data as Record<string, string[]> }));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
}, [instanceId, database, dbType]);
|
}, [instanceId, database, dbType]);
|
||||||
|
|
||||||
/* ---- Templates ---- */
|
/* ---- Templates ---- */
|
||||||
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
||||||
const templateCategories = useMemo(() => { const cats = new Set(templates.map(t => t.category)); return ['all', ...Array.from(cats)]; }, [templates]);
|
const templateCategories = useMemo(() => {
|
||||||
|
const cats = new Set(templates.map((t) => t.category));
|
||||||
|
return ['all', ...Array.from(cats)];
|
||||||
|
}, [templates]);
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
let r = templates;
|
let r = templates;
|
||||||
if (templateCategory !== 'all') r = r.filter(t => t.category === templateCategory);
|
if (templateCategory !== 'all') r = r.filter((t) => t.category === templateCategory);
|
||||||
if (templateSearch) { const kw = templateSearch.toLowerCase(); r = r.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); }
|
if (templateSearch) {
|
||||||
|
const kw = templateSearch.toLowerCase();
|
||||||
|
r = r.filter((t) => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
return r;
|
return r;
|
||||||
}, [templates, templateCategory, templateSearch]);
|
}, [templates, templateCategory, templateSearch]);
|
||||||
|
|
||||||
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => {
|
const handleApplyTemplate = useCallback(
|
||||||
|
(tpl: QueryTemplate) => {
|
||||||
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
|
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
|
||||||
setShowTemplates(false);
|
setShowTemplates(false);
|
||||||
}, [dbType, database]);
|
},
|
||||||
|
[dbType, database],
|
||||||
|
);
|
||||||
|
|
||||||
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
|
const catLabel: Record<string, string> = {
|
||||||
|
all: '全部',
|
||||||
|
表结构: '表结构',
|
||||||
|
性能分析: '性能分析',
|
||||||
|
数据统计: '数据统计',
|
||||||
|
基础查询: '基础查询',
|
||||||
|
};
|
||||||
|
|
||||||
/* ---- Execute ---- */
|
/* ---- Execute ---- */
|
||||||
const handleExecute = useCallback(async (pageArg: number | unknown = 1) => {
|
const handleExecute = useCallback(
|
||||||
|
async (pageArg: number | unknown = 1) => {
|
||||||
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
|
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
|
||||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
if (!sql || !instanceId) {
|
||||||
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; }
|
setError('请选择实例并输入查询语句');
|
||||||
setExecuting(true); setError(''); setCurrentPage(page);
|
return;
|
||||||
|
}
|
||||||
|
if (/\{(?:table|db|collection)\}/.test(sql)) {
|
||||||
|
setError('SQL 中含有未替换的占位符,请先替换或删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExecuting(true);
|
||||||
|
setError('');
|
||||||
|
setCurrentPage(page);
|
||||||
if (page === 1) setResult(null);
|
if (page === 1) setResult(null);
|
||||||
try {
|
try {
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, limit: pageSize, offset }) as unknown as QueryResult;
|
const res = (await queryApi.execute({
|
||||||
|
instance_id: Number(instanceId),
|
||||||
|
database: database || undefined,
|
||||||
|
sql,
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
})) as unknown as QueryResult;
|
||||||
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
||||||
if (normalized.error) {
|
if (normalized.error) {
|
||||||
setError(normalized.error);
|
setError(normalized.error);
|
||||||
@@ -204,35 +294,80 @@ export default function QueriesPage() {
|
|||||||
setResult(normalized);
|
setResult(normalized);
|
||||||
}
|
}
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
setHistory((prev) => [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
sql,
|
||||||
|
instance_id: Number(instanceId),
|
||||||
|
database,
|
||||||
|
db_type: dbType,
|
||||||
|
status: 'success',
|
||||||
|
execution_time_ms: normalized.execution_time_ms,
|
||||||
|
result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail;
|
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data
|
||||||
|
?.detail;
|
||||||
const msg = Array.isArray(raw)
|
const msg = Array.isArray(raw)
|
||||||
? raw.map(v => v.msg).join('; ')
|
? raw.map((v) => v.msg).join('; ')
|
||||||
: (typeof raw === 'string' ? raw : raw ? JSON.stringify(raw) : '查询失败');
|
: typeof raw === 'string'
|
||||||
|
? raw
|
||||||
|
: raw
|
||||||
|
? JSON.stringify(raw)
|
||||||
|
: '查询失败';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
setHistory((prev) => [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
sql,
|
||||||
|
instance_id: Number(instanceId),
|
||||||
|
database,
|
||||||
|
db_type: dbType,
|
||||||
|
status: 'failed',
|
||||||
|
execution_time_ms: 0,
|
||||||
|
result_preview: msg,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} finally { setExecuting(false); }
|
} finally {
|
||||||
}, [sql, instanceId, database, dbType, pageSize]);
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sql, instanceId, database, dbType, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---- Result Sorting ---- */
|
/* ---- Result Sorting ---- */
|
||||||
const sortedResult = useMemo(() => {
|
const sortedResult = useMemo(() => {
|
||||||
if (!result || !sortState.column || !sortState.direction) return result;
|
if (!result || !sortState.column || !sortState.direction) return result;
|
||||||
if (!Array.isArray(result.rows)) return result;
|
if (!Array.isArray(result.rows)) return result;
|
||||||
const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1;
|
const col = sortState.column;
|
||||||
return { ...result, rows: [...result.rows].sort((a, b) => {
|
const dir = sortState.direction === 'asc' ? 1 : -1;
|
||||||
const av = a[col] ?? ''; const bv = b[col] ?? '';
|
return {
|
||||||
|
...result,
|
||||||
|
rows: [...result.rows].sort((a, b) => {
|
||||||
|
const av = a[col] ?? '';
|
||||||
|
const bv = b[col] ?? '';
|
||||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||||
return String(av).localeCompare(String(bv)) * dir;
|
return String(av).localeCompare(String(bv)) * dir;
|
||||||
})};
|
}),
|
||||||
|
};
|
||||||
}, [result, sortState]);
|
}, [result, sortState]);
|
||||||
|
|
||||||
const handleSort = useCallback((col: string) => {
|
const handleSort = useCallback((col: string) => {
|
||||||
setSortState(prev => {
|
setSortState((prev) => {
|
||||||
if (prev.column === col) { if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection }; if (prev.direction === 'desc') return { column: null, direction: null }; }
|
if (prev.column === col) {
|
||||||
|
if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection };
|
||||||
|
if (prev.direction === 'desc') return { column: null, direction: null };
|
||||||
|
}
|
||||||
return { column: col, direction: 'asc' as SortDirection };
|
return { column: col, direction: 'asc' as SortDirection };
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -240,11 +375,15 @@ export default function QueriesPage() {
|
|||||||
const handleExportResult = useCallback(async () => {
|
const handleExportResult = useCallback(async () => {
|
||||||
if (!sql || !instanceId) return;
|
if (!sql || !instanceId) return;
|
||||||
try {
|
try {
|
||||||
const blob = await api.post<unknown, Blob>('/query/export', {
|
const blob = await api.post<unknown, Blob>(
|
||||||
|
'/query/export',
|
||||||
|
{
|
||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
database: database || undefined,
|
database: database || undefined,
|
||||||
sql,
|
sql,
|
||||||
}, { responseType: 'blob' });
|
},
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -257,28 +396,41 @@ export default function QueriesPage() {
|
|||||||
}, [sql, instanceId, database]);
|
}, [sql, instanceId, database]);
|
||||||
|
|
||||||
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
|
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
|
||||||
const handlePageSizeChange = useCallback((size: number) => {
|
const handlePageSizeChange = useCallback(
|
||||||
|
(size: number) => {
|
||||||
setPageSize(size);
|
setPageSize(size);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
handleExecute(1);
|
handleExecute(1);
|
||||||
}, [handleExecute]);
|
},
|
||||||
|
[handleExecute],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---- History ---- */
|
/* ---- History ---- */
|
||||||
const toggleFavorite = useCallback((id: number) => {
|
const toggleFavorite = useCallback((id: number) => {
|
||||||
setHistory(prev => {
|
setHistory((prev) => {
|
||||||
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
|
const next = prev.map((h) => (h.id === id ? { ...h, favorite: !h.favorite } : h));
|
||||||
try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ }
|
try {
|
||||||
|
writeStorage('queryFavorites', JSON.stringify(next.filter((h) => h.favorite).map((h) => h.id)));
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClearHistory = useCallback(() => {
|
const handleClearHistory = useCallback(() => {
|
||||||
if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; }
|
if (!clearConfirm) {
|
||||||
setHistory([]); setClearConfirm(false);
|
setClearConfirm(true);
|
||||||
|
setTimeout(() => setClearConfirm(false), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHistory([]);
|
||||||
|
setClearConfirm(false);
|
||||||
}, [clearConfirm]);
|
}, [clearConfirm]);
|
||||||
|
|
||||||
// -- Sidebar 拖拽 --
|
// -- Sidebar 拖拽 --
|
||||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startWidth = sideWidth;
|
const startWidth = sideWidth;
|
||||||
@@ -290,32 +442,36 @@ export default function QueriesPage() {
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
setSideWidth(w => {
|
setSideWidth((w) => {
|
||||||
writeStorage('querySideWidth', String(w));
|
writeStorage('querySideWidth', String(w));
|
||||||
return w;
|
return w;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
}, [sideWidth]);
|
},
|
||||||
|
[sideWidth],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredHistory = useMemo(() => {
|
const filteredHistory = useMemo(() => {
|
||||||
let r = history;
|
let r = history;
|
||||||
if (favFilter) r = r.filter(h => h.favorite);
|
if (favFilter) r = r.filter((h) => h.favorite);
|
||||||
if (searchHistory) { const kw = searchHistory.toLowerCase(); r = r.filter(h => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw)); }
|
if (searchHistory) {
|
||||||
|
const kw = searchHistory.toLowerCase();
|
||||||
|
r = r.filter((h) => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
return r;
|
return r;
|
||||||
}, [history, favFilter, searchHistory]);
|
}, [history, favFilter, searchHistory]);
|
||||||
|
|
||||||
const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []);
|
const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
|
||||||
const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []);
|
const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
|
||||||
const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []);
|
const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
|
||||||
const handleToggleResult = useCallback(() => setShowResult(v => !v), []);
|
const handleToggleResult = useCallback(() => setShowResult((v) => !v), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||||
{/* ====== 主区域 ====== */}
|
{/* ====== 主区域 ====== */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||||
|
|
||||||
{/* -- 顶栏: 实例+库选择 -- */}
|
{/* -- 顶栏: 实例+库选择 -- */}
|
||||||
<QueryTopBar
|
<QueryTopBar
|
||||||
instances={instances}
|
instances={instances}
|
||||||
@@ -357,18 +513,25 @@ export default function QueriesPage() {
|
|||||||
{/* Schema 侧边栏(左侧,独立) */}
|
{/* Schema 侧边栏(左侧,独立) */}
|
||||||
{showSidebar && instanceId && (
|
{showSidebar && instanceId && (
|
||||||
<>
|
<>
|
||||||
<div className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
<div
|
||||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto"
|
||||||
|
style={{ width: sideWidth + 'px' }}
|
||||||
|
>
|
||||||
|
<SchemaSidebar
|
||||||
|
instanceId={instanceId}
|
||||||
|
database={database}
|
||||||
|
dbType={dbType}
|
||||||
clusterMode={selectedInstance?.cluster_mode}
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
onInsert={(text) => {
|
onInsert={(text) => {
|
||||||
if (dbType === 'redis') {
|
if (dbType === 'redis') {
|
||||||
setSql(text);
|
setSql(text);
|
||||||
} else {
|
} else {
|
||||||
setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDatabaseChange={setDatabase}
|
onDatabaseChange={setDatabase}
|
||||||
singleDb={!!database} />
|
singleDb={!!database}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={sidebarDragRef}
|
ref={sidebarDragRef}
|
||||||
@@ -416,7 +579,8 @@ export default function QueriesPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="shrink-0 px-4 pb-2">
|
<div className="shrink-0 px-4 pb-2">
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
{error}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -448,9 +612,12 @@ export default function QueriesPage() {
|
|||||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>{/* end 编辑器+结果区 */}
|
</div>
|
||||||
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
|
{/* end 编辑器+结果区 */}
|
||||||
</div>{/* end 主区域 */}
|
</div>
|
||||||
|
{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||||
|
</div>
|
||||||
|
{/* end 主区域 */}
|
||||||
|
|
||||||
{showHistory && (
|
{showHistory && (
|
||||||
<QueryHistoryDrawer
|
<QueryHistoryDrawer
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+17
-12
@@ -1,16 +1,20 @@
|
|||||||
import { execSync } from "child_process"
|
import { execSync } from 'child_process';
|
||||||
import fs from "fs"
|
import fs from 'fs';
|
||||||
import path from "path"
|
import path from 'path';
|
||||||
import react from "@vitejs/plugin-react"
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
// 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命)
|
// 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命)
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "package.json"), "utf-8"))
|
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||||
const gitHash = (() => {
|
const gitHash = (() => {
|
||||||
try { return execSync("git rev-parse --short HEAD").toString().trim() } catch { return "unknown" }
|
try {
|
||||||
})()
|
return execSync('git rev-parse --short HEAD').toString().trim();
|
||||||
const buildTime = new Date().toISOString()
|
} catch {
|
||||||
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`
|
return 'unknown';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const buildTime = new Date().toISOString();
|
||||||
|
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/',
|
base: '/',
|
||||||
@@ -35,14 +39,15 @@ export default defineConfig({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:9000',
|
// 本地联调可用 API_PROXY_TARGET=http://localhost:9001 npm run dev 指向测试实例
|
||||||
|
target: process.env.API_PROXY_TARGET || 'http://localhost:9000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user