236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
/**
|
||
* 数据库查看器页面。
|
||
*
|
||
* - 左侧:Schema → Table 层级树,异步加载
|
||
* - 右侧上方:SQL 编辑器 + 执行按钮
|
||
* - 右侧下方:列定义 / 查询结果 Table
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import { Tree, Input, Button, Table, Space, message, Spin, Tag, Card, Typography, Tooltip } from 'antd';
|
||
import {
|
||
PlayCircleOutlined, ReloadOutlined, TableOutlined,
|
||
DatabaseOutlined, CopyOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { DataNode, EventDataNode } from 'antd/es/tree';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
fetchSchemas, fetchTables, fetchColumns, executeQuery,
|
||
type ColumnInfo, type QueryResult,
|
||
} from '../api/dbViewer';
|
||
|
||
const { TextArea } = Input;
|
||
const { Title, Text } = Typography;
|
||
|
||
const schemaKey = (schema: string) => `s::${schema}`;
|
||
const tableKey = (schema: string, table: string) => `t::${schema}::${table}`;
|
||
|
||
function parseTableKey(key: string): { schema: string; table: string } | null {
|
||
if (!key.startsWith('t::')) return null;
|
||
const parts = key.slice(3).split('::');
|
||
if (parts.length !== 2) return null;
|
||
return { schema: parts[0], table: parts[1] };
|
||
}
|
||
|
||
const DBViewer: React.FC = () => {
|
||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||
const [loadingTree, setLoadingTree] = useState(false);
|
||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||
const [selectedTable, setSelectedTable] = useState<{ schema: string; table: string } | null>(null);
|
||
const [columnData, setColumnData] = useState<ColumnInfo[]>([]);
|
||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||
const [sql, setSql] = useState('');
|
||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||
const [loadingQuery, setLoadingQuery] = useState(false);
|
||
|
||
const loadSchemas = useCallback(async () => {
|
||
setLoadingTree(true);
|
||
try {
|
||
const schemas = await fetchSchemas();
|
||
setTreeData(
|
||
schemas.map((s) => ({
|
||
title: s, key: schemaKey(s), icon: <DatabaseOutlined />, isLeaf: false,
|
||
})),
|
||
);
|
||
} catch { message.error('加载 Schema 列表失败'); }
|
||
finally { setLoadingTree(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { loadSchemas(); }, [loadSchemas]);
|
||
|
||
const onLoadData = async (node: EventDataNode<DataNode>) => {
|
||
const key = node.key as string;
|
||
if (!key.startsWith('s::')) return;
|
||
if (node.children && node.children.length > 0) return;
|
||
const schema = key.slice(3);
|
||
try {
|
||
const tables = await fetchTables(schema);
|
||
const children: DataNode[] = tables.map((t) => ({
|
||
title: (
|
||
<Space size={4}>
|
||
<span>{t.name}</span>
|
||
<Text type="secondary" style={{ fontSize: 11 }}>({t.row_count.toLocaleString()})</Text>
|
||
</Space>
|
||
),
|
||
key: tableKey(schema, t.name), icon: <TableOutlined />, isLeaf: true,
|
||
}));
|
||
setTreeData((prev) => prev.map((n) => n.key === key ? { ...n, children } : n));
|
||
} catch { message.error(`加载 ${schema} 的表列表失败`); }
|
||
};
|
||
|
||
const onSelectNode = async (_: React.Key[], info: { node: DataNode }) => {
|
||
const key = info.node.key as string;
|
||
const parsed = parseTableKey(key);
|
||
if (!parsed) return;
|
||
setSelectedTable(parsed);
|
||
setLoadingColumns(true);
|
||
setQueryResult(null);
|
||
try {
|
||
const cols = await fetchColumns(parsed.schema, parsed.table);
|
||
setColumnData(cols);
|
||
setSql(`SELECT * FROM ${parsed.schema}.${parsed.table} LIMIT 100;`);
|
||
} catch { message.error('加载列定义失败'); setColumnData([]); }
|
||
finally { setLoadingColumns(false); }
|
||
};
|
||
|
||
const handleExecute = async () => {
|
||
const trimmed = sql.trim();
|
||
if (!trimmed) { message.warning('请输入 SQL 语句'); return; }
|
||
setLoadingQuery(true);
|
||
try {
|
||
const result = await executeQuery(trimmed);
|
||
setQueryResult(result);
|
||
} catch (err: unknown) {
|
||
const axiosErr = err as { response?: { data?: { detail?: string } } };
|
||
const msg = axiosErr.response?.data?.detail ?? (err instanceof Error ? err.message : '查询执行失败');
|
||
message.error(msg);
|
||
setQueryResult(null);
|
||
} finally { setLoadingQuery(false); }
|
||
};
|
||
|
||
const handleCopySql = () => {
|
||
navigator.clipboard.writeText(sql).then(() => message.success('已复制'));
|
||
};
|
||
|
||
const columnDefColumns: ColumnsType<ColumnInfo> = [
|
||
{ title: '列名', dataIndex: 'name', key: 'name', render: (v: string) => <code>{v}</code> },
|
||
{ title: '数据类型', dataIndex: 'data_type', key: 'data_type' },
|
||
{
|
||
title: '可空', dataIndex: 'is_nullable', key: 'is_nullable', width: 70, align: 'center',
|
||
render: (v: boolean) => v ? <Tag color="orange">YES</Tag> : <Tag color="blue">NO</Tag>,
|
||
},
|
||
{
|
||
title: '默认值', dataIndex: 'default_value', key: 'default_value',
|
||
render: (v: string | null) => v != null ? <code style={{ fontSize: 12 }}>{v}</code> : <Text type="secondary">—</Text>,
|
||
},
|
||
];
|
||
|
||
const resultColumns: ColumnsType<Record<string, unknown>> = queryResult
|
||
? queryResult.columns.map((col, idx) => ({
|
||
title: col, dataIndex: String(idx), key: col, ellipsis: true,
|
||
render: (v: unknown) => {
|
||
if (v === null || v === undefined) return <Text type="secondary">NULL</Text>;
|
||
return String(v);
|
||
},
|
||
}))
|
||
: [];
|
||
|
||
const resultDataSource: Record<string, unknown>[] = queryResult
|
||
? queryResult.rows.map((row, rowIdx) => {
|
||
const obj: Record<string, unknown> = { _key: rowIdx };
|
||
row.forEach((cell, colIdx) => { obj[String(colIdx)] = cell; });
|
||
return obj;
|
||
})
|
||
: [];
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Title level={4} style={{ margin: 0 }}>
|
||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||
数据库查看器
|
||
</Title>
|
||
{selectedTable && (
|
||
<Tag color="blue">{selectedTable.schema}.{selectedTable.table}</Tag>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flex: 1, gap: 12, minHeight: 0 }}>
|
||
{/* 左侧树 */}
|
||
<Card
|
||
size="small"
|
||
title="Schema / 表"
|
||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={loadSchemas} loading={loadingTree} />}
|
||
style={{ width: 260, minWidth: 260, display: 'flex', flexDirection: 'column' }}
|
||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||
>
|
||
<Spin spinning={loadingTree}>
|
||
<Tree
|
||
showIcon treeData={treeData} loadData={onLoadData}
|
||
expandedKeys={expandedKeys}
|
||
onExpand={(keys) => setExpandedKeys(keys)}
|
||
onSelect={onSelectNode}
|
||
/>
|
||
</Spin>
|
||
</Card>
|
||
|
||
{/* 右侧 */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||
{/* SQL 编辑器 */}
|
||
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 12px' } }}>
|
||
<TextArea
|
||
rows={5} value={sql} onChange={(e) => setSql(e.target.value)}
|
||
placeholder="输入 SQL 查询语句…"
|
||
style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", fontSize: 13, marginBottom: 8 }}
|
||
onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); handleExecute(); } }}
|
||
/>
|
||
<Space>
|
||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleExecute} loading={loadingQuery}>
|
||
执行 <Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>(Ctrl+Enter)</Text>
|
||
</Button>
|
||
<Tooltip title="复制 SQL">
|
||
<Button icon={<CopyOutlined />} onClick={handleCopySql} />
|
||
</Tooltip>
|
||
</Space>
|
||
</Card>
|
||
|
||
{/* 结果区域 */}
|
||
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||
>
|
||
{queryResult ? (
|
||
<>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Text type="secondary">查询返回 {queryResult.row_count} 行</Text>
|
||
</div>
|
||
<Table<Record<string, unknown>>
|
||
rowKey="_key" columns={resultColumns} dataSource={resultDataSource}
|
||
pagination={{ pageSize: 50, showSizeChanger: false, showTotal: (t) => `共 ${t} 行` }}
|
||
size="small" scroll={{ x: 'max-content' }} bordered
|
||
/>
|
||
</>
|
||
) : selectedTable ? (
|
||
<>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Text strong>{selectedTable.schema}.{selectedTable.table}</Text>
|
||
<Text type="secondary" style={{ marginLeft: 8 }}>列定义</Text>
|
||
</div>
|
||
<Table<ColumnInfo>
|
||
rowKey="name" columns={columnDefColumns} dataSource={columnData}
|
||
loading={loadingColumns} pagination={false} size="small" bordered
|
||
/>
|
||
</>
|
||
) : (
|
||
<div style={{ color: '#bbb', textAlign: 'center', marginTop: 60 }}>
|
||
在左侧选择一张表查看列定义,或输入 SQL 执行查询
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DBViewer;
|