mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-31 13:39:48 +08:00
🐛 fix(datagrid): 修正日期字段设置值被误存为 NULL
- 抽取时间字段保存 helper 并统一 picker 类型与格式化逻辑 - 单元格保存优先使用 picker 实时值,避免 Form 同步滞后把日期误判为空 - 补充前端回归测试并更新 issue backlog 记录 Fixes #363
This commit is contained in:
@@ -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 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
10
frontend/src/components/dataGridTemporal.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
59
frontend/src/components/dataGridTemporal.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user