diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 80710df..b5a1ff4 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,7 +1,8 @@ // cspell:ignore anticon sqls uuidv uuidv4 hscroll import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; @@ -156,6 +157,43 @@ const isTemporalColumnType = (columnType?: string): boolean => { return base === 'date' || base === 'time' || base === 'year'; }; +// 根据列类型返回 DatePicker 的 picker 模式 +type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null; +const getTemporalPickerType = (columnType?: string): TemporalPickerType => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return null; + if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime'; + const base = raw.split(/[ (]/)[0]; + if (base === 'date') return 'date'; + if (base === 'time') return 'time'; + if (base === 'year') return 'year'; + return null; +}; + +const TEMPORAL_FORMATS: Record = { + datetime: 'YYYY-MM-DD HH:mm:ss', + date: 'YYYY-MM-DD', + time: 'HH:mm:ss', + year: 'YYYY', +}; + +// 将字符串值转为 dayjs 对象(用于 DatePicker),无效值返回 null +const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => { + if (val === null || val === undefined || val === '') return null; + const str = String(val).trim(); + if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null; // 无效日期 + const fmt = TEMPORAL_FORMATS[pickerType || 'datetime']; + const d = dayjs(str, fmt); + return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null; +}; + +// 将 dayjs 对象格式化为对应格式字符串 +const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => { + if (!val || !val.isValid()) return ''; + const fmt = TEMPORAL_FORMATS[pickerType || 'datetime']; + return val.format(fmt); +}; + // --- Helper: Format Value --- const formatCellValue = (val: any) => { try { @@ -512,6 +550,7 @@ interface EditableCellProps { record: Item; handleSave: (record: Item) => void; focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; + columnType?: string; as?: any; [key: string]: any; } @@ -524,6 +563,7 @@ const EditableCell: React.FC = React.memo(({ record, handleSave, focusCell, + columnType, as: Component = 'td', ...restProps }) => { @@ -541,9 +581,15 @@ const EditableCell: React.FC = React.memo(({ const toggleEdit = () => { setEditing(!editing); const raw = record[dataIndex]; - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; const fieldName = getCellFieldName(record, dataIndex); - setCellFieldValue(form, fieldName, initialValue); + if (isDateTimeField) { + // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 + const dayjsVal = parseToDayjs(raw, pickerType); + setCellFieldValue(form, fieldName, dayjsVal); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } }; const save = async () => { @@ -551,7 +597,13 @@ const EditableCell: React.FC = React.memo(({ if (!form) return; const fieldName = getCellFieldName(record, dataIndex); await form.validateFields([fieldName]); - const nextValue = form.getFieldValue(fieldName); + let nextValue = form.getFieldValue(fieldName); + // 日期时间类型: 将 dayjs 对象转回格式化字符串 + if (isDateTimeField && nextValue && dayjs.isDayjs(nextValue)) { + nextValue = formatFromDayjs(nextValue as dayjs.Dayjs, pickerType); + } else if (isDateTimeField && !nextValue) { + nextValue = null; + } toggleEdit(); // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { @@ -575,30 +627,66 @@ const EditableCell: React.FC = React.memo(({ let childNode = children; + const pickerType = getTemporalPickerType(columnType); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); + if (editable) { childNode = editing ? ( - { - // Enter 编辑态时直接全选,便于快速替换;同时避免双击在 input 内冒泡导致关闭编辑态。 - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - onDoubleClick={(e) => { - e.stopPropagation(); - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - /> + {isDateTimeField ? ( + pickerType === 'time' ? ( + setTimeout(save, 0)} + needConfirm={false} + /> + ) : pickerType === 'datetime' ? ( + setTimeout(save, 0)} + onOpenChange={(open) => { + // 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存 + if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); + }} + needConfirm + /> + ) : ( + setTimeout(save, 0)} + needConfirm={false} + /> + ) + ) : ( + { + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> + )} ) : (
= ({ const displayVal = (displayRow as any)?.[col]; baseRawMap[col] = baseVal; displayMap[col] = toFormText(displayVal); - formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); + // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + if (rowPickerType && displayVal !== null && displayVal !== undefined) { + const dVal = parseToDayjs(displayVal, rowPickerType); + formMap[col] = dVal; + } else { + formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); + } if (baseVal === null || baseVal === undefined) nullCols.add(col); }); @@ -2868,7 +2964,7 @@ const DataGrid: React.FC = ({ rowEditorForm.setFieldsValue(formMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); - }, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr]); + }, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]); const openRowEditor = useCallback(() => { if (!canModifyData) return; @@ -3028,7 +3124,18 @@ const DataGrid: React.FC = ({ const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); if (isAdded) { - setAddedRows(prev => prev.map(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr ? { ...r, ...values } : r)); + // 日期时间类型: 将 dayjs 对象转回格式化字符串 + const convertedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + if (val && dayjs.isDayjs(val)) { + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + convertedValues[col] = formatFromDayjs(val as dayjs.Dayjs, rowPickerType); + } else { + convertedValues[col] = val; + } + }); + setAddedRows(prev => prev.map(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr ? { ...r, ...convertedValues } : r)); closeRowEditor(); return; } @@ -3036,7 +3143,13 @@ const DataGrid: React.FC = ({ const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; columnNames.forEach((col) => { - const nextVal = values[col]; + let nextVal = values[col]; + // 日期时间类型: 将 dayjs 对象转回格式化字符串 + if (nextVal && dayjs.isDayjs(nextVal)) { + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + nextVal = formatFromDayjs(nextVal as dayjs.Dayjs, rowPickerType); + } const baseVal = baseRawMap[col]; if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; }); @@ -3124,6 +3237,7 @@ const DataGrid: React.FC = ({ cellProps.title = dataIndex; cellProps.handleSave = handleCellSave; cellProps.focusCell = openCellEditor; + cellProps.columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; } else if (col.editable && !enableInlineEditableCell) { // 可编辑但非 inline(虚拟模式下):双击和右键通过 onCell 绑定 cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex); @@ -3153,6 +3267,7 @@ const DataGrid: React.FC = ({ record={record} handleSave={handleCellSave} focusCell={openCellEditor} + columnType={(columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type} as="div" style={VIRTUAL_CELL_WRAPPER_STYLE} > @@ -3177,7 +3292,7 @@ const DataGrid: React.FC = ({ return originalRenderContent; } }; - }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu]); + }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu, columnMetaMap, columnMetaMapByLowerName]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -4750,12 +4865,40 @@ const DataGrid: React.FC = ({ const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; const isJson = looksLikeJsonText(sample); const useArea = isJson || sample.includes('\n') || sample.length >= 160; + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + const isRowDateTimeField = !!rowPickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); return (
- {useArea ? ( + {isRowDateTimeField ? ( + rowPickerType === 'time' ? ( + + ) : rowPickerType === 'datetime' ? ( + + ) : ( + + ) + ) : useArea ? (