Files
Neo-ZQYY/apps/admin-web/src/pages/DBViewer.tsx

236 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 数据库查看器页面。
*
* - 左侧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;