Private
Public Access
1
0
Files
u-desk/docs/04-功能迭代/GO-DESK-2.数据库客户端/核对报告/表结构功能实现说明.md
绝尘 a5d30684ed 重构:文件系统模块化架构,增强 Markdown 渲染
- 拆分 FileSystem.vue 为模块化组件架构
- 新增 Markdown Mermaid 图表渲染支持
- 新增 180+ 编程语言代码高亮
- 修复编辑/预览模式切换渲染问题
- 优化亮色/暗色模式主题适配
- 新增 TypeScript 类型定义
2026-02-04 03:32:46 +08:00

18 KiB
Raw Blame History

表结构查看功能实现说明

功能概述

表结构查看功能已完成,用户可以查看 MySQL 表、MongoDB 集合、Redis Key 的详细结构和信息。

实现内容

1. 后端实现Go

MySQL 表结构查询

文件: go-desk/internal/dbclient/mysql.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

// 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

// 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

// 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/web/src/views/db-cli/components/TableStructure.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/web/src/views/db-cli/index.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 状态: 已完成 测试状态: 待用户测试