diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index 32a614b..d4ed208 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -27,7 +27,8 @@ | #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` | | #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` | | #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` | -| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | Pending | +| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | Pending | +| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | `ca76440` | ## Notes @@ -85,6 +86,12 @@ - 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。 - 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。 +### #330 + +- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。 +- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。 +- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend` 下 `npm run build` 确认 TS 与打包通过。 + ## Next - 继续处理下一个最早且可直接落地的开放 issue。 diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 63c8528..a480079 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -48,6 +48,7 @@ import { normalizeTemporalLiteralText, resolveUniqueKeyGroupsFromIndexes, } from './dataGridCopyInsert'; +import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -392,7 +393,7 @@ const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): a // --- Resizable Header (Native Implementation) --- const ResizableTitle = React.forwardRef((props, ref) => { - const { onResizeStart, width, ...restProps } = props; + const { onResizeStart, onResizeAutoFit, width, ...restProps } = props; const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; if (width) { @@ -415,12 +416,20 @@ const ResizableTitle = React.forwardRef((props, ref) // Pass the header element reference implicitly via event target onResizeStart(e); }} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (typeof onResizeAutoFit === 'function') { + onResizeAutoFit(e); + } + }} onPointerDown={(e) => { // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, // 避免调整列宽时意外触发列拖拽排序 e.stopPropagation(); }} onClick={(e) => e.stopPropagation()} + title="拖动调整列宽,双击按内容自适应" style={{ position: 'absolute', right: 0, // Align to right edge @@ -2886,6 +2895,7 @@ const DataGrid: React.FC = ({ const resizeRafRef = useRef(null); const latestClientXRef = useRef(null); const isResizingRef = useRef(false); // Lock for sorting + const autoFitCanvasRef = useRef(null); const flushGhostPosition = useCallback(() => { resizeRafRef.current = null; @@ -2947,6 +2957,74 @@ const DataGrid: React.FC = ({ }, [columnWidths, dataTableColumnWidthMode]); + const measureTextWidth = useCallback((text: string, font: string) => { + if (typeof document === 'undefined') { + return text.length * 8; + } + if (!autoFitCanvasRef.current) { + autoFitCanvasRef.current = document.createElement('canvas'); + } + const context = autoFitCanvasRef.current.getContext('2d'); + if (!context) { + return text.length * 8; + } + context.font = font; + return context.measureText(text).width; + }, []); + + const buildAutoFitMeasurer = useCallback((element: HTMLElement | null, fallbackFont: string) => { + let font = fallbackFont; + if (typeof window !== 'undefined' && element) { + const computed = window.getComputedStyle(element); + const weight = computed.fontWeight || '400'; + const size = computed.fontSize || '13px'; + const family = computed.fontFamily || 'sans-serif'; + font = `${weight} ${size} ${family}`; + } + return (text: string) => measureTextWidth(text, font); + }, [measureTextWidth]); + + const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const handleEl = e.currentTarget as HTMLElement | null; + const headerEl = handleEl?.closest('th') as HTMLElement | null; + const sampleCell = Array.from( + containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || [] + ).find((node) => (node as HTMLElement).getAttribute('data-col-name') === key) as HTMLElement | undefined; + + const meta = columnMetaMap[key] || columnMetaMapByLowerName[key.toLowerCase()]; + const headerTexts = [key]; + if (showColumnType && meta?.type) headerTexts.push(meta.type); + if (showColumnComment && meta?.comment) headerTexts.push(meta.comment); + + const defaultWidth = resolveDataTableColumnWidth({ + manualWidth: columnWidths[key], + widthMode: dataTableColumnWidthMode, + }); + const containerWidth = containerRef.current?.clientWidth ?? 0; + const nextWidth = calculateAutoFitColumnWidth({ + headerTexts, + valueTexts: displayDataRef.current.map((row) => row?.[key]), + measureHeaderText: buildAutoFitMeasurer(headerEl, '600 13px sans-serif'), + measureCellText: buildAutoFitMeasurer(sampleCell ?? null, '400 13px sans-serif'), + defaultWidth, + minWidth: 80, + maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)), + }); + + setColumnWidths((prev) => ({ ...prev, [key]: nextWidth })); + }, [ + buildAutoFitMeasurer, + columnMetaMap, + columnMetaMapByLowerName, + columnWidths, + dataTableColumnWidthMode, + showColumnComment, + showColumnType, + ]); + // 2. Drag Move (Global) const handleResizeMove = useCallback((e: MouseEvent) => { if (!draggingRef.current) return; @@ -3411,6 +3489,7 @@ const DataGrid: React.FC = ({ width: column.width, className: 'gonavi-sortable-header-cell', onResizeStart: handleResizeStart(key), // Only need start + onResizeAutoFit: handleResizeAutoFit(key), onClickCapture: (event: React.MouseEvent) => { if (!onSort) return; const headerCell = event.currentTarget as HTMLElement; @@ -3433,7 +3512,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]); + }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { const dataIndex = String(col.dataIndex); diff --git a/frontend/src/components/dataGridAutoWidth.test.ts b/frontend/src/components/dataGridAutoWidth.test.ts new file mode 100644 index 0000000..c401af9 --- /dev/null +++ b/frontend/src/components/dataGridAutoWidth.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateAutoFitColumnWidth, + normalizeAutoFitCellText, +} from './dataGridAutoWidth'; + +const measure = (text: string) => text.length * 8; + +describe('dataGridAutoWidth helpers', () => { + it('prefers the widest header or sampled value and adds padding', () => { + const width = calculateAutoFitColumnWidth({ + headerTexts: ['user_name'], + valueTexts: ['alice', 'very_long_username_value'], + measureHeaderText: measure, + measureCellText: measure, + padding: 32, + minWidth: 80, + maxWidth: 720, + defaultWidth: 140, + }); + + expect(width).toBe('very_long_username_value'.length * 8 + 32); + }); + + it('measures multiline content by the longest visible line and clamps to max width', () => { + const width = calculateAutoFitColumnWidth({ + headerTexts: ['notes'], + valueTexts: ['short\nmuch much longer line here'], + measureHeaderText: measure, + measureCellText: measure, + padding: 24, + minWidth: 80, + maxWidth: 160, + defaultWidth: 140, + }); + + expect(width).toBe(160); + }); + + it('normalizes null and oversized object values into stable preview text', () => { + expect(normalizeAutoFitCellText(null)).toBe('NULL'); + expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]'); + }); +}); diff --git a/frontend/src/components/dataGridAutoWidth.ts b/frontend/src/components/dataGridAutoWidth.ts new file mode 100644 index 0000000..b0c21d2 --- /dev/null +++ b/frontend/src/components/dataGridAutoWidth.ts @@ -0,0 +1,108 @@ +const AUTO_FIT_DEFAULT_MIN_WIDTH = 80; +const AUTO_FIT_DEFAULT_MAX_WIDTH = 720; +const AUTO_FIT_DEFAULT_PADDING = 40; +const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200; +const AUTO_FIT_MAX_PREVIEW_CHARS = 120; + +const isPlainObject = (value: unknown): value is Record => { + return Object.prototype.toString.call(value) === '[object Object]'; +}; + +const clampWidth = (value: number, minWidth: number, maxWidth: number) => { + const safeMin = Math.max(1, Math.floor(minWidth)); + const safeMax = Math.max(safeMin, Math.floor(maxWidth)); + return Math.min(safeMax, Math.max(safeMin, Math.ceil(value))); +}; + +const normalizePreviewLine = (value: string): string => { + const normalized = String(value ?? '').replace(/\r\n/g, '\n'); + if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) { + return normalized; + } + return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}…`; +}; + +const splitPreviewLines = (value: string): string[] => { + return normalizePreviewLine(value) + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); +}; + +export const normalizeAutoFitCellText = (value: unknown): string => { + if (value === null || value === undefined) { + return 'NULL'; + } + + if (typeof value === 'string') { + return normalizePreviewLine(value); + } + + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + + if (Array.isArray(value)) { + if (value.length > 80) { + return `[Array(${value.length})]`; + } + try { + return normalizePreviewLine(JSON.stringify(value)); + } catch { + return '[Array]'; + } + } + + if (isPlainObject(value)) { + const topLevelSize = Object.keys(value).length; + if (topLevelSize > 80) { + return `{Object(${topLevelSize})}`; + } + try { + return normalizePreviewLine(JSON.stringify(value)); + } catch { + return '[Object]'; + } + } + + return normalizePreviewLine(String(value)); +}; + +export const calculateAutoFitColumnWidth = ({ + headerTexts, + valueTexts, + measureHeaderText, + measureCellText, + minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH, + maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH, + padding = AUTO_FIT_DEFAULT_PADDING, + sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT, + defaultWidth, +}: { + headerTexts: Array; + valueTexts: unknown[]; + measureHeaderText: (text: string) => number; + measureCellText: (text: string) => number; + minWidth?: number; + maxWidth?: number; + padding?: number; + sampleLimit?: number; + defaultWidth: number; +}): number => { + const safePadding = Math.max(0, Math.ceil(padding)); + let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding); + + headerTexts.forEach((text) => { + splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => { + widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line)); + }); + }); + + valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => { + splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => { + widestTextWidth = Math.max(widestTextWidth, measureCellText(line)); + }); + }); + + return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth); +};