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:
1 parent
03ff845485
commit
5ea86fe334
4 files changed
+225
-54
No files matched your search
@@ -505,6 +505,8 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
InstanceID int `json:"instance_id"`
|
InstanceID int `json:"instance_id"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
SQL string `json:"sql"`
|
SQL string `json:"sql"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
|
||||||
return
|
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
|
var instance model.Instance
|
||||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||||
@@ -525,7 +537,7 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
|
|
||||||
switch instance.DBType {
|
switch instance.DBType {
|
||||||
case "mysql", "postgresql", "postgres", "pg":
|
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()
|
costMs := time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
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)
|
db, err := getDBConnection(instance, dbName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connection failed: %v", err)
|
return nil, fmt.Errorf("connection failed: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
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)
|
rows, err := db.Query(sqlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query failed: %v", err)
|
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{
|
return gin.H{
|
||||||
"columns": colNames,
|
"columns": colNames,
|
||||||
"rows": resultRows,
|
"rows": resultRows,
|
||||||
"total": len(resultRows),
|
"total": total,
|
||||||
|
"count": len(resultRows),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,6 +783,121 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
}, nil
|
}, 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) {
|
func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) {
|
||||||
var user model.User
|
var user model.User
|
||||||
var inst model.Instance
|
var inst model.Instance
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func Setup(r *gin.Engine) {
|
|||||||
// Query execution
|
// Query execution
|
||||||
auth.POST("/query/execute", handler.ExecuteQuery)
|
auth.POST("/query/execute", handler.ExecuteQuery)
|
||||||
auth.POST("/query", handler.ExecuteQuery)
|
auth.POST("/query", handler.ExecuteQuery)
|
||||||
|
auth.POST("/query/export", handler.ExportQueryCSV)
|
||||||
|
|
||||||
|
|
||||||
// SQL Assistant
|
// SQL Assistant
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import request from './request';
|
import request from './request';
|
||||||
|
|
||||||
export const queryApi = {
|
export const queryApi = {
|
||||||
execute: (data: { instance_id: number; database?: string; sql: string }) =>
|
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
|
||||||
request.post<any, any>('/query', data),
|
request.post<any, any>('/query', data),
|
||||||
|
|
||||||
formatSQL: (sql: string) =>
|
formatSQL: (sql: string) =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
||||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||||
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
||||||
History, PanelRightClose, PanelRightOpen, Copy, Check,
|
History, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
// Card components replaced by design system utility classes
|
// Card components replaced by design system utility classes
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
@@ -23,6 +23,8 @@ import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
|||||||
interface QueryResult {
|
interface QueryResult {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
rows: Record<string, unknown>[];
|
rows: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
affected_rows: number;
|
affected_rows: number;
|
||||||
execution_time_ms: number;
|
execution_time_ms: number;
|
||||||
cost_ms?: number;
|
cost_ms?: number;
|
||||||
@@ -123,6 +125,10 @@ export default function QueriesPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState<QueryResult | null>(null);
|
const [result, setResult] = useState<QueryResult | null>(null);
|
||||||
|
|
||||||
|
// -- Pagination --
|
||||||
|
const [pageSize, setPageSize] = useState(100);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
// -- UI state --
|
// -- UI state --
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
const [templateSearch, setTemplateSearch] = useState('');
|
const [templateSearch, setTemplateSearch] = useState('');
|
||||||
@@ -209,12 +215,14 @@ export default function QueriesPage() {
|
|||||||
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
|
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
|
||||||
|
|
||||||
/* ---- Execute ---- */
|
/* ---- Execute ---- */
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async (page = 1) => {
|
||||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
||||||
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; }
|
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; }
|
||||||
setExecuting(true); setError(''); setResult(null);
|
setExecuting(true); setError(''); setCurrentPage(page);
|
||||||
|
if (page === 1) setResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql }) as unknown as QueryResult;
|
const offset = (page - 1) * pageSize;
|
||||||
|
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, limit: pageSize, offset }) as unknown as QueryResult;
|
||||||
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
||||||
if (normalized.error) {
|
if (normalized.error) {
|
||||||
setError(normalized.error);
|
setError(normalized.error);
|
||||||
@@ -222,14 +230,18 @@ export default function QueriesPage() {
|
|||||||
} else {
|
} else {
|
||||||
setResult(normalized);
|
setResult(normalized);
|
||||||
}
|
}
|
||||||
|
if (page === 1) {
|
||||||
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail;
|
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail;
|
||||||
const msg = Array.isArray(raw) ? raw.map(v => v.msg).join('; ') : (raw || '查询失败');
|
const msg = Array.isArray(raw) ? raw.map(v => v.msg).join('; ') : (raw || '查询失败');
|
||||||
setError(msg);
|
setError(msg);
|
||||||
|
if (page === 1) {
|
||||||
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]);
|
||||||
|
}
|
||||||
} finally { setExecuting(false); }
|
} finally { setExecuting(false); }
|
||||||
}, [sql, instanceId, database, dbType]);
|
}, [sql, instanceId, database, dbType, pageSize]);
|
||||||
|
|
||||||
/* ---- Result Sorting ---- */
|
/* ---- Result Sorting ---- */
|
||||||
const sortedResult = useMemo(() => {
|
const sortedResult = useMemo(() => {
|
||||||
@@ -264,38 +276,26 @@ export default function QueriesPage() {
|
|||||||
}, [sortedResult, rowHeight]);
|
}, [sortedResult, rowHeight]);
|
||||||
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
||||||
|
|
||||||
const handleExportResult = useCallback(() => {
|
const handleExportResult = useCallback(async () => {
|
||||||
if (!result) return;
|
if (!sql || !instanceId) return;
|
||||||
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, exportToCSV(result.columns, result.rows));
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
/* ---- Copy Result ---- */
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const handleCopyResult = useCallback(() => {
|
|
||||||
if (!result) return;
|
|
||||||
let text = '';
|
|
||||||
if (result.rows?.length && result.columns?.length) {
|
|
||||||
// Format as readable text
|
|
||||||
const lines = result.rows.map((row: Record<string, unknown>) =>
|
|
||||||
result.columns.map((col: string) => {
|
|
||||||
const val = String(row[col] ?? '');
|
|
||||||
// Try to pretty-format JSON values
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(val);
|
const resp = await fetch('/api/v1/query/export', {
|
||||||
if (typeof parsed === 'object') return JSON.stringify(parsed, null, 2);
|
method: 'POST',
|
||||||
} catch { /* not JSON */ }
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
return val;
|
body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }),
|
||||||
}).join('\t')
|
|
||||||
);
|
|
||||||
text = [result.columns.join('\t'), ...lines].join('\n');
|
|
||||||
} else {
|
|
||||||
text = JSON.stringify(result, null, 2);
|
|
||||||
}
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
});
|
});
|
||||||
}, [result]);
|
if (!resp.ok) throw new Error('导出失败');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || '导出失败');
|
||||||
|
}
|
||||||
|
}, [sql, instanceId, database]);
|
||||||
|
|
||||||
/* ---- Smart cell value formatter (detect JSON) ---- */
|
/* ---- Smart cell value formatter (detect JSON) ---- */
|
||||||
const formatCellValue = useCallback((val: unknown) => {
|
const formatCellValue = useCallback((val: unknown) => {
|
||||||
@@ -486,24 +486,19 @@ export default function QueriesPage() {
|
|||||||
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
|
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
|
||||||
{result.raw_text !== undefined ? (
|
{result.raw_text !== undefined ? (
|
||||||
<span>{result.raw_value ? 'JSON' : typeof result.raw_text === 'string' ? 'String' : 'Value'}</span>
|
<span>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span>{result.rows?.length || 0} 行</span>
|
<span>{result.total || result.rows?.length || 0} 总行</span>
|
||||||
|
<span>{result.rows?.length || 0} 行/页</span>
|
||||||
<span>{result.columns?.length || 0} 列</span>
|
<span>{result.columns?.length || 0} 列</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(result.rows?.length > 0 || result.raw_text !== undefined) && (
|
{result.rows?.length > 0 && dbType !== 'redis' && (
|
||||||
<>
|
|
||||||
<button onClick={handleCopyResult}
|
|
||||||
className="dg-btn dg-btn-ghost dg-btn-sm gap-1">
|
|
||||||
{copied ? <><Check className="w-3 h-3 text-green-400" /><span className="text-green-400">已复制</span></> : <><Copy className="w-3 h-3" />复制</>}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleExportResult}
|
<button onClick={handleExportResult}
|
||||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||||
<Download className="w-3 h-3" />导出 CSV
|
<Download className="w-3 h-3" />导出 CSV
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,12 +519,6 @@ export default function QueriesPage() {
|
|||||||
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||||
: String(result.raw_text)
|
: String(result.raw_text)
|
||||||
}</pre>
|
}</pre>
|
||||||
<button onClick={() => {
|
|
||||||
const text = result.raw_value ? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })() : String(result.raw_text);
|
|
||||||
navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
|
||||||
}} className="absolute top-2 right-2 dg-btn dg-btn-ghost dg-btn-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : result.columns && result.columns.length > 0 ? (
|
) : result.columns && result.columns.length > 0 ? (
|
||||||
@@ -576,6 +565,31 @@ export default function QueriesPage() {
|
|||||||
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{/* Pagination */}
|
||||||
|
{result.total > pageSize && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>每页</span>
|
||||||
|
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); handleExecute(1); }}
|
||||||
|
className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded px-1 py-0.5 text-xs">
|
||||||
|
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
<span>条</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button disabled={currentPage <= 1} onClick={() => handleExecute(1)}
|
||||||
|
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3 rotate-180" /></button>
|
||||||
|
<button disabled={currentPage <= 1} onClick={() => handleExecute(currentPage - 1)}
|
||||||
|
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3" /></button>
|
||||||
|
<span className="px-2">{currentPage} / {Math.ceil(result.total / pageSize)}</span>
|
||||||
|
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(currentPage + 1)}
|
||||||
|
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3 rotate-180" /></button>
|
||||||
|
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(Math.ceil(result.total / pageSize))}
|
||||||
|
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
<span>共 {result.total} 条</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
||||||
|
|||||||
Reference in new issue
Block a user