feat: 查询分页(limit/offset)+流式CSV导出+去复制按钮

后端:
- ExecuteQuery 添加 limit/offset 参数,MySQL 自动注入 LIMIT/OFFSET
- 首页自动 COUNT 查总数(total),翻页页不再重复 count
- 新增 POST /query/export 流式 CSV 导出端点,游标分批写不占内存
- 默认 limit=100, max=1000,防止大数据量 OOM

前端:
- 查询结果加分页控件(首页/上一页/下一页/末页 + 每页条数选择)
- CSV 导出改走流式端点(全量数据),不限于当前页
- 去掉复制按钮(Copy/Check/coped state),Redis 单值也不显示复制
- 工具栏显示 总行数/行每页/列数
This commit is contained in:
logan committed 2026-05-28 15:51:22 +08:00
1 parent 03ff845485
commit 5ea86fe334
4 files changed
+232 -61

No files matched your search

+159 -3
View File
@@ -505,6 +505,8 @@ func ExecuteQuery(c *gin.Context) {
InstanceID int `json:"instance_id"`
Database string `json:"database"`
SQL string `json:"sql"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
@@ -514,6 +516,16 @@ func ExecuteQuery(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
return
}
// Default pagination
if input.Limit <= 0 {
input.Limit = 100
}
if input.Limit > 1000 {
input.Limit = 1000
}
if input.Offset < 0 {
input.Offset = 0
}
var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
@@ -525,7 +537,7 @@ func ExecuteQuery(c *gin.Context) {
switch instance.DBType {
case "mysql", "postgresql", "postgres", "pg":
result, err := executeSQLQuery(&instance, input.Database, input.SQL)
result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
costMs := time.Since(start).Milliseconds()
if err != nil {
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
@@ -551,13 +563,41 @@ func ExecuteQuery(c *gin.Context) {
}
}
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gin.H, error) {
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, limit int, offset int) (gin.H, error) {
db, err := getDBConnection(instance, dbName)
if err != nil {
return nil, fmt.Errorf("connection failed: %v", err)
}
defer db.Close()
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
// Count total (only when no user LIMIT and offset=0)
total := 0
hasLimit := strings.Contains(upperSQL, " LIMIT ")
if !hasLimit && offset == 0 {
if strings.HasPrefix(upperSQL, "SELECT") {
fromIdx := strings.Index(upperSQL, " FROM ")
if fromIdx > 0 {
countSQL := "SELECT COUNT(*) " + sqlStr[fromIdx:]
upperCount := strings.ToUpper(countSQL)
if idx := strings.Index(upperCount, " ORDER BY "); idx > 0 {
countSQL = countSQL[:idx]
}
if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 {
countSQL = countSQL[:idx]
}
db.QueryRow(countSQL).Scan(&total)
}
}
}
// Inject LIMIT/OFFSET if not already present
if !hasLimit {
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
}
rows, err := db.Query(sqlStr)
if err != nil {
return nil, fmt.Errorf("query failed: %v", err)
@@ -594,7 +634,8 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gi
return gin.H{
"columns": colNames,
"rows": resultRows,
"total": len(resultRows),
"total": total,
"count": len(resultRows),
}, nil
}
@@ -742,6 +783,121 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
}, nil
}
// POST /api/v1/query/export — Streaming CSV export for large datasets
func ExportQueryCSV(c *gin.Context) {
var input struct {
InstanceID int `json:"instance_id"`
Database string `json:"database"`
SQL string `json:"sql"`
Limit int `json:"limit"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if input.InstanceID == 0 || input.SQL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
return
}
if input.Limit <= 0 {
input.Limit = 10000 // Export max 10k rows
}
if input.Limit > 50000 {
input.Limit = 50000
}
var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
if instance.DBType != "mysql" && instance.DBType != "postgresql" && instance.DBType != "postgres" && instance.DBType != "pg" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export only supports MySQL/PostgreSQL"})
return
}
db, err := getDBConnection(&instance, input.Database)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Connection failed: " + err.Error()})
return
}
defer db.Close()
// Inject LIMIT if not present
upperSQL := strings.ToUpper(strings.TrimSpace(input.SQL))
if !strings.Contains(upperSQL, " LIMIT ") {
input.SQL = fmt.Sprintf("%s LIMIT %d", input.SQL, input.Limit)
}
rows, err := db.Query(input.SQL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Query failed: " + err.Error()})
return
}
defer rows.Close()
cols, _ := rows.Columns()
colNames := make([]string, len(cols))
for i, col := range cols {
colNames[i] = col
}
// Stream CSV response
filename := fmt.Sprintf("query_export_%s.csv", time.Now().Format("20060102_150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
// BOM for Excel UTF-8
c.Writer.Write([]byte("\xEF\xBB\xBF"))
// Header row
c.Writer.Write([]byte(strings.Join(colNames, ",") + "\n"))
// Data rows — write in chunks to avoid OOM
batchSize := 500
batch := make([]string, 0, batchSize)
rowCount := 0
for rows.Next() {
values := make([]interface{}, len(cols))
ptrValues := make([]interface{}, len(cols))
for i := range values {
ptrValues[i] = &values[i]
}
rows.Scan(ptrValues...)
cells := make([]string, len(cols))
for i, val := range values {
s := ""
if b, ok := val.([]byte); ok {
s = string(b)
} else if val != nil {
s = fmt.Sprintf("%v", val)
}
// CSV escape: wrap in quotes if contains comma, quote, or newline
if strings.ContainsAny(s, ",\"\n\r") {
s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
cells[i] = s
}
batch = append(batch, strings.Join(cells, ","))
rowCount++
if len(batch) >= batchSize {
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
c.Writer.(http.Flusher).Flush()
batch = batch[:0]
}
}
// Flush remaining
if len(batch) > 0 {
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
}
c.Writer.(http.Flusher).Flush()
saveQueryHistory(0, input.InstanceID, input.Database, input.SQL, 0, rowCount)
}
func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) {
var user model.User
var inst model.Instance
+1
View File
@@ -155,6 +155,7 @@ func Setup(r *gin.Engine) {
// Query execution
auth.POST("/query/execute", handler.ExecuteQuery)
auth.POST("/query", handler.ExecuteQuery)
auth.POST("/query/export", handler.ExportQueryCSV)
// SQL Assistant