From 6421662f5df417548ff209ee7e055b32839578c1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 16 Jun 2026 14:05:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(oracle):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A3=B8=E8=A1=A8=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=85=83=E6=95=B0=E6=8D=AE=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Oracle-like 裸表查询默认使用连接用户名作为 schema 加载字段元数据 - 修复未写 schema 时结果表头不显示字段类型和注释的问题 - 保持显式 schema.table 查询优先级不变 - 补充查询结果表引用解析和 QueryEditor 回归测试 --- .../QueryEditor.external-sql-save.test.tsx | 51 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 11 +++- frontend/src/utils/queryResultTable.test.ts | 9 ++++ frontend/src/utils/queryResultTable.ts | 13 +++-- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 075d0ef4..eb697756 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -3046,6 +3046,57 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).not.toHaveBeenCalled(); }); + it('uses Oracle login user as default schema for unqualified query result metadata', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.user = 'dev'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ + columns: ['PID', 'BUSILOG_ID', 'ZJLX_ID', ORACLE_ROWID_LOCATOR_COLUMN], + rows: [{ + PID: '200005000000010', + BUSILOG_ID: '00000000000000000000', + ZJLX_ID: '01', + [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAATestAABAAABrXAAA', + }], + }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'PID', type: 'CHAR(15)', comment: '个人标识', key: '' }, + { name: 'BUSILOG_ID', type: 'VARCHAR2(20)', comment: '业务日志编号', key: '' }, + { name: 'ZJLX_ID', type: 'CHAR(2)', comment: '证件类型', key: '' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'DEV', 'PER_CERT_INFO'); + expect(dataGridState.latestProps?.tableName).toBe('DEV.PER_CERT_INFO'); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'oracle-rowid', + columns: ['ROWID'], + valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('gonavi_query_source.ROWID'); + expect(messageApi.warning).not.toHaveBeenCalled(); + }); + it('uses a unique index locator for query results without primary keys', async () => { storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c8e83062..57f38b23 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1794,7 +1794,8 @@ const resolveQueryLocatorPlan = async ({ }; if (forceReadOnly) return plan; - const tableRef = extractQueryResultTableRef(statement, dbType, currentDb); + const defaultSchema = isOracleLikeDialect(dbType) ? String(config?.user || '').trim() : ''; + let tableRef = extractQueryResultTableRef(statement, dbType, currentDb, defaultSchema); if (!tableRef) return plan; plan.tableRef = tableRef; if (isSystemMetadataQueryResult(tableRef, dbType)) { @@ -1811,6 +1812,14 @@ const resolveQueryLocatorPlan = async ({ return plan; } + if (isOracleLikeDialect(dbType) && defaultSchema && !String(tableRef.tableName || '').includes('.')) { + tableRef = { + ...tableRef, + tableName: `${tableRef.metadataDbName}.${tableRef.metadataTableName}`, + }; + plan.tableRef = tableRef; + } + try { const [resCols, resIndexes] = await Promise.all([ DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName), diff --git a/frontend/src/utils/queryResultTable.test.ts b/frontend/src/utils/queryResultTable.test.ts index 5726ff62..20da643d 100644 --- a/frontend/src/utils/queryResultTable.test.ts +++ b/frontend/src/utils/queryResultTable.test.ts @@ -39,6 +39,15 @@ describe('extractQueryResultTableRef', () => { }); }); + it('uses the login user as Oracle default schema when the current db is a service name', () => { + expect(extractQueryResultTableRef('SELECT * FROM per_cert_info', 'oracle', 'ORCLPDB1', 'dev')) + .toEqual({ + tableName: 'PER_CERT_INFO', + metadataDbName: 'DEV', + metadataTableName: 'PER_CERT_INFO', + }); + }); + it('keeps existing simple table behavior for MySQL-style qualified names', () => { expect(extractQueryResultTableRef('SELECT * FROM app.users LIMIT 500', 'mysql', 'app')) .toEqual({ diff --git a/frontend/src/utils/queryResultTable.ts b/frontend/src/utils/queryResultTable.ts index 0d5548f8..753f56d1 100644 --- a/frontend/src/utils/queryResultTable.ts +++ b/frontend/src/utils/queryResultTable.ts @@ -61,6 +61,7 @@ export const extractQueryResultTableRef = ( sql: string, dialect: string, currentDb: string, + defaultSchema = '', ): QueryResultTableRef | undefined => { const text = String(sql || '').trim(); if (!text) return undefined; @@ -77,10 +78,16 @@ export const extractQueryResultTableRef = ( if (!metadataTableName) return undefined; const owner = parts.length >= 2 ? parts[parts.length - 2] : ''; - const metadataDbName = owner || normalizeCurrentDbName(currentDb, dialect); - const tableName = (isOracleLikeDialect(dialect) || keepsQualifiedTableNameForMetadata(dialect)) && owner + const defaultOracleSchema = isOracleLikeDialect(dialect) + ? normalizeCurrentDbName(defaultSchema, dialect) + : ''; + const fallbackSchema = isOracleLikeDialect(dialect) + ? defaultOracleSchema || normalizeCurrentDbName(currentDb, dialect) + : normalizeCurrentDbName(currentDb, dialect); + const metadataDbName = owner || fallbackSchema; + const tableName = isOracleLikeDialect(dialect) && owner ? `${owner}.${metadataTableName}` - : metadataTableName; + : (keepsQualifiedTableNameForMetadata(dialect) && owner ? `${owner}.${metadataTableName}` : metadataTableName); const resolvedMetadataTableName = keepsQualifiedTableNameForMetadata(dialect) && owner ? `${owner}.${metadataTableName}` : metadataTableName; From 0632c5242c18b972eb85102bde5f0b52d3c6ebe0 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 09:49:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20fix(oceanbase/data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Oracle=20=E6=97=B6=E9=97=B4=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=98=BE=E7=A4=BA=E7=BC=96=E8=BE=91=E4=B8=8E=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A7=86=E5=9B=BE=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 OceanBase Oracle DATE 与 TIMESTAMP 的解码、展示和编辑精度丢失问题 - 修复查询结果与数据视图的行号显示、分页页数和日期列展示口径 - 打通 Oracle 与 OceanBase 会话执行链路的扫描方言透传 - 补齐 DBQuery、DataGrid temporal 和 OceanBase 结果链路回归测试 --- .../src/components/DataGrid.layout.test.tsx | 95 ++++ frontend/src/components/DataGrid.tsx | 208 +++++++-- .../src/components/DataGridPaginationBar.tsx | 20 +- .../DataViewer.primary-key.test.tsx | 32 ++ frontend/src/components/DataViewer.tsx | 4 +- .../src/components/dataGridTemporal.test.ts | 43 +- frontend/src/components/dataGridTemporal.ts | 116 ++++- .../src/utils/dataSourceCapabilities.test.ts | 10 +- frontend/src/utils/dataSourceCapabilities.ts | 7 + internal/app/methods_db.go | 358 ++++++++++++++ internal/app/methods_db_conn_test.go | 8 + .../app/methods_db_create_statement_test.go | 31 ++ .../app/methods_db_metadata_retry_test.go | 149 +++++- internal/db/database.go | 23 +- internal/db/database_session_test.go | 87 ++++ internal/db/oceanbase_impl.go | 143 +++++- internal/db/oceanbase_impl_test.go | 77 +++ internal/db/oracle_applychanges_test.go | 54 ++- internal/db/oracle_get_tables_test.go | 114 +++++ internal/db/oracle_impl.go | 437 +++++++++++++----- internal/db/query_value.go | 337 +++++++++++++- internal/db/query_value_test.go | 167 +++++++ internal/db/scan_rows_test.go | 336 ++++++++++++++ internal/mcpserver/service.go | 4 +- internal/mcpserver/service_test.go | 40 ++ 25 files changed, 2702 insertions(+), 198 deletions(-) create mode 100644 internal/db/database_session_test.go diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 97e9269b..2338def9 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -222,7 +222,9 @@ describe('DataGrid layout', () => { paginationSummaryText="当前 24 条 / 共 24 条" paginationControlTotal={24} paginationTotalPages={1} + paginationPageText="第 1 / 1 页" paginationPageSizeOptions={['100', '200']} + showKnownPageCount onPageChange={() => {}} onPageSizeChange={() => {}} onV2PageStep={() => {}} @@ -233,6 +235,37 @@ describe('DataGrid layout', () => { expect(markup).not.toContain('第 1 / 1 页'); }); + it('keeps unknown-total pagination in sequential mode instead of pretending total pages are known', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain('第 3 页'); + expect(markup).not.toContain('3/4'); + expect(markup).not.toContain('data-grid-pagination-jump="true"'); + }); + it('renders the v2 DataGrid toolbar using the redesigned topbar hooks', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('AI 洞察'); }); + it('renders a non-data row number column when enabled', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain('aria-label="行号"'); + expect(markup).toContain('#'); + expect(markup).not.toContain('>行号<'); + expect(markup).toContain('data-grid-row-number-title="true"'); + expect(markup).toContain('data-grid-column-title-single-line="true"'); + expect(markup).toContain('justify-content:center'); + expect(markup).toContain('align-items:center'); + expect(markup).toContain('min-height:var(--gonavi-header-min-height, 40px)'); + expect(markup).toContain('text-align:center'); + expect(markup).toContain('padding-inline:0'); + expect(markup).toContain('vertical-align:middle'); + expect(markup).toContain('data-grid-row-number="true"'); + expect(markup).toContain('51'); + }); + it('clears modified cell markers when refreshing the grid', () => { const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[clearAutoCommitTimer, onReload\]\);/); }); + it('routes temporal inline editors through the current connection config', () => { + const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig);'); + expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, currentConnConfig);'); + expect(source).toContain('cellProps.connectionConfig = currentConnConfig;'); + expect(source).toContain('format={getTemporalPickerFormat(pickerType)}'); + }); + it('renders a cell-level undo action in the v2 context menu for modified cells', () => { const markup = renderToStaticMarkup( { expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456'); }); + it('collapses OceanBase Oracle DATE midnight values to date-only text', () => { + const oceanBaseOracleConfig = { + type: 'oceanbase', + oceanBaseProtocol: 'oracle', + } as any; + + expect(formatCellDisplayText('2026-06-16T00:00:00Z', 'DATE', oceanBaseOracleConfig)).toBe('2026-06-16'); + expect(formatCellDisplayText('2026-06-16 00:00:00', 'DATE', oceanBaseOracleConfig)).toBe('2026-06-16'); + expect(formatCellDisplayText('2026-06-16T13:14:15Z', 'DATE', oceanBaseOracleConfig)).toBe('2026-06-16 13:14:15'); + expect(formatCellDisplayText('2026-06-16T00:00:00Z', 'DATE', { type: 'oracle' } as any)).toBe('2026-06-16 00:00:00'); + }); + it('renders bit column hex values as decimal flags', () => { expect(formatCellDisplayText('0x00', 'bit(1)')).toBe('0'); expect(formatCellDisplayText('0x01', 'bit(1)')).toBe('1'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 86fb37f4..2fad341a 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -31,12 +31,13 @@ import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { getDensityParams, resolveDataTableColumnWidth, resolveDataTableVerticalBorderColor, } from '../utils/dataGridDisplay'; -import { resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; +import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort'; import { calculateExternalHorizontalScrollInnerWidth, @@ -71,10 +72,12 @@ import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } import { TEMPORAL_FORMATS, formatFromDayjs, + getTemporalPickerFormat, getTemporalPickerType, isTemporalColumnType, parseToDayjs, resolveTemporalEditorSaveValue, + type TemporalConnectionLike, type TemporalPickerType, } from './dataGridTemporal'; import { @@ -182,6 +185,7 @@ class DataGridErrorBoundary extends React.Component< // 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 export const GONAVI_ROW_KEY = '__gonavi_row_key__'; +export const GONAVI_ROW_NUMBER_COLUMN_KEY = '__gonavi_row_number__'; // Cell key helpers for batch selection/fill. // Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). @@ -189,6 +193,7 @@ const CELL_KEY_SEP = '\u0001'; const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; const DATE_TIME_CACHE_LIMIT = 2000; const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const ROW_NUMBER_COLUMN_WIDTH = 58; const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ { value: 3000, label: '3 秒' }, { value: 5000, label: '5 秒' }, @@ -281,7 +286,46 @@ const normalizeBitHexDisplayText = (val: any, columnType?: string): string | nul } }; -export const formatCellDisplayText = (val: any, columnType?: string): string => { +type CellDisplayConnectionLike = TemporalConnectionLike; + +const isDateOnlyColumnType = (columnType?: string): boolean => { + const normalized = String(columnType || '').trim().toLowerCase(); + if (!normalized) return false; + const base = normalized.split(/[ (]/)[0]; + return base === 'date' || base === 'newdate'; +}; + +const isOceanBaseOracleDisplayConnection = (connectionConfig?: CellDisplayConnectionLike): boolean => { + if (!connectionConfig) return false; + const type = String(connectionConfig.type || '').trim().toLowerCase(); + const driver = String(connectionConfig.driver || '').trim().toLowerCase(); + return (type === 'oceanbase' || driver === 'oceanbase') + && normalizeOceanBaseProtocol(connectionConfig.oceanBaseProtocol) === 'oracle'; +}; + +const normalizeOceanBaseOracleDateDisplayText = ( + val: string, + columnType?: string, + connectionConfig?: CellDisplayConnectionLike, +): string | null => { + if (!isDateOnlyColumnType(columnType) || !isOceanBaseOracleDisplayConnection(connectionConfig)) { + return null; + } + const trimmed = String(val || '').trim(); + if (!trimmed) return trimmed; + const match = trimmed.match( + /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?)?$/ + ); + if (!match) return null; + const [, datePart, timePart, fractionPart] = match; + if (!timePart) return datePart; + if (timePart === '00:00:00' && (!fractionPart || /^\.0+$/.test(fractionPart))) { + return datePart; + } + return null; +}; + +export const formatCellDisplayText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { try { if (val === null) return 'NULL'; const bitText = normalizeBitHexDisplayText(val, columnType); @@ -310,6 +354,10 @@ export const formatCellDisplayText = (val: any, columnType?: string): string => } } if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) { + return oceanBaseDateOnly.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${oceanBaseDateOnly.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : oceanBaseDateOnly; + } const normalized = normalizeDateTimeString(val); return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; } @@ -320,12 +368,16 @@ export const formatCellDisplayText = (val: any, columnType?: string): string => } }; -const formatClipboardCellText = (val: any, columnType?: string): string => { +const formatClipboardCellText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { try { if (val === null || val === undefined) return 'NULL'; const bitText = normalizeBitHexDisplayText(val, columnType); if (bitText !== null) return bitText; - if (typeof val === 'string') return normalizeDateTimeString(val); + if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; + return normalizeDateTimeString(val); + } if (typeof val === 'object') { try { return JSON.stringify(val); @@ -346,6 +398,7 @@ const buildClipboardTsv = ( rows: Array>, columnNames: string[], getColumnType?: (columnName: string) => string | undefined, + connectionConfig?: CellDisplayConnectionLike, ): string => { if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { return ''; @@ -353,7 +406,7 @@ const buildClipboardTsv = ( const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); const lines = rows.map((row) => ( columnNames - .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName)))) + .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName), connectionConfig))) .join('\t') )); return [header, ...lines].join('\n'); @@ -382,8 +435,8 @@ const renderHighlightedCellText = (text: string, query: string): React.ReactNode return <>{nodes}; }; -const renderCellDisplayValue = (val: any, query: string, columnType?: string): React.ReactNode => { - const text = formatCellDisplayText(val, columnType); +const renderCellDisplayValue = (val: any, query: string, columnType?: string, connectionConfig?: CellDisplayConnectionLike): React.ReactNode => { + const text = formatCellDisplayText(val, columnType, connectionConfig); const content = renderHighlightedCellText(text, query); if (val === null) return {content}; return content; @@ -778,6 +831,7 @@ interface EditableCellProps { focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; columnType?: string; dbType?: string; + connectionConfig?: CellDisplayConnectionLike; inputCellPadding?: React.CSSProperties; as?: any; modifiedColumns?: Record>; @@ -828,6 +882,9 @@ const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: Edit if (prevProps.title !== nextProps.title) return false; if (prevProps.columnType !== nextProps.columnType) return false; if (prevProps.dbType !== nextProps.dbType) return false; + if ((prevProps.connectionConfig?.type ?? null) !== (nextProps.connectionConfig?.type ?? null)) return false; + if ((prevProps.connectionConfig?.driver ?? null) !== (nextProps.connectionConfig?.driver ?? null)) return false; + if ((prevProps.connectionConfig?.oceanBaseProtocol ?? null) !== (nextProps.connectionConfig?.oceanBaseProtocol ?? null)) return false; if (prevProps.darkMode !== nextProps.darkMode) return false; if (prevProps.as !== nextProps.as) return false; if (prevProps.handleSave !== nextProps.handleSave) return false; @@ -866,6 +923,7 @@ const EditableCell: React.FC = React.memo(({ focusCell, columnType, dbType, + connectionConfig, inputCellPadding, as: Component = 'td', modifiedColumns, @@ -953,7 +1011,7 @@ const EditableCell: React.FC = React.memo(({ let childNode = children; - const pickerType = getTemporalPickerType(columnType, dbType); + const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined @@ -988,7 +1046,7 @@ const EditableCell: React.FC = React.memo(({ style={{ width: '100%' }} showTime showNow={false} - format={TEMPORAL_FORMATS[pickerType]} + format={getTemporalPickerFormat(pickerType)} renderExtraFooter={() => ( void; onSort?: (field: string, order: string) => void; onPageChange?: (page: number, size: number) => void; @@ -1499,7 +1558,7 @@ const DataGrid: React.FC = ({ resultExportAllSql, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, - scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions + scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn = false }) => { const connections = useStore(state => state.connections); const addTab = useStore(state => state.addTab); @@ -4448,7 +4507,7 @@ const DataGrid: React.FC = ({ } const columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; - const pickerType = getTemporalPickerType(columnType, dbType); + const pickerType = getTemporalPickerType(columnType, dbType, currentConnConfig); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(raw || ''))); const fieldName = getCellFieldName(record, dataIndex); if (isDateTimeField) { @@ -4463,7 +4522,7 @@ const DataGrid: React.FC = ({ title, columnType, }); - }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, dbType, form, openCellEditor, rowKeyStr]); + }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, form, openCellEditor, rowKeyStr]); const handleVirtualCellActivate = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { if (!canModifyData) return; @@ -4593,7 +4652,7 @@ const DataGrid: React.FC = ({ return; } - const pickerType = getTemporalPickerType(editingCell.columnType, dbType); + const pickerType = getTemporalPickerType(editingCell.columnType, dbType, currentConnConfig); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[editingCell.dataIndex] || ''))); const fieldName = getCellFieldName(record, editingCell.dataIndex); try { @@ -4612,22 +4671,30 @@ const DataGrid: React.FC = ({ closeVirtualInlineEditor(); } } - }, [closeVirtualInlineEditor, dbType, form, handleCellSave, virtualEditingCell]); + }, [closeVirtualInlineEditor, currentConnConfig, dbType, form, handleCellSave, virtualEditingCell]); const pageFindMatches = useMemo(() => collectDataGridFindMatches( mergedDisplayData, displayColumnNames, normalizedPageFindText, - (value, _row, columnName) => formatCellDisplayText(value, (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type), + (value, _row, columnName) => formatCellDisplayText( + value, + (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, + currentConnConfig, + ), (row, rowIndex) => String(row?.[GONAVI_ROW_KEY] ?? `row-${rowIndex}`), - ), [mergedDisplayData, displayColumnNames, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName]); + ), [mergedDisplayData, displayColumnNames, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName, currentConnConfig]); const pageFindSummary = useMemo(() => summarizeDataGridFindMatches( mergedDisplayData, displayColumnNames, normalizedPageFindText, - (value, _row, columnName) => formatCellDisplayText(value, (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type), - ), [mergedDisplayData, displayColumnNames, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName]); + (value, _row, columnName) => formatCellDisplayText( + value, + (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, + currentConnConfig, + ), + ), [mergedDisplayData, displayColumnNames, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName, currentConnConfig]); useEffect(() => { setActivePageFindMatchIndex(-1); @@ -4734,7 +4801,7 @@ const DataGrid: React.FC = ({ displayMap[col] = toFormText(displayVal); // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); if (rowPickerType && displayVal !== null && displayVal !== undefined) { const dVal = parseToDayjs(displayVal, rowPickerType); formMap[col] = dVal; @@ -4751,7 +4818,7 @@ const DataGrid: React.FC = ({ nullCols, formValues: formMap, }); - }, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowKeyStr, columnMetaMap, columnMetaMapByLowerName, dbType, openRowEditor]); + }, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, openRowEditor, rowKeyStr, visibleColumnNames]); const openCurrentViewRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4916,7 +4983,7 @@ const DataGrid: React.FC = ({ if (!isWritableResultColumn(col, effectiveEditLocator)) return; if (val && dayjs.isDayjs(val)) { const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); convertedValues[col] = formatFromDayjs(val as dayjs.Dayjs, rowPickerType); } else { convertedValues[col] = val; @@ -4935,7 +5002,7 @@ const DataGrid: React.FC = ({ // 日期时间类型: 将 dayjs 对象转回格式化字符串 if (nextVal && dayjs.isDayjs(nextVal)) { const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); nextVal = formatFromDayjs(nextVal as dayjs.Dayjs, rowPickerType); } const baseVal = baseRawMap[col]; @@ -4950,7 +5017,7 @@ const DataGrid: React.FC = ({ }); closeRowEditor(); - }, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor, effectiveEditLocator, columnMetaMap, columnMetaMapByLowerName, dbType]); + }, [addedRows, closeRowEditor, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, effectiveEditLocator, rowEditorForm, rowEditorRowKey, rowKeyStr, visibleColumnNames]); const enableVirtual = isTableSurfaceActive; @@ -4985,7 +5052,7 @@ const DataGrid: React.FC = ({ sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined, editable: canModifyData && isWritableResultColumn(key, effectiveEditLocator), render: (text: any) => { - const renderedContent = renderCellDisplayValue(text, normalizedPageFindText, displayColumnTypeMap[key]); + const renderedContent = renderCellDisplayValue(text, normalizedPageFindText, displayColumnTypeMap[key], currentConnConfig); if (enableVirtual) { return renderedContent; } @@ -5037,7 +5104,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, isV2Ui, showColumnHeaderContextMenu, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText, displayColumnTypeMap, enableVirtual, showColumnComment, showColumnType]); + }, [canModifyData, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, normalizedPageFindText, onSort, renderColumnTitle, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { const dataIndex = String(col.dataIndex); @@ -5067,6 +5134,7 @@ const DataGrid: React.FC = ({ cellProps.focusCell = openCellEditor; cellProps.columnType = displayColumnTypeMap[dataIndex]; cellProps.dbType = dbType; + cellProps.connectionConfig = currentConnConfig; cellProps.inputCellPadding = inputCellPadding; cellProps.modifiedColumns = modifiedColumns; cellProps.rowKeyStr = rowKeyStr; @@ -5097,7 +5165,7 @@ const DataGrid: React.FC = ({ : undefined; const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle; if (enableVirtual && enableInlineEditableCell) { - const pickerType = getTemporalPickerType(columnType, dbType); + const pickerType = getTemporalPickerType(columnType, dbType, currentConnConfig); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); const virtualCellStyle = modifiedStyle ? { ...virtualCellWrapperStyle, ...modifiedStyle } : virtualCellWrapperStyle; const virtualEditable = !!col.editable && !rowDeletedForRender; @@ -5126,7 +5194,7 @@ const DataGrid: React.FC = ({ style={{ width: '100%' }} showTime showNow={false} - format={TEMPORAL_FORMATS[pickerType]} + format={getTemporalPickerFormat(pickerType)} renderExtraFooter={() => ( = ({ return originalRenderContent; } }; - }), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleSharedCellContextMenu, displayColumnTypeMap, dbType, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]); + }), [closeVirtualInlineEditor, columns, currentConnConfig, darkMode, dbType, deletedRowKeys, displayColumnTypeMap, enableInlineEditableCell, enableVirtual, form, handleCellSave, handleSharedCellContextMenu, handleVirtualCellActivate, inputCellPadding, lockVirtualInlineTableScroll, modifiedColumns, openCellEditor, rowKeyStr, saveVirtualInlineEditor, updateFocusedCell, useInlineEditableBodyCell, virtualCellWrapperStyle, virtualEditingCell]); + + const rowNumberColumn = useMemo>(() => ({ + title: ( +
+ # +
+ ), + key: GONAVI_ROW_NUMBER_COLUMN_KEY, + dataIndex: GONAVI_ROW_NUMBER_COLUMN_KEY, + width: ROW_NUMBER_COLUMN_WIDTH, + className: 'data-grid-row-number-cell', + align: 'center', + onHeaderCell: () => ({ + style: { + textAlign: 'center' as const, + paddingInline: 0, + verticalAlign: 'middle' as const, + }, + }), + render: (_value: unknown, _record: Item, index: number) => { + const currentPage = Math.max(1, Number(pagination?.current) || 1); + const pageSize = Math.max(1, Number(pagination?.pageSize) || 0); + const offset = pageSize > 0 ? (currentPage - 1) * pageSize : 0; + return ( + + {offset + index + 1} + + ); + }, + }), [pagination?.current, pagination?.pageSize]); + + const tableColumns = useMemo( + () => (showRowNumberColumn ? [rowNumberColumn, ...mergedColumns] : mergedColumns), + [mergedColumns, rowNumberColumn, showRowNumberColumn] + ); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -5529,10 +5648,10 @@ const DataGrid: React.FC = ({ const columnType = (columnMetaMap[normalizedColumnName] || columnMetaMapByLowerName[normalizedColumnName.toLowerCase()])?.type; const text = mergedDisplayData - .map((row) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[normalizedColumnName], columnType))) + .map((row) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[normalizedColumnName], columnType, currentConnConfig))) .join('\n'); copyToClipboard(text); - }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, displayOutputColumnNames, mergedDisplayData]); + }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, mergedDisplayData]); const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeaderContextMenuActionKey) => { const columnName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); @@ -5872,13 +5991,14 @@ const DataGrid: React.FC = ({ rows, columns, (columnName) => (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, + currentConnConfig, ); if (!text) { void message.info('当前行没有可复制内容'); return; } copyToClipboard(text); - }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, displayOutputColumnNames, getContextMenuTargetRows]); + }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, getContextMenuTargetRows]); const buildConnConfig = useCallback(() => { if (!connectionId) return null; @@ -6435,7 +6555,7 @@ const DataGrid: React.FC = ({ const rowPropsFactory = useCallback((record: any) => ({ record } as any), []); - const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || densityParams.defaultColumnWidth), 0) + selectionColumnWidth; + const totalWidth = tableColumns.reduce((sum: number, col: any) => sum + (Number(col.width) || densityParams.defaultColumnWidth), 0) + selectionColumnWidth; const useContextMenuRow = false; const tableScrollX = useMemo(() => { // rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。 @@ -7330,6 +7450,14 @@ const DataGrid: React.FC = ({ }); }, [pagination, supportsApproximateTotalPages]); + const paginationHasKnownTotalPages = useMemo(() => { + if (!pagination) return false; + if (pagination.totalKnown !== false) return true; + if (!supportsApproximateTotalPages || !pagination.totalApprox) return false; + const approximateTotal = Number(pagination.approximateTotal); + return Number.isFinite(approximateTotal) && approximateTotal > 0; + }, [pagination, supportsApproximateTotalPages]); + const paginationTotalPages = useMemo(() => { if (!pagination) return 1; if (!Number.isFinite(paginationControlTotal) || paginationControlTotal <= 0) { @@ -7361,6 +7489,14 @@ const DataGrid: React.FC = ({ supportsApproximateTotalPages, ]); + const paginationPageText = useMemo(() => { + if (!pagination) return ''; + return resolvePaginationPageText({ + pagination, + supportsApproximateTotalPages, + }); + }, [pagination, supportsApproximateTotalPages]); + const handlePageSizeChange = useCallback((value: string) => { if (!pagination || !onPageChange) return; const nextSize = Number(value); @@ -7412,7 +7548,7 @@ const DataGrid: React.FC = ({ ref={tableRef} components={tableComponents} dataSource={tableRenderData} - columns={mergedColumns} + columns={tableColumns} {...(enableVirtual && typeof virtualListItemHeight === 'number' ? { listItemHeight: virtualListItemHeight } : {})} @@ -7502,7 +7638,9 @@ const DataGrid: React.FC = ({ paginationSummaryText={paginationSummaryText} paginationControlTotal={paginationControlTotal} paginationTotalPages={paginationTotalPages} + paginationPageText={paginationPageText} paginationPageSizeOptions={paginationPageSizeOptions} + showKnownPageCount={paginationHasKnownTotalPages} onPageChange={onPageChange} onPageSizeChange={handlePageSizeChange} onV2PageStep={handleV2PageStep} @@ -7516,7 +7654,7 @@ const DataGrid: React.FC = ({ const isJson = looksLikeJsonText(sample); const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const pickerType = getTemporalPickerType(colMeta?.type, dbType); + const pickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); const isWritable = isWritableResultColumn(col, effectiveEditLocator); return { @@ -7530,7 +7668,7 @@ const DataGrid: React.FC = ({ isWritable, }; }) - ), [displayColumnNames, columnMetaMap, columnMetaMapByLowerName, dbType, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); + ), [columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, displayColumnNames, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); const handleRefreshGrid = useCallback(() => { clearAutoCommitTimer(); diff --git a/frontend/src/components/DataGridPaginationBar.tsx b/frontend/src/components/DataGridPaginationBar.tsx index 09ccbddb..4cb0faab 100644 --- a/frontend/src/components/DataGridPaginationBar.tsx +++ b/frontend/src/components/DataGridPaginationBar.tsx @@ -20,7 +20,9 @@ export interface DataGridPaginationBarProps { paginationSummaryText: string; paginationControlTotal: number; paginationTotalPages: number; + paginationPageText: string; paginationPageSizeOptions: string[]; + showKnownPageCount: boolean; onPageChange?: (page: number, size: number) => void; onPageSizeChange: (value: string) => void; onV2PageStep: (direction: 'previous' | 'next') => void; @@ -33,7 +35,9 @@ const DataGridPaginationBar: React.FC = ({ paginationSummaryText, paginationControlTotal, paginationTotalPages, + paginationPageText, paginationPageSizeOptions, + showKnownPageCount, onPageChange, onPageSizeChange, onV2PageStep, @@ -58,7 +62,7 @@ const DataGridPaginationBar: React.FC = ({ if (normalizedJumpPage === pagination.current) return; onPageChange(normalizedJumpPage, pagination.pageSize); }; - const jumpPageControl = ( + const jumpPageControl = showKnownPageCount ? (
跳页 = ({ 跳
- ); + ) : null; return (
= ({ onClick={() => onV2PageStep('previous')} />
- {pagination.current} - / - {paginationTotalPages} + {showKnownPageCount ? ( + <> + {pagination.current} + / + {paginationTotalPages} + + ) : ( + {paginationPageText} + )}