在准备环境前提交次全部更改。
This commit is contained in:
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 数据库查看器页面。
|
||||
*
|
||||
* - 左侧: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;
|
||||
Reference in New Issue
Block a user