diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 74a0094..7a1a604 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -73,15 +73,18 @@ const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | nu }; }; -// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. -// Also handle invalid datetime values like '0000-00-00 00:00:00' +// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. +// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. +// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. const normalizeDateTimeString = (val: string) => { // 检查是否为无效日期时间(0000-00-00 或类似格式) if (/^0{4}-0{2}-0{2}/.test(val)) { return val; // 保持原样显示,不尝试转换 } - const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/); + const match = val.match( + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + ); if (!match) return val; return `${match[1]} ${match[2]}`; }; @@ -179,11 +182,12 @@ const normalizeValueForJsonView = (value: any): any => { if (value === null || value === undefined) return value; if (typeof value === 'string') { - if (!looksLikeJsonText(value)) return value; + const normalizedText = normalizeDateTimeString(value); + if (!looksLikeJsonText(normalizedText)) return normalizedText; try { - return normalizeValueForJsonView(JSON.parse(value)); + return normalizeValueForJsonView(JSON.parse(normalizedText)); } catch { - return value; + return normalizedText; } } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 59b461f..fc07757 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -268,6 +268,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `${schema}.${name}`; }; + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const raw = String(qualifiedName || '').trim(); + if (!raw) return { schemaName: '', objectName: '' }; + const idx = raw.lastIndexOf('.'); + if (idx <= 0 || idx >= raw.length - 1) { + return { schemaName: '', objectName: raw }; + } + return { + schemaName: raw.substring(0, idx), + objectName: raw.substring(idx + 1), + }; + }; + const buildViewsMetadataQuery = (dialect: string, dbName: string): string => { const safeDbName = escapeSQLLiteral(dbName); switch (dialect) { @@ -539,105 +552,214 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const res = await DBGetTables(config as any, conn.dbName); if (res.success) { setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); - const tables = (res.data as any[]).map((row: any) => { - const tableName = Object.values(row)[0] as string; - const tableDisplayName = getSidebarTableDisplayName(conn, tableName); - return { - title: tableDisplayName, - key: `${conn.id}-${conn.dbName}-${tableName}`, - icon: , - type: 'table' as const, - dataRef: { ...conn, tableName }, - isLeaf: false, - }; - }); - const [views, triggers, routines] = await Promise.all([ - loadViews(conn, conn.dbName), - loadDatabaseTriggers(conn, conn.dbName), - loadFunctions(conn, conn.dbName), - ]); + const tableEntries = (res.data as any[]).map((row: any) => { + const tableName = Object.values(row)[0] as string; + const parsed = splitQualifiedName(tableName); + return { + tableName, + schemaName: parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, tableName), + }; + }); - // 获取当前数据库的排序偏好 - const sortPreferenceKey = `${conn.id}-${conn.dbName}`; - const sortBy = tableSortPreference[sortPreferenceKey] || 'name'; + const [views, triggers, routines] = await Promise.all([ + loadViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + loadFunctions(conn, conn.dbName), + ]); - // 根据排序偏好排序表 - if (sortBy === 'frequency') { - // 按使用频率排序(降序) - tables.sort((a, b) => { - const keyA = `${conn.id}-${conn.dbName}-${a.dataRef.tableName}`; - const keyB = `${conn.id}-${conn.dbName}-${b.dataRef.tableName}`; - const countA = tableAccessCount[keyA] || 0; - const countB = tableAccessCount[keyB] || 0; - if (countA !== countB) { - return countB - countA; // 降序 - } - // 频率相同时按名称排序 - return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); - }); - } else { - // 按名称排序(字母顺序) - tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); - } + const viewEntries = views.map((viewName) => { + const parsed = splitQualifiedName(viewName); + return { + viewName, + schemaName: parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, viewName), + }; + }); - // Sort views by name (case-insensitive) - views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + const triggerEntries = triggers.map((trigger) => { + const triggerParsed = splitQualifiedName(trigger.triggerName); + const tableParsed = splitQualifiedName(trigger.tableName); + const schemaName = tableParsed.schemaName || triggerParsed.schemaName; + const triggerObjectName = triggerParsed.objectName || trigger.triggerName; + const tableObjectName = tableParsed.objectName || trigger.tableName; + const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; + return { + ...trigger, + schemaName, + displayName, + }; + }); - // Sort triggers by display name (case-insensitive) - triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + const routineEntries = routines.map((routine) => { + const parsed = splitQualifiedName(routine.routineName); + const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F'; + return { + ...routine, + schemaName: parsed.schemaName, + displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`, + }; + }); - // Sort routines by display name (case-insensitive) - routines.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + // 获取当前数据库的排序偏好 + const sortPreferenceKey = `${conn.id}-${conn.dbName}`; + const sortBy = tableSortPreference[sortPreferenceKey] || 'name'; - const viewNodes: TreeNode[] = views.map((viewName) => ({ - title: getSidebarTableDisplayName(conn, viewName), - key: `${conn.id}-${conn.dbName}-view-${viewName}`, - icon: , - type: 'view', - dataRef: { ...conn, viewName, tableName: viewName }, - isLeaf: true, - })); + // 根据排序偏好排序表 + if (sortBy === 'frequency') { + // 按使用频率排序(降序) + tableEntries.sort((a, b) => { + const keyA = `${conn.id}-${conn.dbName}-${a.tableName}`; + const keyB = `${conn.id}-${conn.dbName}-${b.tableName}`; + const countA = tableAccessCount[keyA] || 0; + const countB = tableAccessCount[keyB] || 0; + if (countA !== countB) { + return countB - countA; + } + return a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()); + }); + } else { + tableEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + } - const triggerNodes: TreeNode[] = triggers.map((trigger) => ({ - title: trigger.displayName, - key: `${conn.id}-${conn.dbName}-trigger-${trigger.triggerName}-${trigger.tableName}`, - icon: , - type: 'db-trigger', - dataRef: { ...conn, triggerName: trigger.triggerName, triggerTableName: trigger.tableName }, - isLeaf: true, - })); + // Sort views by name (case-insensitive) + viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const routineNodes: TreeNode[] = routines.map((r) => ({ - title: r.displayName, - key: `${conn.id}-${conn.dbName}-routine-${r.routineName}`, - icon: , - type: 'routine', - dataRef: { ...conn, routineName: r.routineName, routineType: r.routineType }, - isLeaf: true, - })); + // Sort triggers by display name (case-insensitive) + triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({ - title: `${groupTitle} (${children.length})`, - key: `${key}-${groupKey}`, - icon: groupIcon, - type: 'object-group', - isLeaf: children.length === 0, - children: children.length > 0 ? children : undefined, - dataRef: { ...conn, dbName: conn.dbName, groupKey } - }); + // Sort routines by display name (case-insensitive) + routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const groupedNodes: TreeNode[] = [ - buildObjectGroup('tables', '表', , tables), - buildObjectGroup('views', '视图', , viewNodes), - buildObjectGroup('routines', '函数', , routineNodes), - buildObjectGroup('triggers', '触发器', , triggerNodes), - ]; + const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-${entry.tableName}`, + icon: , + type: 'table', + dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName }, + isLeaf: false, + }); - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); - } else { - setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); - message.error({ content: res.message, key: `db-${key}-tables` }); + const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-view-${entry.viewName}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`, + icon: , + type: 'db-trigger', + dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`, + icon: , + type: 'routine', + dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildObjectGroup = ( + parentKey: string, + groupKey: string, + groupTitle: string, + groupIcon: React.ReactNode, + children: TreeNode[], + extraData: Record = {} + ): TreeNode => ({ + title: `${groupTitle} (${children.length})`, + key: `${parentKey}-${groupKey}`, + icon: groupIcon, + type: 'object-group', + isLeaf: children.length === 0, + children: children.length > 0 ? children : undefined, + dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData } + }); + + const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection); + if (shouldGroupBySchema) { + type SchemaBucket = { + schemaName: string; + tables: TreeNode[]; + views: TreeNode[]; + routines: TreeNode[]; + triggers: TreeNode[]; + }; + + const schemaMap = new Map(); + const getSchemaBucket = (rawSchemaName: string): SchemaBucket => { + const schemaName = String(rawSchemaName || '').trim(); + const schemaKey = schemaName || '__default__'; + let bucket = schemaMap.get(schemaKey); + if (!bucket) { + bucket = { + schemaName, + tables: [], + views: [], + routines: [], + triggers: [], + }; + schemaMap.set(schemaKey, bucket); + } + return bucket; + }; + + tableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry))); + viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry))); + routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry))); + triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry))); + + const schemaNodes: TreeNode[] = Array.from(schemaMap.values()) + .sort((a, b) => { + if (!a.schemaName && !b.schemaName) return 0; + if (!a.schemaName) return -1; + if (!b.schemaName) return 1; + return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase()); + }) + .map((bucket) => { + const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`; + const schemaTitle = bucket.schemaName || '默认模式'; + const groupedNodes: TreeNode[] = [ + buildObjectGroup(schemaNodeKey, 'tables', '表', , bucket.tables, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'views', '视图', , bucket.views, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'routines', '函数', , bucket.routines, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'triggers', '触发器', , bucket.triggers, { schemaName: bucket.schemaName }), + ]; + + return { + title: schemaTitle, + key: schemaNodeKey, + icon: , + type: 'object-group' as const, + isLeaf: groupedNodes.length === 0, + children: groupedNodes, + dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName } + }; + }); + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes])); + } else { + const groupedNodes: TreeNode[] = [ + buildObjectGroup(key as string, 'tables', '表', , tableEntries.map(buildTableNode)), + buildObjectGroup(key as string, 'views', '视图', , viewEntries.map(buildViewNode)), + buildObjectGroup(key as string, 'routines', '函数', , routineEntries.map(buildRoutineNode)), + buildObjectGroup(key as string, 'triggers', '触发器', , triggerEntries.map(buildTriggerNode)), + ]; + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); + } + } else { + setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); + message.error({ content: res.message, key: `db-${key}-tables` }); } } finally { loadingNodesRef.current.delete(loadKey); diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 8e87e96..711a094 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -77,7 +77,6 @@ func (a *App) ImportConfigFile() connection.QueryResult { return connection.QueryResult{Success: true, Data: string(content)} } - // PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据 func (a *App) PreviewImportFile(filePath string) connection.QueryResult { if filePath == "" { @@ -220,6 +219,148 @@ func parseImportFile(filePath string) ([]map[string]interface{}, []string, error return rows, columns, nil } +func normalizeColumnName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func buildImportColumnTypeMap(defs []connection.ColumnDefinition) map[string]string { + result := make(map[string]string, len(defs)) + for _, def := range defs { + key := normalizeColumnName(def.Name) + if key == "" { + continue + } + result[key] = strings.TrimSpace(def.Type) + } + return result +} + +func isTimezoneAwareColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + return strings.Contains(typ, "with time zone") || + strings.Contains(typ, "with timezone") || + strings.Contains(typ, "datetimeoffset") || + strings.Contains(typ, "timestamptz") +} + +func isDateTimeColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") +} + +func isTimeOnlyColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") { + return false + } + return strings.Contains(typ, "time") +} + +func isDateOnlyColumnType(dbType, columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "time") { + return false + } + if !strings.Contains(typ, "date") { + return false + } + db := strings.ToLower(strings.TrimSpace(dbType)) + // Oracle/Dameng 的 DATE 带时间语义,不能按纯日期裁剪。 + return db != "oracle" && db != "dameng" +} + +func isTemporalColumnType(dbType, columnType string) bool { + return isDateTimeColumnType(columnType) || isTimeOnlyColumnType(columnType) || isDateOnlyColumnType(dbType, columnType) +} + +func parseTemporalString(raw string) (time.Time, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return time.Time{}, false + } + + layouts := []string{ + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700", + "2006-01-02 15:04:05 -0700", + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02", + "15:04:05.999999999", + "15:04:05", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, text) + if err == nil { + return parsed, true + } + } + + return time.Time{}, false +} + +func normalizeImportTemporalValue(dbType, columnType, raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return text + } + + parsed, ok := parseTemporalString(text) + if !ok { + if isDateTimeColumnType(columnType) { + candidate := strings.ReplaceAll(text, "T", " ") + if len(candidate) >= 19 { + prefix := candidate[:19] + if _, err := time.Parse("2006-01-02 15:04:05", prefix); err == nil { + return prefix + } + } + } + return text + } + + if isTimeOnlyColumnType(columnType) { + return parsed.Format("15:04:05") + } + if isDateOnlyColumnType(dbType, columnType) { + return parsed.Format("2006-01-02") + } + if isTimezoneAwareColumnType(columnType) { + return parsed.Format("2006-01-02 15:04:05-07:00") + } + return parsed.Format("2006-01-02 15:04:05") +} + +func formatImportSQLValue(dbType, columnType string, value interface{}) string { + if value == nil { + return "NULL" + } + + if isTemporalColumnType(dbType, columnType) { + normalized := normalizeImportTemporalValue(dbType, columnType, fmt.Sprintf("%v", value)) + escaped := strings.ReplaceAll(normalized, "'", "''") + return "'" + escaped + "'" + } + + return formatSQLValue(dbType, value) +} + // ImportDataWithProgress 执行导入并发送进度事件 func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult { rows, columns, err := parseImportFile(filePath) @@ -237,6 +378,12 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, return connection.QueryResult{Success: false, Message: err.Error()} } + schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + columnTypeMap := map[string]string{} + if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil { + columnTypeMap = buildImportColumnTypeMap(defs) + } + totalRows := len(rows) successCount := 0 var errorLogs []string @@ -250,13 +397,8 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, var values []string for _, col := range columns { val := row[col] - if val == nil { - values = append(values, "NULL") - } else { - vStr := fmt.Sprintf("%v", val) - vStr = strings.ReplaceAll(vStr, "'", "''") - values = append(values, fmt.Sprintf("'%s'", vStr)) - } + colType := columnTypeMap[normalizeColumnName(col)] + values = append(values, formatImportSQLValue(runConfig.Type, colType, val)) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", @@ -848,7 +990,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string continue } - s := fmt.Sprintf("%v", val) + s := formatExportCellText(val) if format == "md" { s = strings.ReplaceAll(s, "|", "\\|") s = strings.ReplaceAll(s, "\n", "
") @@ -894,6 +1036,24 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return nil } +func formatExportCellText(val interface{}) string { + if val == nil { + return "NULL" + } + + switch v := val.(type) { + case time.Time: + return v.Format("2006-01-02 15:04:05") + case *time.Time: + if v == nil { + return "NULL" + } + return v.Format("2006-01-02 15:04:05") + default: + return fmt.Sprintf("%v", val) + } +} + // writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件 func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error { xlsx := excelize.NewFile() @@ -915,7 +1075,7 @@ func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []s if val == nil { xlsx.SetCellValue(sheet, cell, "NULL") } else { - xlsx.SetCellValue(sheet, cell, fmt.Sprintf("%v", val)) + xlsx.SetCellValue(sheet, cell, formatExportCellText(val)) } } }