🐛 fix(datagrid): 修正日期字段设置值被误存为 NULL

- 抽取时间字段保存 helper 并统一 picker 类型与格式化逻辑
- 单元格保存优先使用 picker 实时值,避免 Form 同步滞后把日期误判为空
- 补充前端回归测试并更新 issue backlog 记录

Fixes #363
This commit is contained in:
Syngnat
2026-04-17 13:46:38 +08:00
parent 8a10519f9b
commit 04c4613e4d
4 changed files with 95 additions and 58 deletions

View File

@@ -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 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。

View File

@@ -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<string, string> = {
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<EditableCellProps> = 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<EditableCellProps> = 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<EditableCellProps> = React.memo(({
}}
></a>
)}
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<EditableCellProps> = 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}
/>
)
) : (
<Input
ref={inputRef}
onPressEnter={save}
onBlur={save}
onPressEnter={() => { void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
try {
(e.target as HTMLInputElement)?.select?.();

View File

@@ -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');
});
});

View File

@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
export const TEMPORAL_FORMATS: Record<string, string> = {
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;
};