diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index 636f3b6..91c6b61 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -38,6 +38,7 @@ | #346 | TDEngine只显示子表不显示超级表 | Fixed | Pending | | #348 | [Bug] sql查询同名字段,结果集不会自动添加别名 | Fixed | Pending | | #349 | [Bug] postgres对于表名大小写敏感,且为大写时,通过选中表右键新建查询时生成的sql语句没有自动带上引号"" | Fixed | Pending | +| #363 | [Bug] 日期字段无法设置值 | Fixed | Pending | | #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending | ## Notes @@ -144,6 +145,12 @@ - 处理:抽出统一的 `buildTableSelectQuery` helper,内部复用 `quoteQualifiedIdent` 按数据库方言生成表引用;并将 Sidebar、TableOverview 的三个“新建查询”入口统一接到该 helper,保证 PostgreSQL/Kingbase 等方言在大写或特殊字符表名场景下自动补双引号。 - 验证:新增 `frontend/src/utils/objectQueryTemplates.test.ts` 回归测试,覆盖 PostgreSQL `public.MyTable` 自动生成 `SELECT * FROM public.\"MyTable\";`,并执行 `frontend` 下 `npm exec vitest run src/utils/objectQueryTemplates.test.ts` 与 `npm run build`。 +### #363 + +- 根因:表格内日期类单元格的内联编辑在 `DatePicker/TimePicker` 的 `onChange` 时立即调用 `save()`,但保存逻辑只从 Form store 读取当前字段值。日期 picker 选值后存在一个短暂窗口,picker 已经产出新值而 Form 还没同步完成,结果保存路径把“未同步的空值”当成真实值,最终写成 `NULL`。 +- 处理:抽出 `dataGridTemporal` helper,统一时间字段的 picker 类型、格式化和保存决策;单元格保存时优先使用 picker 回调里实时拿到的值,再回退到 Form store,避免 `date/time/year` 场景把刚选中的值误判为空。 +- 验证:新增 `frontend/src/components/dataGridTemporal.test.ts` 回归测试,覆盖“picker 已选中日期、Form 仍为空”时仍保存 `YYYY-MM-DD`;并执行 `frontend` 下 `npm exec vitest run src/components/dataGridTemporal.test.ts` 与 `npm run build`。 + ### #330 - 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。 diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index ce4a380..da7c9fd 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -50,6 +50,15 @@ import { } from './dataGridCopyInsert'; import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; import { buildSelectedCellClipboardText } from './dataGridSelectionCopy'; +import { + TEMPORAL_FORMATS, + formatFromDayjs, + getTemporalPickerType, + isTemporalColumnType, + parseToDayjs, + resolveTemporalEditorSaveValue, + type TemporalPickerType, +} from './dataGridTemporal'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -166,51 +175,6 @@ const normalizeDateTimeString = (val: string) => { return normalized; }; -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'; -}; - -// 根据列类型返回 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 { @@ -639,17 +603,14 @@ const EditableCell: React.FC = React.memo(({ setEditing(!editing); }; - const save = async () => { + const save = async (pickerValue?: dayjs.Dayjs | null) => { try { if (!form || !editing) return; const fieldName = getCellFieldName(record, dataIndex); await form.validateFields([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; + if (isDateTimeField) { + nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); } toggleEdit(); // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 @@ -688,9 +649,9 @@ const EditableCell: React.FC = React.memo(({ ref={inputRef} style={{ width: '100%' }} format={TEMPORAL_FORMATS[pickerType]} - onChange={() => setTimeout(save, 0)} + onChange={(value) => setTimeout(() => { void save(value); }, 0)} onOpenChange={lockTableScroll} - onBlur={() => setTimeout(save, 0)} + onBlur={() => setTimeout(() => { void save(); }, 0)} needConfirm={false} /> ) : pickerType === 'datetime' ? ( @@ -711,7 +672,7 @@ const EditableCell: React.FC = React.memo(({ }} >此刻 )} - onOk={() => setTimeout(save, 0)} + onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} onOpenChange={(open) => { pickerOpenRef.current = open; lockTableScroll(open); @@ -731,17 +692,17 @@ const EditableCell: React.FC = React.memo(({ style={{ width: '100%' }} format={TEMPORAL_FORMATS[pickerType]} picker={pickerType as any} - onChange={() => setTimeout(save, 0)} + onChange={(value) => setTimeout(() => { void save(value); }, 0)} onOpenChange={lockTableScroll} - onBlur={() => setTimeout(save, 0)} + onBlur={() => setTimeout(() => { void save(); }, 0)} needConfirm={false} /> ) ) : ( { void save(); }} + onBlur={() => { void save(); }} onFocus={(e) => { try { (e.target as HTMLInputElement)?.select?.(); diff --git a/frontend/src/components/dataGridTemporal.test.ts b/frontend/src/components/dataGridTemporal.test.ts new file mode 100644 index 0000000..18ae576 --- /dev/null +++ b/frontend/src/components/dataGridTemporal.test.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs'; +import { describe, expect, it } from 'vitest'; + +import { resolveTemporalEditorSaveValue } from './dataGridTemporal'; + +describe('dataGridTemporal helpers', () => { + it('prefers the picker selected date when form store has not caught up yet', () => { + expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12'); + }); +}); diff --git a/frontend/src/components/dataGridTemporal.ts b/frontend/src/components/dataGridTemporal.ts new file mode 100644 index 0000000..a6a0195 --- /dev/null +++ b/frontend/src/components/dataGridTemporal.ts @@ -0,0 +1,59 @@ +import dayjs from 'dayjs'; + +export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null; + +export const TEMPORAL_FORMATS: Record = { + datetime: 'YYYY-MM-DD HH:mm:ss', + date: 'YYYY-MM-DD', + time: 'HH:mm:ss', + year: 'YYYY', +}; + +export 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'; +}; + +export 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; +}; + +export 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; +}; + +export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => { + if (!val || !val.isValid()) return ''; + const fmt = TEMPORAL_FORMATS[pickerType || 'datetime']; + return val.format(fmt); +}; + +export const resolveTemporalEditorSaveValue = ( + formValue: any, + pickerValue: dayjs.Dayjs | null | undefined, + pickerType: TemporalPickerType, +): string | null | any => { + const value = pickerValue !== undefined ? pickerValue : formValue; + if (value && dayjs.isDayjs(value)) { + return formatFromDayjs(value as dayjs.Dayjs, pickerType); + } + if (!value) { + return null; + } + return value; +};