From 66a3113fa8de8f0cbebe5459fa1db87503ed653d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 14:13:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(datagrid-mysql):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DMySQL=E8=A1=8C=E7=BC=96=E8=BE=91=E6=97=B6datetime?= =?UTF-8?q?=E7=A9=BA=E5=80=BC=E6=8F=90=E4=BA=A4=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL - 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp - 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景 - refs #113 --- frontend/src/components/DataGrid.tsx | 61 +++++++++++++++++++- internal/db/mysql_impl.go | 85 ++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 04fde1b..7dee7a7 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -90,6 +90,14 @@ const normalizeDateTimeString = (val: string) => { return `${match[1]} ${match[2]}`; }; +const isTemporalColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + if (raw.includes('datetime') || raw.includes('timestamp')) return true; + const base = raw.split(/[ (]/)[0]; + return base === 'date' || base === 'time' || base === 'year'; +}; + // --- Helper: Format Value --- const formatCellValue = (val: any) => { try { @@ -764,6 +772,35 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMap]); + const normalizeCommitCellValue = useCallback( + (columnName: string, value: any, mode: 'insert' | 'update') => { + if (value === undefined) return undefined; + const normalizedName = String(columnName || '').trim(); + const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; + const temporal = isTemporalColumnType(meta?.type); + + if (!temporal) { + return value; + } + + if (value === null) { + return null; + } + + if (typeof value === 'string') { + const raw = value.trim(); + if (raw === '') { + // INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。 + return mode === 'insert' ? undefined : null; + } + return normalizeDateTimeString(value); + } + + return value; + }, + [columnMetaMap, columnMetaMapByLowerName] + ); + const renderColumnTitle = useCallback((name: string): React.ReactNode => { const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; @@ -1814,7 +1851,17 @@ const DataGrid: React.FC = ({ const updates: any[] = []; const deletes: any[] = []; - addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); }); + addedRows.forEach(row => { + const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; + const normalizedValues: Record = {}; + Object.entries(vals).forEach(([col, val]) => { + const normalizedVal = normalizeCommitCellValue(col, val, 'insert'); + if (normalizedVal !== undefined) { + normalizedValues[col] = normalizedVal; + } + }); + inserts.push(normalizedValues); + }); deletedRowKeys.forEach(keyStr => { // Find original data const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr); @@ -1847,8 +1894,16 @@ const DataGrid: React.FC = ({ }); } - if (Object.keys(values).length === 0) return; - updates.push({ keys: pkData, values }); + const normalizedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + const normalizedVal = normalizeCommitCellValue(col, val, 'update'); + if (normalizedVal !== undefined) { + normalizedValues[col] = normalizedVal; + } + }); + + if (Object.keys(normalizedValues).length === 0) return; + updates.push({ keys: pkData, values: normalizedValues }); }); if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) { diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index 44b269d..2c6a332 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e return fmt.Errorf("connection not open") } + columnTypeMap := m.loadColumnTypeMap(tableName) + tx, err := m.conn.Begin() if err != nil { return err @@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var args []interface{} for k, v := range pk { wheres = append(wheres, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(wheres) == 0 { continue @@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e for k, v := range update.Values { sets = append(sets, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(sets) == 0 { @@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var wheres []string for k, v := range update.Keys { wheres = append(wheres, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(wheres) == 0 { @@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var args []interface{} for k, v := range row { + normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap) + if omit { + continue + } cols = append(cols, fmt.Sprintf("`%s`", k)) placeholders = append(placeholders, "?") - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizedValue) } if len(cols) == 0 { + query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName) + res, err := tx.Exec(query) + if err != nil { + return fmt.Errorf("insert error: %v", err) + } + if affected, err := res.RowsAffected(); err == nil && affected == 0 { + return fmt.Errorf("插入未生效:未影响任何行") + } continue } @@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} { return value } +func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string { + result := map[string]string{} + table := strings.TrimSpace(tableName) + if table == "" { + return result + } + + columns, err := m.GetColumns("", table) + if err != nil { + logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err) + return result + } + + for _, col := range columns { + name := strings.ToLower(strings.TrimSpace(col.Name)) + if name == "" { + continue + } + result[name] = strings.TrimSpace(col.Type) + } + return result +} + +func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) { + columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))])) + if !isMySQLTemporalColumnType(columnType) { + return value, false + } + text, ok := value.(string) + if ok && strings.TrimSpace(text) == "" { + // INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP)。 + return nil, true + } + return normalizeMySQLDateTimeValue(value), false +} + +func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} { + columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))])) + if !isMySQLTemporalColumnType(columnType) { + return value + } + text, ok := value.(string) + if ok && strings.TrimSpace(text) == "" { + return nil + } + return normalizeMySQLDateTimeValue(value) +} + +func isMySQLTemporalColumnType(columnType string) bool { + raw := strings.ToLower(strings.TrimSpace(columnType)) + if raw == "" { + return false + } + if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") { + return true + } + base := raw + if idx := strings.IndexAny(base, "( "); idx >= 0 { + base = base[:idx] + } + return base == "date" || base == "time" || base == "year" +} + func hasTimezoneOffset(text string) bool { pos := strings.LastIndexAny(text, "+-") if pos < 0 || pos < 10 || pos+1 >= len(text) {