707 lines
18 KiB
Markdown
707 lines
18 KiB
Markdown
# 表结构查看功能实现说明
|
||
|
||
## 功能概述
|
||
|
||
表结构查看功能已完成,用户可以查看 MySQL 表、MongoDB 集合、Redis Key 的详细结构和信息。
|
||
|
||
## 实现内容
|
||
|
||
### 1. 后端实现(Go)
|
||
|
||
#### MySQL 表结构查询
|
||
**文件**: `go-desk/internal/dbclient/mysql.go`
|
||
|
||
```go
|
||
// GetTableStructure 获取表结构
|
||
func (c *MySQLClient) GetTableStructure(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||
var columns []map[string]interface{}
|
||
|
||
query := "DESCRIBE "
|
||
if database != "" {
|
||
query += fmt.Sprintf("`%s`.", database)
|
||
}
|
||
query += fmt.Sprintf("`%s`", tableName)
|
||
|
||
err := c.db.Raw(query).Scan(&columns).Error
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取表结构失败: %v", err)
|
||
}
|
||
|
||
// 转换为统一格式
|
||
for _, col := range columns {
|
||
// 确保字段存在
|
||
if _, ok := col["Field"]; !ok {
|
||
col["Field"] = ""
|
||
}
|
||
if _, ok := col["Type"]; !ok {
|
||
col["Type"] = ""
|
||
}
|
||
if _, ok := col["Null"]; !ok {
|
||
col["Null"] = "NO"
|
||
}
|
||
if _, ok := col["Key"]; !ok {
|
||
col["Key"] = ""
|
||
}
|
||
if _, ok := col["Default"]; !ok {
|
||
col["Default"] = nil
|
||
}
|
||
if _, ok := col["Extra"]; !ok {
|
||
col["Extra"] = ""
|
||
}
|
||
}
|
||
|
||
return columns, nil
|
||
}
|
||
|
||
// GetIndexes 获取索引列表
|
||
func (c *MySQLClient) GetIndexes(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||
var indexes []map[string]interface{}
|
||
|
||
query := "SHOW INDEX FROM "
|
||
if database != "" {
|
||
query += fmt.Sprintf("`%s`.", database)
|
||
}
|
||
query += fmt.Sprintf("`%s`", tableName)
|
||
|
||
err := c.db.Raw(query).Scan(&indexes).Error
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取索引列表失败: %v", err)
|
||
}
|
||
|
||
return indexes, nil
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
- `Field`: 字段名
|
||
- `Type`: 字段类型(int, varchar, text, datetime, etc.)
|
||
- `Null`: 是否允许 NULL
|
||
- `Key`: 是否主键
|
||
- `Default`: 默认值
|
||
- `Extra`: 额外信息
|
||
|
||
#### MongoDB 集合结构查询
|
||
**文件**: `go-desk/internal/dbclient/mongo.go`
|
||
|
||
```go
|
||
// GetCollectionStructure 获取集合结构
|
||
func (c *MongoClient) GetCollectionStructure(ctx context.Context, database, collectionName string) (map[string]interface{}, error) {
|
||
coll := c.client.Database(database).Collection(collectionName)
|
||
|
||
result := map[string]interface{}{
|
||
"database": database,
|
||
"collection": collectionName,
|
||
"sampleDocs": []map[string]interface{}{},
|
||
"fieldStats": map[string]int{},
|
||
}
|
||
|
||
// 获取文档示例(最多 5 个)
|
||
cursor, err := coll.Find(ctx, bson.M{}).Limit(5)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取文档示例失败: %v", err)
|
||
}
|
||
defer cursor.Close(ctx)
|
||
|
||
var docs []bson.M
|
||
if err = cursor.All(ctx, &docs); err != nil {
|
||
return nil, fmt.Errorf("解析文档失败: %v", err)
|
||
}
|
||
|
||
// 转换为 map
|
||
for _, doc := range docs {
|
||
docMap := make(map[string]interface{})
|
||
for k, v := range doc {
|
||
docMap[k] = v
|
||
}
|
||
result["sampleDocs"] = append(result["sampleDocs"].([]map[string]interface{}), docMap)
|
||
}
|
||
|
||
// 字段统计
|
||
fieldCount := make(map[string]int)
|
||
for _, doc := range docs {
|
||
for key := range doc {
|
||
fieldCount[key]++
|
||
}
|
||
}
|
||
result["fieldStats"] = fieldCount
|
||
|
||
// 文档总数
|
||
count, err := coll.CountDocuments(ctx, bson.M{})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取文档数量失败: %v", err)
|
||
}
|
||
result["documentCount"] = count
|
||
|
||
// 索引信息
|
||
cursor, err = coll.Indexes().ListSpecifications(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取索引信息失败: %v", err)
|
||
} else {
|
||
var indexes []map[string]interface{}
|
||
for cursor.Next(ctx) {
|
||
spec := cursor.Current
|
||
indexes = append(indexes, map[string]interface{}{
|
||
"name": spec.Name,
|
||
"unique": spec.Unique,
|
||
"keys": spec.Keys,
|
||
})
|
||
}
|
||
cursor.Close(ctx)
|
||
result["indexes"] = indexes
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// CountDocuments 获取文档数量
|
||
func (c *MongoClient) CountDocuments(ctx context.Context, database, collectionName string) (int64, error) {
|
||
coll := c.client.Database(database).Collection(collectionName)
|
||
count, err := coll.CountDocuments(ctx, bson.M{})
|
||
return count, err
|
||
}
|
||
```
|
||
|
||
**返回数据**:
|
||
- `database`: 数据库名
|
||
- `collection`: 集合名
|
||
- `sampleDocs`: 文档示例(最多 5 个)
|
||
- `fieldStats`: 字段统计
|
||
- `documentCount`: 文档总数
|
||
- `indexes`: 索引列表
|
||
|
||
#### Redis Key 详细信息
|
||
**文件**: `go-desk/internal/dbclient/redis.go`
|
||
|
||
```go
|
||
// GetKeyInfo 获取 Key 详细信息
|
||
func (c *RedisClient) GetKeyInfo(ctx context.Context, key string) (map[string]interface{}, error) {
|
||
info := map[string]interface{}{
|
||
"key": key,
|
||
"type": "",
|
||
"value": nil,
|
||
"ttl": 0,
|
||
"length": 0,
|
||
}
|
||
|
||
// 获取 Key 类型
|
||
keyType, err := c.GetKeyType(ctx, key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 Key 类型失败: %v", err)
|
||
}
|
||
info["type"] = keyType
|
||
|
||
// 获取 TTL
|
||
ttl, err := c.GetTTL(ctx, key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 TTL 失败: %v", err)
|
||
}
|
||
info["ttl"] = ttl.Seconds()
|
||
|
||
// 获取 Key 值(限制大小,避免过大)
|
||
value, err := c.GetKeyValue(ctx, key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 Key 值失败: %v", err)
|
||
}
|
||
info["value"] = formatValuePreview(value)
|
||
|
||
// 获取 Key 长度(使用 STRLEN、HLEN、SCARD、ZCARD)
|
||
var keyLength int64
|
||
switch keyType {
|
||
case "string":
|
||
keyLength, err = c.client.StrLen(ctx, key).Result()
|
||
case "list":
|
||
keyLength, err = c.client.LLen(ctx, key).Result()
|
||
case "set":
|
||
keyLength, err = c.client.SCard(ctx, key).Result()
|
||
case "zset":
|
||
keyLength, err = c.client.ZCard(ctx, key).Result()
|
||
case "hash":
|
||
keyLength, err = c.client.HLen(ctx, key).Result()
|
||
}
|
||
if err == nil {
|
||
info["length"] = keyLength
|
||
}
|
||
|
||
return info, nil
|
||
}
|
||
|
||
// formatValuePreview 格式化值预览(限制长度)
|
||
func formatValuePreview(value interface{}) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
|
||
const maxPreviewLength = 200
|
||
valueStr := fmt.Sprintf("%v", value)
|
||
if len(valueStr) > maxPreviewLength {
|
||
valueStr = valueStr[:maxPreviewLength] + "..."
|
||
}
|
||
|
||
return valueStr
|
||
}
|
||
```
|
||
|
||
**返回数据**:
|
||
- `key`: Key 名称
|
||
- `type`: 数据类型(string, list, set, zset, hash)
|
||
- `value`: 值预览(最多 200 字符)
|
||
- `ttl`: 过期时间(秒)
|
||
- `length`: 数据长度(string 为字符数,list/set/zset/hash 为元素数)
|
||
|
||
#### 应用层 API
|
||
**文件**: `go-desk/app.go`
|
||
|
||
```go
|
||
// GetTableStructure 获取表结构
|
||
func (a *App) GetTableStructure(connectionId uint, database, tableName string) (map[string]interface{}, error) {
|
||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||
defer cancel()
|
||
pool := dbclient.GetPool()
|
||
|
||
// 获取连接配置
|
||
conn, err := storage.GetConnection(connectionId)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||
}
|
||
|
||
// 根据数据库类型调用对应客户端
|
||
switch conn.Type {
|
||
case "mysql":
|
||
client, err := pool.GetMySQLClient(conn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||
}
|
||
structure, err := client.GetTableStructure(ctx, database, tableName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{
|
||
"type": "mysql",
|
||
"database": database,
|
||
"table": tableName,
|
||
"columns": structure,
|
||
}, nil
|
||
|
||
case "mongo":
|
||
client, err := pool.GetMongoClient(conn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||
}
|
||
structure, err := client.GetCollectionStructure(ctx, database, tableName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{
|
||
"type": "mongo",
|
||
"database": database,
|
||
"collection": tableName,
|
||
"structure": structure,
|
||
}, nil
|
||
|
||
case "redis":
|
||
client, err := pool.GetRedisClient(conn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||
}
|
||
info, err := client.GetKeyInfo(ctx, tableName) // tableName 作为 key 名
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{
|
||
"type": "redis",
|
||
"key": tableName,
|
||
"info": info,
|
||
}, nil
|
||
|
||
default:
|
||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||
}
|
||
}
|
||
|
||
// GetIndexes 获取索引列表
|
||
func (a *App) GetIndexes(connectionId uint, database, tableName string) ([]map[string]interface{}, error) {
|
||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||
defer cancel()
|
||
pool := dbclient.GetPool()
|
||
|
||
// 获取连接配置
|
||
conn, err := storage.GetConnection(connectionId)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||
}
|
||
|
||
// 目前只支持 MySQL
|
||
if conn.Type != "mysql" {
|
||
return nil, fmt.Errorf("当前只支持 MySQL 的索引查询")
|
||
}
|
||
|
||
client, err := pool.GetMySQLClient(conn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||
}
|
||
|
||
indexes, err := client.GetIndexes(ctx, database, tableName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return indexes, nil
|
||
}
|
||
```
|
||
|
||
### 2. 前端实现(Vue)
|
||
|
||
#### 表结构展示组件
|
||
**文件**: `go-desk/frontend/src/views/db-cli/components/TableStructure.vue`
|
||
|
||
```vue
|
||
<template>
|
||
<a-modal
|
||
v-model:visible="visible"
|
||
:title="title"
|
||
width="900px"
|
||
:footer="false"
|
||
@cancel="handleClose"
|
||
>
|
||
<a-tabs v-model:active-tab>
|
||
<!-- MySQL 表结构 -->
|
||
<a-tab-pane key="mysql" title="表结构">
|
||
<a-table
|
||
:data="mysqlColumns"
|
||
:columns="mysqlColumnDefs"
|
||
:pagination="false"
|
||
size="small"
|
||
:bordered="{cell:true}"
|
||
>
|
||
<template #columns>
|
||
<a-table-column title="字段名" data-index="Field" width="150"/>
|
||
<a-table-column title="类型" data-index="Type" width="120"/>
|
||
<a-table-column title="是否NULL" data-index="Null" width="80"/>
|
||
<a-table-column title="主键" data-index="Key" width="80"/>
|
||
<a-table-column title="默认值" data-index="Default" width="120"/>
|
||
<a-table-column title="额外信息" data-index="Extra" width="200"/>
|
||
</template>
|
||
</a-table>
|
||
|
||
<a-divider>索引信息</a-divider>
|
||
|
||
<a-table
|
||
:data="indexes"
|
||
:columns="indexColumnDefs"
|
||
:pagination="false"
|
||
size="small"
|
||
:bordered="{cell:true}"
|
||
>
|
||
<template #columns>
|
||
<a-table-column title="索引名" data-index="name" width="150"/>
|
||
<a-table-column title="唯一" data-index="unique" width="80">
|
||
<template #cell="{ record }">
|
||
{{ record.unique ? '是' : '否' }}
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="字段" data-index="keys" width="200"/>
|
||
</template>
|
||
</a-table>
|
||
</a-tab-pane>
|
||
|
||
<!-- MongoDB 集合结构 -->
|
||
<a-tab-pane key="mongo" title="集合结构">
|
||
<a-statistic-group :data="mongoStats" direction="row" style="margin-bottom: 16px;">
|
||
<a-statistic-item title="文档总数" :value="structure.documentCount"/>
|
||
<a-statistic-item title="字段数" :value="Object.keys(structure.fieldStats).length"/>
|
||
<a-statistic-item title="索引数" :value="structure.indexes.length"/>
|
||
</a-statistic-group>
|
||
|
||
<a-divider>文档示例</a-divider>
|
||
|
||
<a-table
|
||
:data="structure.sampleDocs"
|
||
:columns="mongoColumnDefs"
|
||
:pagination="false"
|
||
size="small"
|
||
:bordered="{cell:true}"
|
||
>
|
||
</a-table>
|
||
</a-tab-pane>
|
||
|
||
<!-- Redis Key 信息 -->
|
||
<a-tab-pane key="redis" title="Key 信息">
|
||
<a-descriptions :data="redisInfo" :column="1" size="small">
|
||
<a-descriptions-item label="Key 名" :value="structure.key"/>
|
||
<a-descriptions-item label="数据类型" :value="structure.info.type"/>
|
||
<a-descriptions-item label="数据长度" :value="structure.info.length"/>
|
||
<a-descriptions-item label="TTL(秒)">
|
||
{{ structure.info.ttl }}
|
||
<a-tag v-if="structure.info.ttl > 0" color="red">即将过期</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="值预览">
|
||
<pre style="max-height: 150px; overflow: auto;">{{ structure.info.value }}</pre>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {computed, onMounted, ref} from 'vue'
|
||
import {Message} from '@arco-design/web-vue'
|
||
|
||
// Props
|
||
const props = defineProps({
|
||
visible: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
connectionId: {
|
||
type: Number,
|
||
default: null
|
||
},
|
||
database: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
tableName: {
|
||
type: String,
|
||
default: ''
|
||
}
|
||
})
|
||
|
||
// 状态
|
||
const loading = ref(false)
|
||
const structure = ref({})
|
||
const indexes = ref([])
|
||
|
||
// 计算属性
|
||
const title = computed(() => {
|
||
return `${props.tableName} - 结构`
|
||
})
|
||
|
||
const activeTab = computed(() => {
|
||
if (!props.database) {
|
||
return 'mysql'
|
||
}
|
||
// 根据 database 判断数据库类型(简化处理)
|
||
return 'mysql'
|
||
})
|
||
|
||
// MySQL 列定义
|
||
const mysqlColumnDefs = [
|
||
{ title: '字段名', dataIndex: 'Field', width: 150 },
|
||
{ title: '类型', dataIndex: 'Type', width: 120 },
|
||
{ title: '是否NULL', dataIndex: 'Null', width: 80 },
|
||
{ title: '主键', dataIndex: 'Key', width: 80 },
|
||
{ title: '默认值', dataIndex: 'Default', width: 120 },
|
||
{ title: '额外信息', dataIndex: 'Extra', width: 200 }
|
||
]
|
||
|
||
const mysqlColumns = computed(() => {
|
||
return structure.value.columns || []
|
||
})
|
||
|
||
// 索引列定义
|
||
const indexColumnDefs = [
|
||
{ title: '索引名', dataIndex: 'name', width: 150 },
|
||
{ title: '唯一', dataIndex: 'unique', width: 80 },
|
||
{ title: '字段', dataIndex: 'keys', width: 200 }
|
||
]
|
||
|
||
// MongoDB 统计数据
|
||
const mongoStats = computed(() => {
|
||
return [
|
||
{ label: '文档总数', value: structure.value.documentCount || 0 },
|
||
{ label: '字段数', value: Object.keys(structure.value.fieldStats || {}).length },
|
||
{ label: '索引数', value: structure.value.indexes?.length || 0 }
|
||
]
|
||
})
|
||
|
||
const mongoColumnDefs = computed(() => {
|
||
const columns = []
|
||
if (structure.value.sampleDocs && structure.value.sampleDocs.length > 0) {
|
||
const firstDoc = structure.value.sampleDocs[0]
|
||
Object.keys(firstDoc).forEach(key => {
|
||
columns.push({ title: key, dataIndex: key, width: 150 })
|
||
})
|
||
}
|
||
return columns
|
||
})
|
||
|
||
const mongoSampleDocs = computed(() => {
|
||
return structure.value.sampleDocs || []
|
||
})
|
||
|
||
// Redis 信息
|
||
const redisInfo = computed(() => {
|
||
return structure.value.info || {}
|
||
})
|
||
|
||
// 加载表结构
|
||
const loadStructure = async () => {
|
||
if (!props.connectionId || !props.database || !props.tableName) {
|
||
Message.warning('参数不完整')
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
if (!window.go?.main?.App?.GetTableStructure) {
|
||
throw new Error('Go 后端未就绪')
|
||
}
|
||
|
||
const result = await window.go.main.App.GetTableStructure(
|
||
props.connectionId,
|
||
props.database,
|
||
props.tableName
|
||
)
|
||
|
||
console.log('GetTableStructure 返回结果:', result)
|
||
|
||
structure.value = result
|
||
} catch (error) {
|
||
console.error('加载表结构失败:', error)
|
||
Message.error('加载表结构失败: ' + (error.message || error))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 关闭对话框
|
||
const handleClose = () => {
|
||
emit('update:visible', false)
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (props.visible) {
|
||
loadStructure()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.arco-table {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arco-table :deep(.arco-table-cell) {
|
||
padding: 8px 12px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
#### 集成到主页面
|
||
**文件**: `go-desk/frontend/src/views/db-cli/index.vue`
|
||
|
||
```vue
|
||
<!-- 表结构对话框 -->
|
||
<TableStructure
|
||
v-model:visible="showTableStructure"
|
||
:connection-id="currentConnection?.id"
|
||
:database="currentDatabase"
|
||
:table-name="currentTableName"
|
||
/>
|
||
|
||
<!-- 连接树组件更新 -->
|
||
<ConnectionTree
|
||
:current-connection-id="currentConnection?.id"
|
||
@connection-select="handleConnectionSelect"
|
||
@connection-edit="handleConnectionEdit"
|
||
@connection-delete="handleConnectionDelete"
|
||
@table-select="handleTableSelect"
|
||
@table-structure="handleTableStructure"
|
||
@show-bookmarks="handleShowBookmarks"
|
||
@show-templates="handleShowTemplates"
|
||
@new-connection="handleNewConnection"
|
||
ref="connectionTreeRef"
|
||
/>
|
||
```
|
||
|
||
### 数据流程
|
||
|
||
```
|
||
用户点击表名
|
||
↓
|
||
ConnectionTree 触发 table-select 事件
|
||
↓
|
||
index.vue 记录当前数据库和表名
|
||
↓
|
||
用户点击表结构按钮(新增)
|
||
↓
|
||
index.vue 显示 TableStructure 对话框
|
||
↓
|
||
TableStructure 组件调用 GetTableStructure API
|
||
↓
|
||
后端根据数据库类型调用对应客户端
|
||
↓
|
||
MySQL: GetTableStructure → DESCRIBE 查询
|
||
→ 解析列信息
|
||
MongoDB: GetCollectionStructure → 文档分析
|
||
→ 字段统计
|
||
Redis: GetKeyInfo → 命令查询
|
||
→ 值预览
|
||
↓
|
||
返回结构数据
|
||
↓
|
||
前端展示对应 Tab 页面
|
||
```
|
||
|
||
### 功能特性
|
||
|
||
#### MySQL
|
||
- ✅ 表结构展示(字段名、类型、是否NULL、主键、默认值)
|
||
- ✅ 索引列表(索引名、唯一、字段)
|
||
|
||
#### MongoDB
|
||
- ✅ 文档示例(最多 5 个)
|
||
- ✅ 字段统计
|
||
- ✅ 文档总数
|
||
- ✅ 索引列表
|
||
|
||
#### Redis
|
||
- ✅ Key 类型识别
|
||
- ✅ TTL 显示
|
||
- ✅ 数据长度统计
|
||
- ✅ 值预览(限制 200 字符)
|
||
|
||
### 使用示例
|
||
|
||
#### MySQL
|
||
1. 在连接树中选择表
|
||
2. 点击"表结构"按钮
|
||
3. 查看表字段信息
|
||
4. 查看表索引信息
|
||
|
||
#### MongoDB
|
||
1. 在连接树中选择集合
|
||
2. 点击"表结构"按钮
|
||
3. 查看文档示例
|
||
4. 查看字段统计
|
||
5. 查看索引信息
|
||
|
||
#### Redis
|
||
1. 在连接树中选择 Key
|
||
2. 点击"表结构"按钮
|
||
3. 查看 Key 类型
|
||
4. 查看 TTL
|
||
5. 查看数据长度
|
||
6. 查看值预览
|
||
|
||
### 技术要点
|
||
|
||
#### 后端
|
||
- **统一接口**: `GetTableStructure()` 根据 `conn.Type` 调用不同客户端
|
||
- **数据解析**: 自动转换为统一格式
|
||
- **错误处理**: 完善的超时和错误处理
|
||
|
||
#### 前端
|
||
- **Tab 页面**: 根据数据库类型显示不同内容
|
||
- **响应式数据**: 使用 `computed` 自动更新
|
||
- **表格组件**: 使用 Arco Design 统一展示
|
||
- **统计卡片**: MongoDB 数据统计
|
||
|
||
---
|
||
|
||
**实现时间**: 2026-01-XX
|
||
**状态**: ✅ 已完成
|
||
**测试状态**: ⏳ 待用户测试
|
||
|