mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
- 导入按列类型标准化 datetime/date/time,避免 +0800 CST 导致 1292 错误 - 导出文件统一时间格式为 yyyy-MM-dd HH:mm:ss - JSON 视图时间字符串统一规范化显示 - 侧边栏改为 schema -> 对象类型 -> 对象 的分层分组展示 - refs #89
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <TableOutlined />,
|
||||
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: <EyeOutlined />,
|
||||
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: <FunctionOutlined />,
|
||||
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: <CodeOutlined />,
|
||||
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', '表', <TableOutlined />, tables),
|
||||
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
|
||||
buildObjectGroup('routines', '函数', <CodeOutlined />, routineNodes),
|
||||
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
|
||||
];
|
||||
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
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: <EyeOutlined />,
|
||||
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: <FunctionOutlined />,
|
||||
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: <CodeOutlined />,
|
||||
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<string, any> = {}
|
||||
): 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<string, SchemaBucket>();
|
||||
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', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'routines', '函数', <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'triggers', '触发器', <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
|
||||
];
|
||||
|
||||
return {
|
||||
title: schemaTitle,
|
||||
key: schemaNodeKey,
|
||||
icon: <FolderOpenOutlined />,
|
||||
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', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
buildObjectGroup(key as string, 'views', '视图', <EyeOutlined />, viewEntries.map(buildViewNode)),
|
||||
buildObjectGroup(key as string, 'routines', '函数', <CodeOutlined />, routineEntries.map(buildRoutineNode)),
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, 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);
|
||||
|
||||
@@ -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", "<br>")
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user