在准备环境前提交次全部更改。

This commit is contained in:
Neo
2026-02-19 08:35:13 +08:00
parent ded6dfb9d8
commit 4eac07da47
1387 changed files with 6107191 additions and 33002 deletions

View 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;