From 0f717706b0f2e7fffd893dd5a3af999967621f13 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 10:33:51 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=90=9B=20fix(TableOverview/DataGrid?= =?UTF-8?q?):=20=E4=BF=AE=E5=A4=8D=E8=A1=A8=E6=A6=82=E8=A7=88=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=89=93=E5=BC=80Tab=E5=8F=8A=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E5=88=97=E4=BF=AE=E6=94=B9=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab去重:表概览 buildTableStatusSQL 对 postgres/kingbase/vastbase/highgo/sqlserver 返回 schema.table 格式表名,与侧边栏一致 - Tab ID统一:移除 openTable 中多余的 table- 前缀,使 Tab ID 格式匹配 - 语义去重:addTab 新增 connectionId+dbName+tableName 语义匹配作为安全网 - 数据修复:handleCommit 和 applyRowEditor 将 displayColumnNames 改为 columnNames,确保隐藏列修改被正确提交 - refs #264 - refs #265 --- frontend/src/components/DataGrid.tsx | 6 +++--- frontend/src/components/TableOverview.tsx | 14 ++++++++------ frontend/src/store.ts | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 78ca46c..6c7d350 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3011,7 +3011,7 @@ const DataGrid: React.FC = ({ const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; - displayColumnNames.forEach((col) => { + columnNames.forEach((col) => { const nextVal = values[col]; const baseVal = baseRawMap[col]; if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; @@ -3025,7 +3025,7 @@ const DataGrid: React.FC = ({ }); closeRowEditor(); - }, [rowEditorRowKey, rowEditorForm, addedRows, displayColumnNames, rowKeyStr, closeRowEditor]); + }, [rowEditorRowKey, rowEditorForm, addedRows, columnNames, rowKeyStr, closeRowEditor]); const enableVirtual = viewMode === 'table'; @@ -3207,7 +3207,7 @@ const DataGrid: React.FC = ({ if (!hasRowKey) { values = { ...(newRow as any) }; } else { - displayColumnNames.forEach((col) => { + columnNames.forEach((col) => { const nextVal = (newRow as any)?.[col]; const prevVal = (originalRow as any)?.[col]; if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 9da0075..4a93783 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -57,11 +57,12 @@ const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: strin return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``; case 'postgres': case 'kingbase': - case 'vastbase': { + case 'vastbase': + case 'highgo': { const schema = schemaName || 'public'; return ` SELECT - c.relname AS table_name, + n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, @@ -76,18 +77,19 @@ ORDER BY c.relname`; const safeDB = `[${dbName.replace(/]/g, ']]')}]`; return ` SELECT - t.name AS table_name, + s.name + '.' + t.name AS table_name, ep.value AS table_comment, SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length FROM ${safeDB}.sys.tables t +JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id WHERE t.type = 'U' -GROUP BY t.name, ep.value -ORDER BY t.name`; +GROUP BY s.name, t.name, ep.value +ORDER BY s.name, t.name`; } case 'clickhouse': return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`; @@ -194,7 +196,7 @@ const TableOverview: React.FC = ({ tab }) => { const openTable = useCallback((tableName: string) => { if (!connection) return; addTab({ - id: `${connection.id}-${tab.dbName}-table-${tableName}`, + id: `${connection.id}-${tab.dbName}-${tableName}`, title: tableName, type: 'table', connectionId: connection.id, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 0338859..0833ae2 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -721,6 +721,21 @@ export const useStore = create()( newTabs[index] = { ...newTabs[index], ...tab }; return { tabs: newTabs, activeTabId: tab.id }; } + // 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab + if ((tab.type === 'table' || tab.type === 'design') && tab.tableName && tab.connectionId && tab.dbName) { + const semanticIndex = state.tabs.findIndex(t => + t.type === tab.type && + t.connectionId === tab.connectionId && + t.dbName === tab.dbName && + t.tableName === tab.tableName + ); + if (semanticIndex !== -1) { + const existingTab = state.tabs[semanticIndex]; + const newTabs = [...state.tabs]; + newTabs[semanticIndex] = { ...existingTab, ...tab, id: existingTab.id }; + return { tabs: newTabs, activeTabId: existingTab.id }; + } + } return { tabs: [...state.tabs, tab], activeTabId: tab.id }; }), From eaa76d8f04a1f6aec243cc0cbe8d7bb54144f5d1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 11:19:08 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E2=9C=A8=20feat(connection):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=9E=E6=8E=A5=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=BE=BE?= =?UTF-8?q?=E6=A2=A6=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=97=E8=A1=A8=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图标组件:新增 DatabaseIcons.tsx,10 种品牌 SVG logo + 7 种彩色文字标签覆盖全部数据源 - 品牌资源:下载 MySQL/PG/Redis/MongoDB/ClickHouse/SQLite/MariaDB/Doris/Sphinx/DuckDB 的 SVG(CC0 许可) - 类型扩展:SavedConnection 新增 iconType/iconColor 支持自定义图标和颜色 - 外观配置:ConnectionModal 新增"外观"配置区(图标选择器 + 14 色选择器 + 预览面板) - 数据源选择:Step1 数据源类型卡片全部统一为品牌图标 - 达梦修复:dameng_metadata.go 增加原生 SQL 查询和诊断日志,改善数据库列表获取 - refs #114 - refs #266 --- frontend/public/db-icons/clickhouse.svg | 1 + frontend/public/db-icons/diros.svg | 1 + frontend/public/db-icons/duckdb.svg | 1 + frontend/public/db-icons/mariadb.svg | 1 + frontend/public/db-icons/mongodb.svg | 1 + frontend/public/db-icons/mysql.svg | 1 + frontend/public/db-icons/postgres.svg | 1 + frontend/public/db-icons/redis.svg | 1 + frontend/public/db-icons/sphinx.svg | 1 + frontend/public/db-icons/sqlite.svg | 1 + frontend/src/components/ConnectionModal.tsx | 140 ++++++++++--- frontend/src/components/DatabaseIcons.tsx | 217 ++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 5 +- frontend/src/types.ts | 2 + internal/db/dameng_metadata.go | 14 +- 15 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 frontend/public/db-icons/clickhouse.svg create mode 100644 frontend/public/db-icons/diros.svg create mode 100644 frontend/public/db-icons/duckdb.svg create mode 100644 frontend/public/db-icons/mariadb.svg create mode 100644 frontend/public/db-icons/mongodb.svg create mode 100644 frontend/public/db-icons/mysql.svg create mode 100644 frontend/public/db-icons/postgres.svg create mode 100644 frontend/public/db-icons/redis.svg create mode 100644 frontend/public/db-icons/sphinx.svg create mode 100644 frontend/public/db-icons/sqlite.svg create mode 100644 frontend/src/components/DatabaseIcons.tsx diff --git a/frontend/public/db-icons/clickhouse.svg b/frontend/public/db-icons/clickhouse.svg new file mode 100644 index 0000000..689aca3 --- /dev/null +++ b/frontend/public/db-icons/clickhouse.svg @@ -0,0 +1 @@ +ClickHouse \ No newline at end of file diff --git a/frontend/public/db-icons/diros.svg b/frontend/public/db-icons/diros.svg new file mode 100644 index 0000000..7582151 --- /dev/null +++ b/frontend/public/db-icons/diros.svg @@ -0,0 +1 @@ +Apache Doris \ No newline at end of file diff --git a/frontend/public/db-icons/duckdb.svg b/frontend/public/db-icons/duckdb.svg new file mode 100644 index 0000000..feaab85 --- /dev/null +++ b/frontend/public/db-icons/duckdb.svg @@ -0,0 +1 @@ +DuckDB \ No newline at end of file diff --git a/frontend/public/db-icons/mariadb.svg b/frontend/public/db-icons/mariadb.svg new file mode 100644 index 0000000..fe068e7 --- /dev/null +++ b/frontend/public/db-icons/mariadb.svg @@ -0,0 +1 @@ +MariaDB \ No newline at end of file diff --git a/frontend/public/db-icons/mongodb.svg b/frontend/public/db-icons/mongodb.svg new file mode 100644 index 0000000..a4c8174 --- /dev/null +++ b/frontend/public/db-icons/mongodb.svg @@ -0,0 +1 @@ +MongoDB \ No newline at end of file diff --git a/frontend/public/db-icons/mysql.svg b/frontend/public/db-icons/mysql.svg new file mode 100644 index 0000000..195caad --- /dev/null +++ b/frontend/public/db-icons/mysql.svg @@ -0,0 +1 @@ +MySQL \ No newline at end of file diff --git a/frontend/public/db-icons/postgres.svg b/frontend/public/db-icons/postgres.svg new file mode 100644 index 0000000..dcf75b7 --- /dev/null +++ b/frontend/public/db-icons/postgres.svg @@ -0,0 +1 @@ +PostgreSQL \ No newline at end of file diff --git a/frontend/public/db-icons/redis.svg b/frontend/public/db-icons/redis.svg new file mode 100644 index 0000000..fc47db8 --- /dev/null +++ b/frontend/public/db-icons/redis.svg @@ -0,0 +1 @@ +Redis \ No newline at end of file diff --git a/frontend/public/db-icons/sphinx.svg b/frontend/public/db-icons/sphinx.svg new file mode 100644 index 0000000..ca57775 --- /dev/null +++ b/frontend/public/db-icons/sphinx.svg @@ -0,0 +1 @@ +Sphinx \ No newline at end of file diff --git a/frontend/public/db-icons/sqlite.svg b/frontend/public/db-icons/sqlite.svg new file mode 100644 index 0000000..a776ea1 --- /dev/null +++ b/frontend/public/db-icons/sqlite.svg @@ -0,0 +1 @@ +SQLite \ No newline at end of file diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index f7e13b4..037d76b 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; -import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons'; +import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -105,7 +106,9 @@ const ConnectionModal: React.FC<{ const [dbType, setDbType] = useState('mysql'); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 - const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network'>('basic'); + const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network' | 'appearance'>('basic'); + const [customIconType, setCustomIconType] = useState(undefined); + const [customIconColor, setCustomIconColor] = useState(undefined); const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl'); const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [testErrorLogOpen, setTestErrorLogOpen] = useState(false); @@ -1061,6 +1064,8 @@ const ConnectionModal: React.FC<{ setRedisDbList([]); setMongoMembers([]); setUriFeedback(null); + setCustomIconType(undefined); + setCustomIconColor(undefined); setTypeSelectWarning(null); setDriverStatusLoaded(false); void refreshDriverStatus(); @@ -1146,6 +1151,8 @@ const ConnectionModal: React.FC<{ mongoReplicaPassword: config.mongoReplicaPassword || '' }); setUseSSL(!!config.useSSL); + setCustomIconType(initialValues.iconType); + setCustomIconColor(initialValues.iconColor); setUseSSH(config.useSSH || false); setUseProxy(hasProxy); setUseHttpTunnel(hasHttpTunnel); @@ -1212,7 +1219,9 @@ const ConnectionModal: React.FC<{ name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), config: config, includeDatabases: values.includeDatabases, - includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined + includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined, + iconType: customIconType, + iconColor: customIconColor, }; if (initialValues) { @@ -1735,32 +1744,32 @@ const ConnectionModal: React.FC<{ const dbTypeGroups = [ { label: '关系型数据库', items: [ - { key: 'mysql', name: 'MySQL', icon: }, - { key: 'mariadb', name: 'MariaDB', icon: }, - { key: 'diros', name: 'Doris', icon: }, - { key: 'sphinx', name: 'Sphinx', icon: }, - { key: 'clickhouse', name: 'ClickHouse', icon: }, - { key: 'postgres', name: 'PostgreSQL', icon: }, - { key: 'sqlserver', name: 'SQL Server', icon: }, - { key: 'sqlite', name: 'SQLite', icon: }, - { key: 'duckdb', name: 'DuckDB', icon: }, - { key: 'oracle', name: 'Oracle', icon: }, + { key: 'mysql', name: 'MySQL', icon: getDbIcon('mysql', undefined, 36) }, + { key: 'mariadb', name: 'MariaDB', icon: getDbIcon('mariadb', undefined, 36) }, + { key: 'diros', name: 'Doris', icon: getDbIcon('diros', undefined, 36) }, + { key: 'sphinx', name: 'Sphinx', icon: getDbIcon('sphinx', undefined, 36) }, + { key: 'clickhouse', name: 'ClickHouse', icon: getDbIcon('clickhouse', undefined, 36) }, + { key: 'postgres', name: 'PostgreSQL', icon: getDbIcon('postgres', undefined, 36) }, + { key: 'sqlserver', name: 'SQL Server', icon: getDbIcon('sqlserver', undefined, 36) }, + { key: 'sqlite', name: 'SQLite', icon: getDbIcon('sqlite', undefined, 36) }, + { key: 'duckdb', name: 'DuckDB', icon: getDbIcon('duckdb', undefined, 36) }, + { key: 'oracle', name: 'Oracle', icon: getDbIcon('oracle', undefined, 36) }, ]}, { label: '国产数据库', items: [ - { key: 'dameng', name: 'Dameng (达梦)', icon: }, - { key: 'kingbase', name: 'Kingbase (人大金仓)', icon: }, - { key: 'highgo', name: 'HighGo (瀚高)', icon: }, - { key: 'vastbase', name: 'Vastbase (海量)', icon: }, + { key: 'dameng', name: 'Dameng (达梦)', icon: getDbIcon('dameng', undefined, 36) }, + { key: 'kingbase', name: 'Kingbase (人大金仓)', icon: getDbIcon('kingbase', undefined, 36) }, + { key: 'highgo', name: 'HighGo (瀚高)', icon: getDbIcon('highgo', undefined, 36) }, + { key: 'vastbase', name: 'Vastbase (海量)', icon: getDbIcon('vastbase', undefined, 36) }, ]}, { label: 'NoSQL', items: [ - { key: 'mongodb', name: 'MongoDB', icon: }, - { key: 'redis', name: 'Redis', icon: }, + { key: 'mongodb', name: 'MongoDB', icon: getDbIcon('mongodb', undefined, 36) }, + { key: 'redis', name: 'Redis', icon: getDbIcon('redis', undefined, 36) }, ]}, { label: '时序数据库', items: [ - { key: 'tdengine', name: 'TDengine', icon: }, + { key: 'tdengine', name: 'TDengine', icon: getDbIcon('tdengine', undefined, 36) }, ]}, { label: '其他', items: [ - { key: 'custom', name: 'Custom (自定义)', icon: }, + { key: 'custom', name: 'Custom (自定义)', icon: getDbIcon('custom', undefined, 36) }, ]}, ]; @@ -2512,16 +2521,101 @@ const ConnectionModal: React.FC<{ /> )} {(() => { - const sectionItems: Array<{ key: 'basic' | 'network'; title: string; description: string; icon: React.ReactNode }> = [ + const sectionItems: Array<{ key: 'basic' | 'network' | 'appearance'; title: string; description: string; icon: React.ReactNode }> = [ { key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: }, ...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: }] : []), + { key: 'appearance', title: '外观', description: '自定义图标与颜色', icon: }, ]; const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection) ? activeConfigSection : sectionItems[0]?.key || 'basic'; + + const effectiveIconType = customIconType || dbType; + const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType); + + const appearanceSection = ( +
+
+
图标
+
+ {DB_ICON_TYPES.map((iconKey) => { + const isActive = effectiveIconType === iconKey; + return ( + + ); + })} +
+
+ 当前:{getDbIconLabel(effectiveIconType)} +
+
+
+
颜色
+
+ {PRESET_ICON_COLORS.map((presetColor) => { + const isActive = effectiveIconColor === presetColor; + return ( +
+
+
+
预览
+
+ {getDbIcon(effectiveIconType, effectiveIconColor, 24)} + {form.getFieldValue('name') || '连接名称'} +
+ {(customIconType || customIconColor) && ( + + )} +
+
+ ); + const currentSectionContent = resolvedSection === 'basic' ? baseInfoSection - : networkSecuritySection; + : resolvedSection === 'appearance' + ? appearanceSection + : networkSecuritySection; if (sectionItems.length <= 1) { return currentSectionContent; diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx new file mode 100644 index 0000000..6d55fb2 --- /dev/null +++ b/frontend/src/components/DatabaseIcons.tsx @@ -0,0 +1,217 @@ +import React from 'react'; + +// ─── 公共接口 ─────────────────────────────────────────────── + +export interface DbIconProps { + size?: number; + color?: string; +} + +// ─── 默认色表 ─────────────────────────────────────────────── + +const DB_DEFAULT_COLORS: Record = { + mysql: '#00758F', + mariadb: '#003545', + postgres: '#336791', + redis: '#DC382D', + mongodb: '#47A248', + kingbase: '#1890FF', + dameng: '#E6002D', + oracle: '#F80000', + sqlserver: '#CC2927', + clickhouse: '#FFBF00', + sqlite: '#003B57', + duckdb: '#FFC107', + vastbase: '#0066CC', + highgo: '#00A86B', + tdengine: '#2962FF', + diros: '#0050B3', + sphinx: '#2F5D62', + custom: '#888888', +}; + +export const getDbDefaultColor = (type: string): string => + DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom; + +// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ──── + +const BRAND_SVG_TYPES = new Set([ + 'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite', + 'diros', 'sphinx', 'duckdb', +]); + +/** 品牌 SVG 图标:用 加载 /db-icons/*.svg */ +const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => { + const bgColor = color || getDbDefaultColor(type); + return ( + + {type} + + ); +}; + +// ─── 彩色标签图标(fallback) ────────────────────────────── + +/** 通用彩色标签:填充背景 + 白色粗体缩写 */ +const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => { + const textSize = label.length <= 2 ? size * 0.48 : size * 0.38; + return ( + + + 2 ? -0.5 : 0} + > + {label} + + + ); +}; + +// ─── 各数据库图标 ─────────────────────────────────────────── + +// 有品牌 SVG 的数据库 +const MySQLIcon: React.FC = ({ size = 16, color }) => ( + +); +const MariaDBIcon: React.FC = ({ size = 16, color }) => ( + +); +const PostgresIcon: React.FC = ({ size = 16, color }) => ( + +); +const RedisIcon: React.FC = ({ size = 16, color }) => ( + +); +const MongoDBIcon: React.FC = ({ size = 16, color }) => ( + +); +const ClickHouseIcon: React.FC = ({ size = 16, color }) => ( + +); +const SQLiteIcon: React.FC = ({ size = 16, color }) => ( + +); + +// 无品牌 SVG → 彩色文字标签 +const OracleIcon: React.FC = ({ size = 16, color }) => ( + +); +const SQLServerIcon: React.FC = ({ size = 16, color }) => ( + +); +const DorisIcon: React.FC = ({ size = 16, color }) => ( + +); +const SphinxIcon: React.FC = ({ size = 16, color }) => ( + +); +const DuckDBIcon: React.FC = ({ size = 16, color }) => ( + +); +const KingBaseIcon: React.FC = ({ size = 16, color }) => ( + +); +const DamengIcon: React.FC = ({ size = 16, color }) => ( + +); +const VastBaseIcon: React.FC = ({ size = 16, color }) => ( + +); +const HighGoIcon: React.FC = ({ size = 16, color }) => ( + +); +const TDengineIcon: React.FC = ({ size = 16, color }) => ( + +); + +/** Custom — 齿轮图标 */ +const CustomIcon: React.FC = ({ size = 16, color }) => { + const c = color || DB_DEFAULT_COLORS.custom; + return ( + + + + + + ); +}; + +// ─── 图标注册表 ───────────────────────────────────────────── + +const DorisIconFallback: React.FC = ({ size = 16, color }) => ( + +); +const SphinxIconFallback: React.FC = ({ size = 16, color }) => ( + +); + +const DB_ICON_MAP: Record> = { + mysql: MySQLIcon, + mariadb: MariaDBIcon, + diros: DorisIcon, + sphinx: SphinxIcon, + postgres: PostgresIcon, + redis: RedisIcon, + mongodb: MongoDBIcon, + kingbase: KingBaseIcon, + dameng: DamengIcon, + oracle: OracleIcon, + sqlserver: SQLServerIcon, + clickhouse: ClickHouseIcon, + sqlite: SQLiteIcon, + duckdb: DuckDBIcon, + vastbase: VastBaseIcon, + highgo: HighGoIcon, + tdengine: TDengineIcon, + custom: CustomIcon, +}; + +/** 可选图标类型列表(用于图标选择器 UI) */ +export const DB_ICON_TYPES: string[] = [ + 'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', + 'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', + 'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom', +]; + +/** 该类型是否有品牌 SVG 文件 */ +export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase()); + +/** 获取数据库图标 React 节点 */ +export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => { + const key = (type || 'custom').toLowerCase(); + const Component = DB_ICON_MAP[key] || CustomIcon; + return ; +}; + +/** 获取数据库图标显示名称(中文) */ +export const getDbIconLabel = (type: string): string => { + const labels: Record = { + mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL', + redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle', + sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite', + duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦', + vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine', + custom: '自定义', + }; + return labels[type?.toLowerCase()] || type; +}; + +/** 预设颜色列表 */ +export const PRESET_ICON_COLORS: string[] = [ + '#336791', '#00758F', '#DC382D', '#47A248', '#F80000', + '#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF', + '#00A86B', '#0066CC', '#FF6B35', '#7C3AED', +]; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ae0a9e9..d0f4e47 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -35,6 +35,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection } from '../types'; +import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -329,7 +330,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return { title: conn.name, key: conn.id, - icon: conn.config.type === 'redis' ? : , + icon: getDbIcon(conn.iconType || conn.config.type, conn.iconColor, 22), type: 'connection', dataRef: conn, isLeaf: false, @@ -3603,7 +3604,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } const statusBadge = node.type === 'connection' || node.type === 'database' ? ( - + ) : null; const displayTitle = String(node.title ?? ''); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d173baf..ea10867 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -72,6 +72,8 @@ export interface SavedConnection { config: ConnectionConfig; includeDatabases?: string[]; includeRedisDatabases?: number[]; // Redis databases to show (0-15) + iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type + iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色 } export interface ConnectionTag { diff --git a/internal/db/dameng_metadata.go b/internal/db/dameng_metadata.go index c963da1..b0f698b 100644 --- a/internal/db/dameng_metadata.go +++ b/internal/db/dameng_metadata.go @@ -4,9 +4,15 @@ import ( "fmt" "sort" "strings" + + "GoNavi-Wails/internal/logger" ) var damengDatabaseQueries = []string{ + // 优先使用达梦原生系统表 + "SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME", + "SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME", + // Oracle 兼容层 "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL", "SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL", "SELECT USERNAME AS DATABASE_NAME FROM USER_USERS", @@ -24,12 +30,14 @@ func collectDamengDatabaseNames(query damengQueryFunc) ([]string, error) { dbs := make([]string, 0, 64) var lastErr error - for _, q := range damengDatabaseQueries { + for idx, q := range damengDatabaseQueries { data, _, err := query(q) if err != nil { + logger.Warnf("达梦 GetDatabases 查询[%d]失败:%v(SQL: %.80s…)", idx, err, q) lastErr = err continue } + newCount := 0 for _, row := range data { name := getDamengRowString(row, "DATABASE_NAME", @@ -58,10 +66,14 @@ func collectDamengDatabaseNames(query damengQueryFunc) ([]string, error) { } seen[key] = struct{}{} dbs = append(dbs, name) + newCount++ } + logger.Infof("达梦 GetDatabases 查询[%d]成功:返回 %d 行,新增 %d 条(SQL: %.80s…)", idx, len(data), newCount, q) } + logger.Infof("达梦 GetDatabases 最终结果:共 %d 条数据库/schema", len(dbs)) if len(dbs) == 0 && lastErr != nil { + logger.Warnf("达梦 GetDatabases 所有查询均失败,返回最后错误:%v", lastErr) return nil, lastErr } From 1b36f6082121d5d97dc934e368163780a2b0f89a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 12:11:09 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor/data-grid?= =?UTF-8?q?):=20=E4=BF=AE=E5=A4=8D=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E5=8F=8A=E6=8F=90=E4=BA=A4=E4=BA=8B=E5=8A=A1=E5=90=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 右键菜单修复:移除 handleContextMenu 的 editable 守卫,只读模式也能弹出右键菜单 - 非编辑单元格绑定:EditableCell 非编辑模式增加 onContextMenu 包装,确保右键事件触发 - mergedColumns 统一:所有列通过 onCell 绑定 onContextMenu,不再跳过非 editable 列 - 表名正则增强:支持多行 SQL 和 schema.table 写法,复杂 SELECT 也能提取表名获得编辑能力 - 精准重查询:新增 handleReloadResult 函数,提交事务后只用当前结果集 SQL 重查,避免整个编辑器 SQL 二次处理导致数据丢失 - refs #267 --- frontend/src/components/DataGrid.tsx | 38 ++++++++----- frontend/src/components/QueryEditor.tsx | 71 ++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 6c7d350..d84b807 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -567,12 +567,10 @@ const EditableCell: React.FC = React.memo(({ }; const handleContextMenu = (e: React.MouseEvent) => { - if (!editable) return; + if (!cellContextMenuContext) return; e.preventDefault(); e.stopPropagation(); // 阻止冒泡到行级菜单 - if (cellContextMenuContext) { - cellContextMenuContext.showMenu(e, record, dataIndex, title); - } + cellContextMenuContext.showMenu(e, record, dataIndex, title); }; let childNode = children; @@ -611,6 +609,13 @@ const EditableCell: React.FC = React.memo(({ {children} ); + } else if (cellContextMenuContext) { + // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 + childNode = ( +
+ {children} +
+ ); } const handleDoubleClick = () => { @@ -3081,8 +3086,8 @@ const DataGrid: React.FC = ({ }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { - if (!col.editable) return col as ColumnType; const dataIndex = String(col.dataIndex); + // 即使不可编辑,也需要通过 onCell/render 绑定右键菜单 return { ...col, onCell: (record: Item) => { @@ -3092,7 +3097,16 @@ const DataGrid: React.FC = ({ 'data-col-name': dataIndex, }; - if (!enableInlineEditableCell) { + if (col.editable && enableInlineEditableCell) { + // 可编辑模式(非虚拟):传递给 EditableCell 的 props + cellProps.record = record; + cellProps.editable = col.editable; + cellProps.dataIndex = col.dataIndex; + cellProps.title = dataIndex; + cellProps.handleSave = handleCellSave; + cellProps.focusCell = openCellEditor; + } else if (col.editable && !enableInlineEditableCell) { + // 可编辑但非 inline(虚拟模式下):双击和右键通过 onCell 绑定 cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex); cellProps.onContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -3100,12 +3114,12 @@ const DataGrid: React.FC = ({ showCellContextMenu(e, record, dataIndex, dataIndex); }; } else { - cellProps.record = record; - cellProps.editable = col.editable; - cellProps.dataIndex = col.dataIndex; - cellProps.title = dataIndex; - cellProps.handleSave = handleCellSave; - cellProps.focusCell = openCellEditor; + // 不可编辑(只读查询结果):只绑定右键菜单 + cellProps.onContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + showCellContextMenu(e, record, dataIndex, dataIndex); + }; } return cellProps; }, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 1a92a5f..8212a93 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1313,6 +1313,72 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return selected; }; + // 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL + const handleReloadResult = async (resultKey: string, sql: string) => { + if (!sql?.trim() || !currentDb) return; + const conn = connections.find(c => c.id === currentConnectionId); + if (!conn) return; + + const config = { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: conn.config.database || "", + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + + try { + setLoading(true); + // 使用 DBQueryMulti 保持和首次查询一致的后端路径 + let queryId: string; + try { + queryId = await GenerateQueryID(); + } catch { + queryId = 'reload-' + Date.now(); + } + const res = await DBQueryMulti(config as any, currentDb, sql, queryId); + if (!res?.success) { + message.error('刷新失败: ' + (res?.message || '未知错误')); + return; + } + + // 取第一个结果集(单条 SQL 只有一个结果集) + const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; + if (resultSetDataArray.length === 0) return; + const rsData = resultSetDataArray[0]; + const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 + && rsData.columns && rsData.columns.length === 1 + && rsData.columns[0] === 'affectedRows'; + if (isAffectedResult) return; // 不应该出现,但保险起见 + + let rows = Array.isArray(rsData.rows) ? rsData.rows : []; + const maxRows = Number(queryOptions?.maxRows) || 0; + let truncated = false; + if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { + truncated = true; + rows = rows.slice(0, maxRows); + } + const cols = (rsData.columns && rsData.columns.length > 0) + ? rsData.columns + : (rows.length > 0 ? Object.keys(rows[0]) : []); + rows.forEach((row: any, i: number) => { + if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; + }); + + // 只更新匹配的结果集的 rows 和 columns,保留 tableName/pkColumns/readOnly 等元数据 + setResultSets(prev => prev.map(rs => + rs.key === resultKey + ? { ...rs, rows, columns: cols, truncated } + : rs + )); + } catch (err: any) { + message.error('刷新失败: ' + (err?.message || '未知错误')); + } finally { + setLoading(false); + } + }; + const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; @@ -1601,7 +1667,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { let simpleTableName: string | undefined = undefined; if (rawStatement) { - const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); + // 支持多行 SQL:SELECT * FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等 + const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+(?:[\w`"]+\.)?[`"]?(\w+)[`"]?\s*(?:$|[\s;])/im); if (tableMatch) { simpleTableName = tableMatch[1]; if (!forceReadOnlyResult) { @@ -2060,7 +2127,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { dbName={currentDb} connectionId={currentConnectionId} pkColumns={rs.pkColumns} - onReload={handleRun} + onReload={() => handleReloadResult(rs.key, rs.sql)} readOnly={rs.readOnly} /> From 5afd80c559465af8b51d13b805542d6c5ebce1e9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 12:55:16 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9C=A8=20feat(about/update):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96macOS=E4=B8=8B=E8=BD=BD=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=85=B3=E4=BA=8E=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动打开目录:macOS下载完成后根据用户是否点了"隐藏到后台"决定是否自动打开下载目录 - 文件校验兜底:打开安装目录失败时清除已下载状态,允许重新下载 - 缓存同步修复:checkForUpdates以后端downloaded字段为准,清除过期的本地ref缓存 - 关于弹窗重构:已下载状态直接显示"打开安装目录"主操作按钮,无需经下载进度中转 - 按钮互斥优化:下载中隐藏"下载更新"和"本次不再提示",显示"下载进度" - 按钮排版调整:主操作按钮置右侧高亮,各状态下按钮层次分明 --- frontend/src/App.tsx | 62 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9d22ee..31cf7c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -657,6 +657,7 @@ function App() { const activeTabId = useStore(state => state.activeTabId); const updateCheckInFlightRef = React.useRef(false); const updateDownloadInFlightRef = React.useRef(false); + const updateUserDismissedRef = React.useRef(false); const updateDownloadedVersionRef = React.useRef(null); const updateInstallTriggeredVersionRef = React.useRef(null); const updateDownloadMetaRef = React.useRef(null); @@ -745,6 +746,7 @@ function App() { return; } updateDownloadInFlightRef.current = true; + updateUserDismissedRef.current = false; updateDownloadMetaRef.current = null; setUpdateDownloadProgress({ open: true, @@ -789,7 +791,18 @@ function App() { } else { void message.success({ content: '更新下载完成', duration: 2 }); } - setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`); + setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击"下载进度"后安装)`); + // macOS:如果用户没有主动隐藏进度弹窗,则下载完成后自动打开下载目录 + if (isMacRuntime && !updateUserDismissedRef.current) { + try { + const openRes = await (window as any).go.app.App.OpenDownloadedUpdateDirectory(); + if (openRes?.success) { + void message.success(openRes?.message || '已打开安装目录,请手动完成替换'); + } + } catch (e) { + console.warn('自动打开下载目录失败', e); + } + } } else { setUpdateDownloadProgress(prev => ({ ...prev, @@ -820,18 +833,34 @@ function App() { && updateDownloadProgress.version === lastUpdateInfo?.latestVersion && (updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' + || updateDownloadProgress.status === 'done' || updateDownloadProgress.status === 'error'); const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate) && updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null); const handleInstallFromProgress = React.useCallback(async () => { - if (updateDownloadProgress.status !== 'done') { + // 允许从下载进度弹窗(status=done)或关于弹窗(isLatestUpdateDownloaded=true)触发 + const canInstall = updateDownloadProgress.status === 'done' + || (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion)); + if (!canInstall) { return; } if (isMacRuntime) { const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory(); if (!res?.success) { void message.error('打开安装目录失败: ' + (res?.message || '未知错误')); + // 文件可能已被用户删除,清除已下载状态以允许重新下载 + updateDownloadedVersionRef.current = null; + updateDownloadMetaRef.current = null; + setUpdateDownloadProgress(prev => ({ + ...prev, + status: 'idle', + percent: 0, + downloaded: 0, + open: false, + })); + setLastUpdateInfo(prev => prev ? { ...prev, downloaded: false, downloadPath: undefined } : prev); + setAboutUpdateStatus(prev => prev.replace('已下载', '未下载')); return; } updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; @@ -846,7 +875,7 @@ function App() { } updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; hideUpdateDownloadProgress(); - }, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, updateDownloadProgress.status, updateDownloadProgress.version]); + }, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, lastUpdateInfo?.hasUpdate, lastUpdateInfo?.downloaded, updateDownloadProgress.status, updateDownloadProgress.version]); const checkForUpdates = React.useCallback(async (silent: boolean) => { if (updateCheckInFlightRef.current) return; @@ -867,6 +896,11 @@ function App() { if (!info) return; const aboutOpen = isAboutOpenRef.current; if (info.hasUpdate) { + // 以后端校验为准:如果后端确认文件不存在(downloaded=false),清除本地 ref + if (!info.downloaded && updateDownloadedVersionRef.current === info.latestVersion) { + updateDownloadedVersionRef.current = null; + updateDownloadMetaRef.current = null; + } const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion; const hasDownloaded = Boolean(info.downloaded) || localDownloaded; if (hasDownloaded) { @@ -1719,17 +1753,22 @@ function App() { onCancel={() => setIsAboutOpen(false)} styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }} footer={[ - canShowProgressEntry ? ( + isBackgroundProgressForLatestUpdate && !isLatestUpdateDownloaded ? ( ) : null, - lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded ? ( - - ) : null, - lastUpdateInfo?.hasUpdate ? ( + lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( ) : null, , - + , + lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( + + ) : null, + isLatestUpdateDownloaded ? ( + + ) : null, ].filter(Boolean)} > {aboutLoading ? ( @@ -2162,7 +2201,10 @@ function App() { footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [ From ccb9f09452af600b660cb25f439e03130a913b2e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 12:59:24 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=90=9B=20fix(store):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BF=9D=E5=AD=98=E6=9F=A5=E8=AF=A2=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E6=AC=A1=E6=89=93=E5=BC=80=E4=BA=A7=E7=94=9F=E9=87=8D=E5=A4=8D?= =?UTF-8?q?Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增语义去重:addTab对query类型按savedQueryId匹配已有Tab - 匹配条件覆盖savedQueryId相同或Tab id等于savedQueryId两种场景 - 命中已有Tab时复用并激活,避免重复创建 - refs #280 --- frontend/src/store.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 0833ae2..e081769 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -736,6 +736,18 @@ export const useStore = create()( return { tabs: newTabs, activeTabId: existingTab.id }; } } + // 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开) + if (tab.type === 'query' && tab.savedQueryId) { + const savedQueryIndex = state.tabs.findIndex(t => + t.type === 'query' && (t.savedQueryId === tab.savedQueryId || t.id === tab.savedQueryId) + ); + if (savedQueryIndex !== -1) { + const existingTab = state.tabs[savedQueryIndex]; + const newTabs = [...state.tabs]; + newTabs[savedQueryIndex] = { ...existingTab, ...tab, id: existingTab.id }; + return { tabs: newTabs, activeTabId: existingTab.id }; + } + } return { tabs: [...state.tabs, tab], activeTabId: tab.id }; }), From cd5a0e85e840b44763ad67cf224be5f8b5ef99b1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 13:22:10 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E9=9D=A2=E6=9D=BF=E6=96=B0=E5=A2=9E=E5=A4=9A=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8E=92=E5=BA=8F=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=AF=E7=94=A8=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 排序扩展:SortInfo 类型从单字段扩展为数组,SQL 和 MongoDB 均支持多字段 ORDER BY - 筛选面板:新增排序配置区域,支持动态添加/删除多个排序字段及启用/禁用 - 表头联动:启用 Ant Design 多列排序模式,表头排序图标与筛选面板双向同步 - 增量更新:表头点击排序时在现有排序数组中增量更新,不覆盖其他字段 - 循环优化:表头排序从"升序→降序→取消"改为"升序↔降序"切换 - 布局优化:操作按钮栏增加分隔符分组,排序区域与按钮间增加视觉分隔 - refs #279 --- frontend/src/components/DataGrid.tsx | 151 +++++++++++++++++++------ frontend/src/components/DataViewer.tsx | 33 ++++-- frontend/src/utils/mongodb.ts | 28 +++-- frontend/src/utils/sql.ts | 51 +++++++-- 4 files changed, 199 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index d84b807..bc85e60 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -28,7 +28,7 @@ import { useStore } from '../store'; import type { ColumnDefinition } from '../types'; import { v4 as generateUuid } from 'uuid'; import 'react-resizable/css/styles.css'; -import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; @@ -726,7 +726,7 @@ interface DataGridProps { }; onRequestTotalCount?: () => void; onCancelTotalCount?: () => void; - sortInfoExternal?: { columnKey: string, order: string } | null; + sortInfoExternal?: Array<{ columnKey: string, order: string, enabled?: boolean }>; // Filtering showFilter?: boolean; onToggleFilter?: () => void; @@ -1148,25 +1148,18 @@ const DataGrid: React.FC = ({ } }; - const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); + const [sortInfo, setSortInfo] = useState>([]); const [columnWidths, setColumnWidths] = useState>({}); const [columnMetaMap, setColumnMetaMap] = useState>({}); const columnMetaCacheRef = useRef>>({}); const columnMetaSeqRef = useRef(0); useEffect(() => { - const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend' - ? sortInfoExternal.order - : ''; - const nextColumn = nextOrder ? String(sortInfoExternal?.columnKey || '') : ''; - const currColumn = String(sortInfo?.columnKey || ''); - const currOrder = sortInfo?.order === 'ascend' || sortInfo?.order === 'descend' ? sortInfo.order : ''; - if (nextColumn === currColumn && nextOrder === currOrder) return; - if (!nextColumn || !nextOrder) { - setSortInfo(null); - } else { - setSortInfo({ columnKey: nextColumn, order: nextOrder }); - } + const ext = sortInfoExternal || []; + const extKey = JSON.stringify(ext); + const curKey = JSON.stringify(sortInfo); + if (extKey === curKey) return; + setSortInfo(ext); }, [sortInfoExternal, sortInfo]); useEffect(() => { @@ -2568,22 +2561,39 @@ const DataGrid: React.FC = ({ const handleTableChange = useCallback((_pag: any, _filtersArg: any, sorter: any) => { if (isResizingRef.current) return; // Block sort if resizing - if (sorter.field) { - const field = String(sorter.field); - const order = sorter.order as string; - const normalizedOrder = order === 'ascend' || order === 'descend' ? order : ''; - if (!normalizedOrder) { - setSortInfo(null); - if (onSort) onSort('', ''); - return; - } - setSortInfo({ columnKey: field, order: normalizedOrder }); - if (onSort) onSort(field, normalizedOrder); - } else { - setSortInfo(null); - if (onSort) onSort('', ''); + // Ant Design 多列排序模式下 sorter 可能是数组 + const sorters = Array.isArray(sorter) ? sorter : (sorter?.field ? [sorter] : []); + if (sorters.length === 0) { + setSortInfo([]); + if (onSort) onSort(JSON.stringify([]), ''); + return; } - }, [onSort]); + // 在现有排序数组基础上增量更新 + const next = [...sortInfo]; + for (const s of sorters) { + const field = String(s.field || ''); + if (!field) continue; + const order = s.order as string; + const normalizedOrder = order === 'ascend' || order === 'descend' ? order : ''; + const existIdx = next.findIndex(item => item.columnKey === field); + if (!normalizedOrder) { + // Ant Design 第三次点击想取消排序: + // 如果该字段已在排序数组中,回转为升序而非移除 + if (existIdx >= 0) { + next[existIdx] = { ...next[existIdx], order: 'ascend', enabled: true }; + } + // 不在数组中则忽略 + } else if (existIdx >= 0) { + // 已存在:更新排序方向 + next[existIdx] = { ...next[existIdx], order: normalizedOrder, enabled: true }; + } else { + // 不存在:追加到末尾 + next.push({ columnKey: field, order: normalizedOrder, enabled: true }); + } + } + setSortInfo(next); + if (onSort) onSort(JSON.stringify(next), ''); + }, [onSort, sortInfo]); // Native Drag State const draggingRef = useRef<{ @@ -3043,8 +3053,8 @@ const DataGrid: React.FC = ({ key: key, // 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为 width: columnWidths[key] || 200, - sorter: !!onSort, - sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined, + sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false, + sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined, editable: canModifyData, // Only editable if table name known and not readonly render: (text: any) => (
@@ -3402,10 +3412,10 @@ const DataGrid: React.FC = ({ const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); const normalizedType = String(dbType || '').trim().toLowerCase(); - const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend'); + const hasSortForBuffer = hasExplicitSort(sortInfo); const offset = (pagination.current - 1) * pagination.pageSize; let sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, pagination.pageSize, offset); - if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { + if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } return sql; @@ -4523,7 +4533,7 @@ const DataGrid: React.FC = ({
{showFilter && ( -
= ({
))} -
+ {onSort && ( +
0 ? 4 : 0, borderTop: filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}> + {sortInfo.map((s, idx) => ( +
+ { + const next = [...sortInfo]; + next[idx] = { ...next[idx], enabled: e.target.checked }; + onSort(JSON.stringify(next), ''); + }} + style={{ flex: '0 0 auto' }} + /> + {idx === 0 ? '排序' : '然后'} + { + const next = [...sortInfo]; + next[idx] = { ...next[idx], order: v }; + onSort(JSON.stringify(next), ''); + }} + options={[ + { value: 'ascend', label: '升序 ↑' }, + { value: 'descend', label: '降序 ↓' }, + ]} + disabled={!s.columnKey} + /> +
+ ))} + +
+ )} +
0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}> +
+
diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index afa3e7e..8929546 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; -import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; @@ -157,7 +157,7 @@ type ViewerFilterSnapshot = { conditions: FilterCondition[]; currentPage: number; pageSize: number; - sortInfo: { columnKey: string, order: string } | null; + sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>; scrollTop: number; scrollLeft: number; }; @@ -185,16 +185,17 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); if (!cached) { - return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 }; + return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 }; } return { showFilter: cached.showFilter === true, conditions: normalizeViewerFilterConditions(cached.conditions), currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1, pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100, - sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend') - ? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order } - : null, + sortInfo: Array.isArray(cached.sortInfo) + ? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')) + .map(s => ({ columnKey: String(s.columnKey), order: s.order })) + : (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []), scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0, scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0, }; @@ -238,7 +239,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { totalCountCancelled: false, }); - const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo); + const [sortInfo, setSortInfo] = useState>(initialViewerSnapshot.sortInfo); const [showFilter, setShowFilter] = useState(initialViewerSnapshot.showFilter); const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); @@ -511,7 +512,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } }; - const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend'); + const hasSort = hasExplicitSort(sortInfo); const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || '')); let resData = await executeDataQuery(sql, '主查询'); @@ -788,13 +789,21 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { fetchData(pagination.current, pagination.pageSize); }, [fetchData, pagination.current, pagination.pageSize]); const handleSort = useCallback((field: string, order: string) => { + // 支持多字段排序:field 为 JSON 数组字符串时解析为多字段 + try { + const parsed = JSON.parse(field); + if (Array.isArray(parsed)) { + setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))); + return; + } + } catch { /* 单字段模式 */ } const normalizedOrder = order === 'ascend' || order === 'descend' ? order : ''; const normalizedField = String(field || '').trim(); if (!normalizedField || !normalizedOrder) { - setSortInfo(null); + setSortInfo([]); return; } - setSortInfo({ columnKey: normalizedField, order: normalizedOrder }); + setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]); }, []); const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]); const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []); @@ -811,8 +820,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; sql += buildOrderBySQL(dbType, sortInfo, pkColumns); const normalizedType = dbType.toLowerCase(); - const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend'); - if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { + const hasSortForBuffer = hasExplicitSort(sortInfo); + if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } return sql; diff --git a/frontend/src/utils/mongodb.ts b/frontend/src/utils/mongodb.ts index 577c5af..646d51e 100644 --- a/frontend/src/utils/mongodb.ts +++ b/frontend/src/utils/mongodb.ts @@ -1,10 +1,13 @@ import type { FilterCondition } from './sql'; import { parseListValues } from './sql'; -type SortInfo = { +type SortInfoItem = { columnKey?: string; order?: string; -} | null | undefined; + enabled?: boolean; +}; + +type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined; type ShellConvertResult = { recognized: boolean; @@ -607,14 +610,24 @@ export const buildMongoSort = ( sortInfo: SortInfo, fallbackColumns: string[] = [], ): Record | undefined => { - const sortColumn = String(sortInfo?.columnKey || '').trim(); - const sortOrder = String(sortInfo?.order || ''); - if (sortColumn && (sortOrder === 'ascend' || sortOrder === 'descend')) { - return { [sortColumn]: sortOrder === 'ascend' ? 1 : -1 }; + const items = Array.isArray(sortInfo) ? sortInfo : (sortInfo ? [sortInfo] : []); + const sort: Record = {}; + const seen = new Set(); + for (const item of items) { + if (item?.enabled === false) continue; + const col = String(item?.columnKey || '').trim(); + const order = String(item?.order || ''); + if (col && (order === 'ascend' || order === 'descend')) { + const key = col.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + sort[col] = order === 'ascend' ? 1 : -1; + } + } } + if (Object.keys(sort).length > 0) return sort; const uniqueColumns: string[] = []; - const seen = new Set(); (fallbackColumns || []).forEach((col) => { const key = String(col || '').trim(); if (!key) return; @@ -625,7 +638,6 @@ export const buildMongoSort = ( }); if (uniqueColumns.length === 0) return undefined; - const sort: Record = {}; uniqueColumns.forEach((col) => { sort[col] = 1; }); diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index 4fece31..0864683 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -69,10 +69,13 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => { export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''"); -type SortInfo = { +type SortInfoItem = { columnKey?: string; order?: string; -} | null | undefined; + enabled?: boolean; +}; + +type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined; // 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。 // MySQL: 使用 Optimizer Hint `SET_VAR`。 @@ -101,17 +104,50 @@ export const withSortBufferTuningSQL = ( return rawSql; }; +/** 将 SortInfo(单字段或多字段)标准化为 SortInfoItem 数组 */ +const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => { + if (!sortInfo) return []; + if (Array.isArray(sortInfo)) return sortInfo; + return [sortInfo]; +}; + +/** 判断 SortInfo 中是否存在至少一个有效排序 */ +export const hasExplicitSort = (sortInfo: SortInfo): boolean => { + const items = normalizeSortInfoItems(sortInfo); + return items.some(item => { + if (item?.enabled === false) return false; + const col = String(item?.columnKey || '').trim(); + const order = String(item?.order || ''); + return !!col && (order === 'ascend' || order === 'descend'); + }); +}; + export const buildOrderBySQL = ( dbType: string, sortInfo: SortInfo, fallbackColumns: string[] = [], ) => { const dbTypeLower = String(dbType || '').trim().toLowerCase(); - const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || '')); - const sortOrder = String(sortInfo?.order || ''); - const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : ''; - if (sortColumn && direction) { - return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`; + const items = normalizeSortInfoItems(sortInfo); + const seen = new Set(); + const sortParts: string[] = []; + + for (const item of items) { + if (item?.enabled === false) continue; + const sortColumn = normalizeIdentPart(String(item?.columnKey || '')); + const sortOrder = String(item?.order || ''); + const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : ''; + if (sortColumn && direction) { + const key = sortColumn.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`); + } + } + } + + if (sortParts.length > 0) { + return ` ORDER BY ${sortParts.join(', ')}`; } // MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort, @@ -121,7 +157,6 @@ export const buildOrderBySQL = ( return ''; } - const seen = new Set(); const stableColumns = (fallbackColumns || []) .map((col) => normalizeIdentPart(String(col || ''))) .filter((col) => { From 8935ad2905793dd7feeedb204b608f78a4b210b3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 13:59:38 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9ATab=E5=9C=BA=E6=99=AF=E4=B8=8BSQL?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E6=8F=90=E7=A4=BA=E8=AF=BB=E5=8F=96=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:completion provider 只注册一次,闭包捕获首个 Tab 的组件 ref,切换 Tab 后仍读取旧上下文 - 修复:新增模块级共享变量,所有 QueryEditor 实例在成为活跃 Tab 时同步状态 - 共享变量:currentDb、connectionId、tables、allColumns、visibleDbs、columnsCache - provider 闭包改为读取共享变量,确保始终使用当前活跃 Tab 的数据库上下文 - metadata 加载和数据库列表获取后同步更新共享变量 - refs #278 --- frontend/src/components/QueryEditor.tsx | 68 ++++++++++++++++++------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 8212a93..d7d454a 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -173,6 +173,16 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [ // 模块级标志:确保 SQL completion provider 全局只注册一次 let sqlCompletionRegistered = false; +// 模块级共享变量:completion provider 从这些变量读取当前活跃 Tab 的状态。 +// 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。 +let sharedCurrentDb = ''; +let sharedCurrentConnectionId = ''; +let sharedConnections: any[] = []; +let sharedTablesData: {dbName: string, tableName: string}[] = []; +let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type: string}[] = []; +let sharedVisibleDbs: string[] = []; +let sharedColumnsCacheData: Record = {}; + const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); @@ -269,6 +279,19 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { currentDbRef.current = currentDb; }, [currentDb]); + // 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量 + // 确保 completion provider 始终使用当前活跃 Tab 的上下文 + useEffect(() => { + if (activeTabId !== tab.id) return; + sharedCurrentDb = currentDb; + sharedCurrentConnectionId = currentConnectionId; + sharedConnections = connections; + sharedTablesData = tablesRef.current; + sharedAllColumnsData = allColumnsRef.current; + sharedVisibleDbs = visibleDbsRef.current; + sharedColumnsCacheData = columnsCacheRef.current; + }, [activeTabId, tab.id, currentDb, currentConnectionId, connections]); + useEffect(() => { connectionsRef.current = connections; }, [connections]); @@ -325,6 +348,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 存储可见数据库列表用于跨库智能提示 visibleDbsRef.current = dbs; + if (activeTabId === tab.id) { + sharedVisibleDbs = dbs; + } setDbList(dbs); if (!currentDbRef.current) { @@ -333,6 +359,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } } else { visibleDbsRef.current = []; + if (activeTabId === tab.id) { + sharedVisibleDbs = []; + } setDbList([]); } }; @@ -387,6 +416,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { tablesRef.current = allTables; allColumnsRef.current = allColumns; + // 如果当前 Tab 是活跃 Tab,同步更新共享变量 + if (activeTabId === tab.id) { + sharedTablesData = allTables; + sharedAllColumnsData = allColumns; + } }; void fetchMetadata(); }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 @@ -487,8 +521,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; const buildConnConfig = () => { - const connId = currentConnectionIdRef.current; - const conn = connectionsRef.current.find(c => c.id === connId); + const connId = sharedCurrentConnectionId; + const conn = sharedConnections.find(c => c.id === connId); if (!conn) return null; return { ...conn.config, @@ -501,11 +535,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; const getColumnsByDB = async (tableIdent: string) => { - const connId = currentConnectionIdRef.current; - const dbName = currentDbRef.current; + const connId = sharedCurrentConnectionId; + const dbName = sharedCurrentDb; if (!connId || !dbName) return [] as ColumnDefinition[]; const key = `${connId}|${dbName}|${tableIdent}`; - const cached = columnsCacheRef.current[key]; + const cached = sharedColumnsCacheData[key]; if (cached) return cached; const config = buildConnConfig(); @@ -514,7 +548,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const res = await DBGetColumns(config as any, dbName, tableIdent); if (res?.success && Array.isArray(res.data)) { const cols = res.data as ColumnDefinition[]; - columnsCacheRef.current[key] = cols; + sharedColumnsCacheData[key] = cols; return cols; } return [] as ColumnDefinition[]; @@ -533,7 +567,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const colPrefix = (threePartMatch[3] || '').toLowerCase(); // 在 allColumnsRef 中查找匹配的列 - const cols = allColumnsRef.current.filter(c => + const cols = sharedAllColumnsData.filter(c => (c.dbName || '').toLowerCase() === dbPart.toLowerCase() && (c.tableName || '').toLowerCase() === tablePart.toLowerCase() ); @@ -561,10 +595,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const qualifierLower = qualifier.toLowerCase(); // 首先检查 qualifier 是否是数据库名(跨库表提示) - const visibleDbs = visibleDbsRef.current; + const visibleDbs = sharedVisibleDbs; if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 - const tables = tablesRef.current.filter(t => + const tables = sharedTablesData.filter(t => (t.dbName || '').toLowerCase() === qualifierLower ); const filtered = prefix @@ -583,7 +617,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table - const schemaTables = tablesRef.current + const schemaTables = sharedTablesData .map(t => { const parsed = splitSchemaAndTable(t.tableName || ''); return { @@ -627,7 +661,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 解析 db.table 或 table 格式 const parts = tableIdent.split('.'); - let dbName = currentDbRef.current || ''; + let dbName = sharedCurrentDb || ''; let tableName = tableIdent; if (parts.length === 2) { dbName = parts[0]; @@ -649,8 +683,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { if (tableInfo) { // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; - if (allColumnsRef.current.length > 0) { - cols = allColumnsRef.current + if (sharedAllColumnsData.length > 0) { + cols = sharedAllColumnsData .filter(c => (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() @@ -688,7 +722,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { foundTables.add(t.toLowerCase()); } - const currentDatabase = currentDbRef.current || ''; + const currentDatabase = sharedCurrentDb || ''; const wordPrefix = (word.word || '').toLowerCase(); const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix); const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim()); @@ -703,7 +737,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等) // 权重最高,输入 WHERE 条件时优先显示 - const relevantColumns = allColumnsRef.current + const relevantColumns = sharedAllColumnsData .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); @@ -723,7 +757,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }); // 表提示:当前库显示表名,其他库显示 db.table 格式 - const tableSuggestions = tablesRef.current + const tableSuggestions = sharedTablesData .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; @@ -744,7 +778,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }); // 数据库提示 - const dbSuggestions = visibleDbsRef.current + const dbSuggestions = sharedVisibleDbs .filter((db) => startsWithPrefix(db)) .map(db => ({ label: db, From da5e879409873e0f7bc65281ea9364cb8473f344 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 14:10:23 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=8D=E5=88=B6=E4=B8=BAINSERT/CSV/Markdow?= =?UTF-8?q?n=E5=AD=97=E6=AE=B5=E4=B9=B1=E5=BA=8F=E5=8F=8A=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E5=AD=97=E7=AC=A6=E6=9C=AA=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INSERT:使用 columnNames 保持 DDL 字段顺序,值中单引号转义为 '' - CSV:使用 columnNames 保持字段顺序,值中双引号转义为 "",增加表头行 - Markdown:使用 columnNames 保持字段顺序,转义管道符和换行,增加表头行和分隔行 - refs #277 --- frontend/src/components/DataGrid.tsx | 48 ++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index bc85e60..80710df 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -673,11 +673,20 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { { key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) }, { key: 'copy', label: '复制为 Markdown', icon: , onClick: () => { const records = getTargets(); - const lines = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - return `| ${Object.values(vals).join(' | ')} |`; + const orderedCols = displayDataRef.current.length > 0 + ? Object.keys(displayDataRef.current[0]).filter(c => c !== GONAVI_ROW_KEY) + : []; + const header = `| ${orderedCols.join(' | ')} |`; + const separator = `| ${orderedCols.map(() => '---').join(' | ')} |`; + const rows = records.map((r: any) => { + const values = orderedCols.map(c => { + const v = r[c]; + if (v === null || v === undefined) return 'NULL'; + return String(v).replace(/\|/g, '\\|').replace(/\n/g, ' '); + }); + return `| ${values.join(' | ')} |`; }); - copyToClipboard(lines.join('\n')); + copyToClipboard([header, separator, ...rows].join('\n')); } }, { type: 'divider' }, { @@ -3324,14 +3333,19 @@ const DataGrid: React.FC = ({ return; } const records = getTargets(record); + // 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序 + const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY); const sqlList = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - const cols = Object.keys(vals); - const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`); + const values = orderedCols.map(c => { + const v = r[c]; + if (v === null || v === undefined) return 'NULL'; + const escaped = String(v).replace(/'/g, "''"); + return `'${escaped}'`; + }); const targetTable = tableName || 'table'; - return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`; + return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`; }); - copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, getTargets, copyToClipboard]); + copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]); const handleCopyJson = useCallback((record: any) => { const records = getTargets(record); @@ -3344,13 +3358,21 @@ const DataGrid: React.FC = ({ const handleCopyCsv = useCallback((record: any) => { const records = getTargets(record); + // 使用 columnNames 保持表定义的字段顺序 + const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY); + const header = orderedCols.map(c => `"${c}"`).join(','); const lines = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`); + const values = orderedCols.map(c => { + const v = r[c]; + if (v === null || v === undefined) return 'NULL'; + // CSV 标准:值中的双引号转义为两个双引号 + const escaped = String(v).replace(/"/g, '""'); + return `"${escaped}"`; + }); return values.join(','); }); - copyToClipboard(lines.join('\n')); - }, [getTargets, copyToClipboard]); + copyToClipboard([header, ...lines].join('\n')); + }, [getTargets, columnNames, copyToClipboard]); const buildConnConfig = useCallback(() => { if (!connectionId) return null; From a1b546ddd9c87167e9afb389472f173a05242d38 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 14:35:45 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=97=B6=E9=97=B4=E7=B1=BB=E5=9E=8B=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=9B=86=E6=88=90DatePicker=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型识别:根据列元数据自动识别datetime/date/time/year类型 - inline编辑:日期时间列双击弹出DatePicker替代纯文本Input - 行编辑器:日期时间字段使用DatePicker组件 - 交互优化:datetime类型需点"确定"按钮才保存,date/time/year即选即保存 - 取消支持:datetime选择器点击外部自动取消编辑,不保存 - 值转换:编辑时字符串↔dayjs自动转换,无效日期回退为文本输入 - refs #276 --- frontend/src/components/DataGrid.tsx | 205 +++++++++++++++++++++++---- 1 file changed, 174 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 80710df..b5a1ff4 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,7 +1,8 @@ // cspell:ignore anticon sqls uuidv uuidv4 hscroll import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; @@ -156,6 +157,43 @@ const isTemporalColumnType = (columnType?: string): boolean => { 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 { @@ -512,6 +550,7 @@ interface EditableCellProps { record: Item; handleSave: (record: Item) => void; focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; + columnType?: string; as?: any; [key: string]: any; } @@ -524,6 +563,7 @@ const EditableCell: React.FC = React.memo(({ record, handleSave, focusCell, + columnType, as: Component = 'td', ...restProps }) => { @@ -541,9 +581,15 @@ const EditableCell: React.FC = React.memo(({ const toggleEdit = () => { setEditing(!editing); const raw = record[dataIndex]; - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; const fieldName = getCellFieldName(record, dataIndex); - setCellFieldValue(form, fieldName, initialValue); + if (isDateTimeField) { + // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 + const dayjsVal = parseToDayjs(raw, pickerType); + setCellFieldValue(form, fieldName, dayjsVal); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } }; const save = async () => { @@ -551,7 +597,13 @@ const EditableCell: React.FC = React.memo(({ if (!form) return; const fieldName = getCellFieldName(record, dataIndex); await form.validateFields([fieldName]); - const nextValue = form.getFieldValue(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; + } toggleEdit(); // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { @@ -575,30 +627,66 @@ const EditableCell: React.FC = React.memo(({ let childNode = children; + const pickerType = getTemporalPickerType(columnType); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); + if (editable) { childNode = editing ? ( - { - // Enter 编辑态时直接全选,便于快速替换;同时避免双击在 input 内冒泡导致关闭编辑态。 - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - onDoubleClick={(e) => { - e.stopPropagation(); - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - /> + {isDateTimeField ? ( + pickerType === 'time' ? ( + setTimeout(save, 0)} + needConfirm={false} + /> + ) : pickerType === 'datetime' ? ( + setTimeout(save, 0)} + onOpenChange={(open) => { + // 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存 + if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); + }} + needConfirm + /> + ) : ( + setTimeout(save, 0)} + needConfirm={false} + /> + ) + ) : ( + { + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> + )} ) : (
= ({ const displayVal = (displayRow as any)?.[col]; baseRawMap[col] = baseVal; displayMap[col] = toFormText(displayVal); - formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); + // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + if (rowPickerType && displayVal !== null && displayVal !== undefined) { + const dVal = parseToDayjs(displayVal, rowPickerType); + formMap[col] = dVal; + } else { + formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); + } if (baseVal === null || baseVal === undefined) nullCols.add(col); }); @@ -2868,7 +2964,7 @@ const DataGrid: React.FC = ({ rowEditorForm.setFieldsValue(formMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); - }, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr]); + }, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]); const openRowEditor = useCallback(() => { if (!canModifyData) return; @@ -3028,7 +3124,18 @@ const DataGrid: React.FC = ({ const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); if (isAdded) { - setAddedRows(prev => prev.map(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr ? { ...r, ...values } : r)); + // 日期时间类型: 将 dayjs 对象转回格式化字符串 + const convertedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + if (val && dayjs.isDayjs(val)) { + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + convertedValues[col] = formatFromDayjs(val as dayjs.Dayjs, rowPickerType); + } else { + convertedValues[col] = val; + } + }); + setAddedRows(prev => prev.map(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr ? { ...r, ...convertedValues } : r)); closeRowEditor(); return; } @@ -3036,7 +3143,13 @@ const DataGrid: React.FC = ({ const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; columnNames.forEach((col) => { - const nextVal = values[col]; + let nextVal = values[col]; + // 日期时间类型: 将 dayjs 对象转回格式化字符串 + if (nextVal && dayjs.isDayjs(nextVal)) { + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + nextVal = formatFromDayjs(nextVal as dayjs.Dayjs, rowPickerType); + } const baseVal = baseRawMap[col]; if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; }); @@ -3124,6 +3237,7 @@ const DataGrid: React.FC = ({ cellProps.title = dataIndex; cellProps.handleSave = handleCellSave; cellProps.focusCell = openCellEditor; + cellProps.columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; } else if (col.editable && !enableInlineEditableCell) { // 可编辑但非 inline(虚拟模式下):双击和右键通过 onCell 绑定 cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex); @@ -3153,6 +3267,7 @@ const DataGrid: React.FC = ({ record={record} handleSave={handleCellSave} focusCell={openCellEditor} + columnType={(columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type} as="div" style={VIRTUAL_CELL_WRAPPER_STYLE} > @@ -3177,7 +3292,7 @@ const DataGrid: React.FC = ({ return originalRenderContent; } }; - }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu]); + }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu, columnMetaMap, columnMetaMapByLowerName]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -4750,12 +4865,40 @@ const DataGrid: React.FC = ({ const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; const isJson = looksLikeJsonText(sample); const useArea = isJson || sample.includes('\n') || sample.length >= 160; + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const rowPickerType = getTemporalPickerType(colMeta?.type); + const isRowDateTimeField = !!rowPickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); return (
- {useArea ? ( + {isRowDateTimeField ? ( + rowPickerType === 'time' ? ( + + ) : rowPickerType === 'datetime' ? ( + + ) : ( + + ) + ) : useArea ? ( Date: Fri, 20 Mar 2026 14:50:18 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DSQL=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E9=99=90=E5=88=B6=E5=92=8C=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除每次执行SQL重复弹出"结果集已自动限制"的warning提示 - 用户手写LIMIT时尊重原始结果,不再被前端maxRows截断 - 结果集tab标签显示精确行数,去掉"1000+"的+号后缀 - refs #275 --- frontend/src/components/QueryEditor.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index d7d454a..5f66560 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1575,9 +1575,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } else if (nextResultSets.length === 0) { message.success('执行成功。'); } - if (anyTruncated && maxRows > 0) { - message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); - } + } else { // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 let fullSQL = normalizedRawSQL; @@ -1590,10 +1588,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 自动给 SELECT 语句注入行数限制(防止大结果集卡死) const maxRowsForLimit = Number(queryOptions?.maxRows) || 0; + let anyLimitApplied = false; if (Number.isFinite(maxRowsForLimit) && maxRowsForLimit > 0) { const stmts = splitSQLStatements(fullSQL); const limitedStmts = stmts.map(s => { const result = applyAutoLimit(s, normalizedDbType, maxRowsForLimit); + if (result.applied) anyLimitApplied = true; return result.sql; }); fullSQL = limitedStmts.join(';\n'); @@ -1686,7 +1686,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } else { let rows = Array.isArray(rsData.rows) ? rsData.rows : []; let truncated = false; - if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { + // 仅当前端自动注入了 LIMIT 时才做兜底截断;用户手写 LIMIT 时尊重原始结果 + if (anyLimitApplied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); @@ -1755,9 +1756,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } else if (nextResultSets.length === 0) { message.success('执行成功。'); } - if (anyTruncated && maxRows > 0) { - message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); - } + } } catch (e: any) { message.error("Error executing query: " + e.message); @@ -2116,7 +2115,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { {(() => { const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows'; if (isAffected) return `结果 ${idx + 1} ✓`; - return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`; + return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length})` : ''}`; })()} From 7ddef7096b0d7c1628e3d61ed261b15827ffd685 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 15:00:00 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=90=9B=20fix(editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DDDL=E8=A7=86=E5=9B=BEstickyScroll=E9=A6=96=E8=A1=8C?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E9=80=8F=E6=98=8E=E8=83=8C=E6=99=AF=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=AD=97=E7=AC=A6=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因定位:TableDesigner和TriggerViewer中局部主题定义覆盖了全局配置 - 全局主题新增editorStickyScroll.background不透明背景色 - 移除TableDesigner.tsx中重复的透明主题定义(26行) - 移除TriggerViewer.tsx中重复的透明主题定义(26行) - 清理未使用的loader import - refs #274 --- frontend/src/components/TableDesigner.tsx | 26 +-------------------- frontend/src/components/TriggerViewer.tsx | 28 ++--------------------- frontend/src/main.tsx | 4 ++-- 3 files changed, 5 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 2697949..8c7924a 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -290,31 +290,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { setCommentEditorValue(''); }, []); - // 初始化透明 Monaco Editor 主题 - useEffect(() => { - loader.init().then(monaco => { - monaco.editor.defineTheme('transparent-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#ffffff10', - 'editorGutter.background': '#00000000', - } - }); - monaco.editor.defineTheme('transparent-light', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000010', - 'editorGutter.background': '#00000000', - } - }); - }); - }, []); + // 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景) useEffect(() => { if (!containerRef.current) return; diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index d0a91be..849a7ca 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import Editor, { loader } from '@monaco-editor/react'; +import Editor from '@monaco-editor/react'; import { Spin, Alert } from 'antd'; import { TabData } from '../types'; import { useStore } from '../store'; @@ -18,31 +18,7 @@ const TriggerViewer: React.FC = ({ tab }) => { const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; - // 初始化透明 Monaco Editor 主题 - useEffect(() => { - loader.init().then(monaco => { - monaco.editor.defineTheme('transparent-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#ffffff10', - 'editorGutter.background': '#00000000', - } - }); - monaco.editor.defineTheme('transparent-light', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000010', - 'editorGutter.background': '#00000000', - } - }); - }); - }, []); + // 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景) const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7ab4fee..99c8104 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -42,11 +42,11 @@ if (typeof window !== 'undefined' && !(window as any).go) { // 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义 monaco.editor.defineTheme('transparent-dark', { base: 'vs-dark', inherit: true, rules: [], - colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' } + colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' } }) monaco.editor.defineTheme('transparent-light', { base: 'vs', inherit: true, rules: [], - colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' } + colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' } }) ReactDOM.createRoot(document.getElementById('root')!).render( From 84579b83c9d2cc6a8fca4052041f5eaa989c648f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 15:21:47 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E2=9C=A8=20feat(TableDesigner):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=BE=E8=AE=A1=E8=A1=A8=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 滚动优化:修复 Tab 切换时 ResizeObserver 高度归零导致表格异常滚动 - 零高度守卫:移除 activeKey 依赖,跳过 display:none 时的零高度观测 - 触发器统一:触发器 Tab 补充 scroll={{ y: tableHeight }} 与索引/外键保持一致 - 批量删除:handleDeleteIndex 支持多选索引批量生成 DROP SQL 合并执行 - 交互优化:删除确认弹窗展示选中索引数量和名称列表 - 状态清理:批量删除成功后自动清空 selectedIndexKeys - refs #273 --- frontend/src/components/TableDesigner.tsx | 43 +++++++++++++++++------ 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 8c7924a..ee2335f 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -292,17 +292,21 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景) + // 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y + // 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0,跳过该次更新保持有效值 useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { - const h = Math.max(200, entry.contentRect.height - 40); - setTableHeight(h); + const h = entry.contentRect.height; + // 跳过零高度观测(Tab 面板被隐藏时) + if (h <= 0) return; + setTableHeight(Math.max(200, h - 40)); } }); resizeObserver.observe(containerRef.current); return () => resizeObserver.disconnect(); - }, [activeKey]); // Re-attach when tab switches + }, []); // 不依赖 activeKey,仅挂载一次,通过零高度守卫避免 Tab 切换异常 // --- Resizable Columns State --- const [tableColumns, setTableColumns] = useState([]); @@ -1687,28 +1691,44 @@ END;`; }; const handleDeleteIndex = () => { - if (!selectedIndex) { - message.warning('请先选择一个索引'); + if (selectedIndexKeys.length === 0) { + message.warning('请先选择要删除的索引'); return; } if (!supportsIndexSchemaOps()) { message.warning('当前数据库暂不支持在此维护索引'); return; } + // 根据选中的 key 找到对应的索引对象 + const toDelete = groupedIndexes.filter(idx => selectedIndexKeys.includes(idx.key)); + if (toDelete.length === 0) { + message.warning('请先选择要删除的索引'); + return; + } + const names = toDelete.map(idx => `"${idx.name}"`).join('、'); Modal.confirm({ title: '确认删除索引', icon: , - content: `确定删除索引 "${selectedIndex.name}" 吗?`, + content: toDelete.length === 1 + ? `确定删除索引 ${names} 吗?` + : `确定删除以下 ${toDelete.length} 个索引吗?\n${names}`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { - const sql = buildIndexDropSql(selectedIndex.name); - if (!sql) { - message.warning('当前数据库暂不支持删除该索引'); - return; + const sqls: string[] = []; + for (const idx of toDelete) { + const sql = buildIndexDropSql(idx.name); + if (!sql) { + message.warning(`当前数据库暂不支持删除索引 "${idx.name}"`); + return; + } + sqls.push(sql); + } + const ok = await executeSchemaSql(sqls.join('\n'), toDelete.length === 1 ? '索引删除成功' : `${toDelete.length} 个索引删除成功`); + if (ok) { + setSelectedIndexKeys([]); } - await executeSchemaSql(sql, '索引删除成功'); } }); }; @@ -2538,6 +2558,7 @@ END;`; size="small" pagination={false} loading={loading} + scroll={{ y: tableHeight }} locale={{ emptyText: }} rowSelection={{ type: 'radio', From 17e4e3ad1c77d0d081237276cb995d165e0b651b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 15:37:17 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=BA=95=E9=83=A8=E6=95=B0=E6=8D=AE=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=94=AF=E6=8C=81=E9=95=BF=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=AE=8C=E6=95=B4=E6=9F=A5=E7=9C=8B=E4=B8=8E?= =?UTF-8?q?=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏新增「数据预览」切换按钮,点击展开/收起底部面板 - 单击单元格自动更新面板内容,完整展示长文本和 JSON 数据 - 面板使用 Monaco Editor,JSON 数据自动语法高亮 - 编辑模式下支持直接修改并保存,只读模式下 Editor 设为 readOnly - 支持 JSON 一键格式化功能 - 通过 ref 追踪面板状态避免 mergedColumns 过度重渲染 - refs #271 --- frontend/src/components/DataGrid.tsx | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index b5a1ff4..bb3f353 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1112,6 +1112,14 @@ const DataGrid: React.FC = ({ const cellEditorApplyRef = useRef<((val: string) => void) | null>(null); const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorValue, setJsonEditorValue] = useState(''); + + // --- Data Preview Panel State --- + const [dataPanelOpen, setDataPanelOpen] = useState(false); + const dataPanelOpenRef = useRef(false); + const [focusedCellInfo, setFocusedCellInfo] = useState<{ record: Item; dataIndex: string; title: string } | null>(null); + const [dataPanelValue, setDataPanelValue] = useState(''); + const [dataPanelIsJson, setDataPanelIsJson] = useState(false); + const dataPanelDirtyRef = useRef(false); const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorRowKey, setRowEditorRowKey] = useState(''); const rowEditorBaseRawRef = useRef>({}); @@ -1420,6 +1428,34 @@ const DataGrid: React.FC = ({ cellEditorApplyRef.current = null; }, []); + // --- Data Preview Panel Helpers --- + const updateFocusedCell = useCallback((record: Item, dataIndex: string) => { + if (!record || !dataIndex) return; + const raw = record?.[dataIndex]; + const text = toEditableText(raw); + const isJson = looksLikeJsonText(text); + setFocusedCellInfo({ record, dataIndex, title: dataIndex }); + // 仅在面板未被用户手动编辑时自动同步值 + if (!dataPanelDirtyRef.current) { + setDataPanelValue(text); + setDataPanelIsJson(isJson); + } + }, []); + + const handleDataPanelFormatJson = useCallback(() => { + if (!dataPanelIsJson) return; + try { + const obj = JSON.parse(dataPanelValue); + setDataPanelValue(JSON.stringify(obj, null, 2)); + dataPanelDirtyRef.current = true; + } catch (e: any) { + void message.error('JSON 格式无效:' + (e?.message || String(e))); + } + }, [dataPanelIsJson, dataPanelValue]); + + // 同步 ref 用于 onCell 闭包 + useEffect(() => { dataPanelOpenRef.current = dataPanelOpen; }, [dataPanelOpen]); + const openCellEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode, onApplyValue?: (val: string) => void) => { if (!record || !dataIndex) return; const raw = record?.[dataIndex]; @@ -2818,6 +2854,14 @@ const DataGrid: React.FC = ({ } }, [addedRows]); + const handleDataPanelSave = useCallback(() => { + if (!focusedCellInfo) return; + const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue }; + handleCellSave(nextRow); + dataPanelDirtyRef.current = false; + void message.success('已保存'); + }, [focusedCellInfo, dataPanelValue, handleCellSave]); + const handleCellSetNull = useCallback(() => { if (!cellContextMenu.record) return; handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: null }); @@ -3228,6 +3272,12 @@ const DataGrid: React.FC = ({ 'data-row-key': rowKey === undefined || rowKey === null ? undefined : String(rowKey), 'data-col-name': dataIndex, }; + // 数据预览面板:单击单元格时更新聚焦信息 + cellProps.onClick = () => { + if (dataPanelOpenRef.current) { + updateFocusedCell(record, dataIndex); + } + }; if (col.editable && enableInlineEditableCell) { // 可编辑模式(非虚拟):传递给 EditableCell 的 props @@ -4613,6 +4663,24 @@ const DataGrid: React.FC = ({ )}
+
+ +
= ({
)} + {/* Data Preview Panel */} + {dataPanelOpen && viewMode === 'table' && ( +
+
+ + {focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'} + + {focusedCellInfo && (() => { + const meta = columnMetaMap[focusedCellInfo.dataIndex] || columnMetaMapByLowerName[focusedCellInfo.dataIndex.toLowerCase()]; + return meta?.type ? ({meta.type}) : null; + })()} +
+ {dataPanelIsJson && ( + + )} + {canModifyData && focusedCellInfo && ( + + )} +
+
+ {focusedCellInfo ? ( + { + setDataPanelValue(val || ''); + dataPanelDirtyRef.current = true; + }} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + fontSize: 13, + tabSize: 2, + automaticLayout: true, + readOnly: !canModifyData, + lineNumbers: 'off', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 4, + padding: { top: 6, bottom: 6 }, + }} + /> + ) : ( +
+ 点击表格中的单元格以预览完整数据 +
+ )} +
+
+ )} + {/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */} {viewMode === 'table' && cellContextMenu.visible && createPortal(
Date: Fri, 20 Mar 2026 15:44:53 +0800 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=90=9B=20fix(export):=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=95=B0=E6=8D=AE=E6=97=A5=E6=9C=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=B8=BA=E6=9C=AC=E5=9C=B0=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=20yyyy-MM-dd=20HH:mm:ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatExportCellText default 分支增加字符串日期时间解析与格式化 - normalizeExportJSONValue 新增 time.Time 和字符串日期时间处理 - 覆盖 CSV/JSON/XLSX/HTML/Markdown 全部导出格式 - refs #270 --- internal/app/methods_file.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index e484091..3d02edf 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -2207,7 +2207,12 @@ func formatExportCellText(val interface{}) string { } return text default: - return fmt.Sprintf("%v", val) + text := fmt.Sprintf("%v", val) + // 字符串型日期时间值(如 RFC3339 "2026-03-10T17:01:55+08:00")格式化为本地时区 yyyy-MM-dd HH:mm:ss + if parsed, ok := parseTemporalString(text); ok { + return parsed.Local().Format("2006-01-02 15:04:05") + } + return text } } @@ -2217,6 +2222,18 @@ func normalizeExportJSONValue(val interface{}) interface{} { } switch v := val.(type) { + case time.Time: + return v.Local().Format("2006-01-02 15:04:05") + case *time.Time: + if v == nil { + return nil + } + return v.Local().Format("2006-01-02 15:04:05") + case string: + if parsed, ok := parseTemporalString(v); ok { + return parsed.Local().Format("2006-01-02 15:04:05") + } + return v case float32: f := float64(v) if math.IsNaN(f) || math.IsInf(f, 0) { From b86cfcacaad6b733e77e2c60ec40f6e2555580c3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 15:52:38 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=8C=90=20fix(editor):=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20monaco-editor=20=E4=B8=AD=E6=96=87=20NLS=20?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=8C=85=E4=BF=AE=E5=A4=8D=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E8=8B=B1=E6=96=87=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 main.tsx 中 import 'monaco-editor/esm/nls.messages.zh-cn' - NLS 必须在 monaco-editor 主包之前导入才能生效 - 覆盖所有 Monaco Editor 实例的内置菜单(Cut→剪切、Copy→复制等) - refs #269 --- frontend/src/main.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 99c8104..4c9d370 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,8 @@ import App from './App' // 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。 // Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。 +// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。 +import 'monaco-editor/esm/nls.messages.zh-cn' import { loader } from '@monaco-editor/react' import * as monaco from 'monaco-editor' loader.config({ monaco }) From 1758d6f9188e85cb1870396440cffc4564e70c2b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 16:00:35 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E2=9C=A8=20feat(table-designer):=20?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=AE=BE=E8=AE=A1=E8=A1=A8=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=88=97=E8=A1=A8=EF=BC=8C=E6=8C=89=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=96=B9=E8=A8=80=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DB_TYPE_OPTIONS 按 MySQL/PostgreSQL/SQL Server/SQLite/Oracle 分组 - MySQL:补充数值(float/double/smallint/mediumint)、字符串(tinytext/mediumtext/longtext)、 二进制(blob/tinyblob/mediumblob/longblob)、其他(enum/set/bit/year) - PostgreSQL:补充 serial/boolean/timestamptz/jsonb/uuid/inet 等 - SQL Server:补充 float/real/money/nvarchar/datetime2/uniqueidentifier 等 - AutoComplete options 从固定 COMMON_TYPES 改为 DB_TYPE_OPTIONS[getDbType()] 动态获取 - refs #281 --- frontend/src/components/TableDesigner.tsx | 145 +++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index ee2335f..a9f604b 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -48,6 +48,7 @@ interface ForeignKeyFormState { refColumnNames: string[]; } +// 通用兜底类型列表 const COMMON_TYPES = [ { value: 'int' }, { value: 'varchar(255)' }, @@ -59,6 +60,148 @@ const COMMON_TYPES = [ { value: 'json' }, ]; +// 按数据库方言分组的完整字段类型列表 +const DB_TYPE_OPTIONS: Record = { + mysql: [ + // 数值 + { value: 'tinyint' }, + { value: 'tinyint(1)' }, + { value: 'smallint' }, + { value: 'mediumint' }, + { value: 'int' }, + { value: 'bigint' }, + { value: 'float' }, + { value: 'double' }, + { value: 'decimal(10,2)' }, + // 字符串 + { value: 'char(50)' }, + { value: 'varchar(255)' }, + { value: 'tinytext' }, + { value: 'text' }, + { value: 'mediumtext' }, + { value: 'longtext' }, + // 二进制 + { value: 'binary(255)' }, + { value: 'varbinary(255)' }, + { value: 'tinyblob' }, + { value: 'blob' }, + { value: 'mediumblob' }, + { value: 'longblob' }, + // 日期时间 + { value: 'date' }, + { value: 'time' }, + { value: 'datetime' }, + { value: 'timestamp' }, + { value: 'year' }, + // 其他 + { value: 'json' }, + { value: 'enum' }, + { value: 'set' }, + { value: 'bit(1)' }, + ], + postgres: [ + // 数值 + { value: 'smallint' }, + { value: 'integer' }, + { value: 'bigint' }, + { value: 'real' }, + { value: 'double precision' }, + { value: 'numeric(10,2)' }, + { value: 'serial' }, + { value: 'bigserial' }, + // 字符串 + { value: 'char(50)' }, + { value: 'varchar(255)' }, + { value: 'text' }, + // 布尔 + { value: 'boolean' }, + // 日期时间 + { value: 'date' }, + { value: 'time' }, + { value: 'timestamp' }, + { value: 'timestamptz' }, + { value: 'interval' }, + // 二进制 + { value: 'bytea' }, + // JSON + { value: 'json' }, + { value: 'jsonb' }, + // 其他 + { value: 'uuid' }, + { value: 'inet' }, + { value: 'cidr' }, + { value: 'macaddr' }, + { value: 'xml' }, + { value: 'int4range' }, + { value: 'tsquery' }, + { value: 'tsvector' }, + ], + sqlserver: [ + // 数值 + { value: 'tinyint' }, + { value: 'smallint' }, + { value: 'int' }, + { value: 'bigint' }, + { value: 'float' }, + { value: 'real' }, + { value: 'decimal(10,2)' }, + { value: 'numeric(10,2)' }, + { value: 'money' }, + { value: 'smallmoney' }, + // 字符串 + { value: 'char(50)' }, + { value: 'varchar(255)' }, + { value: 'varchar(max)' }, + { value: 'nchar(50)' }, + { value: 'nvarchar(255)' }, + { value: 'nvarchar(max)' }, + { value: 'text' }, + { value: 'ntext' }, + // 日期时间 + { value: 'date' }, + { value: 'time' }, + { value: 'datetime' }, + { value: 'datetime2' }, + { value: 'datetimeoffset' }, + { value: 'smalldatetime' }, + // 二进制 + { value: 'binary(255)' }, + { value: 'varbinary(255)' }, + { value: 'varbinary(max)' }, + { value: 'image' }, + // 其他 + { value: 'bit' }, + { value: 'uniqueidentifier' }, + { value: 'xml' }, + ], + sqlite: [ + { value: 'INTEGER' }, + { value: 'REAL' }, + { value: 'TEXT' }, + { value: 'BLOB' }, + { value: 'NUMERIC' }, + ], + oracle: [ + { value: 'NUMBER(10)' }, + { value: 'NUMBER(10,2)' }, + { value: 'FLOAT' }, + { value: 'BINARY_FLOAT' }, + { value: 'BINARY_DOUBLE' }, + { value: 'CHAR(50)' }, + { value: 'VARCHAR2(255)' }, + { value: 'NVARCHAR2(255)' }, + { value: 'CLOB' }, + { value: 'NCLOB' }, + { value: 'BLOB' }, + { value: 'DATE' }, + { value: 'TIMESTAMP' }, + { value: 'TIMESTAMP WITH TIME ZONE' }, + { value: 'RAW(255)' }, + { value: 'LONG RAW' }, + { value: 'XMLTYPE' }, + ], +}; + const COMMON_DEFAULTS = [ { value: 'CURRENT_TIMESTAMP' }, { value: 'NULL' }, @@ -410,7 +553,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { key: 'type', width: 150, render: (text: string, record: EditableColumn) => readOnly ? text : ( - handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" /> + handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" /> ) }, { From 0100b771b09c0fe4c6a59b6ac86d0a6db05a123f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 16:07:25 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=94=A7=20ci(release):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20Release=20Notes=20=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=EF=BC=8C=E6=8C=89=20commit=20=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=B1=95=E7=A4=BA=E8=AF=A6=E7=BB=86=E5=8F=98?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换 generate_release_notes 为 git log 提取 commit message - 按 emoji 前缀分 6 组:✨新功能、🐛修复、⚡性能、♻️重构、🌐国际化、🔧其他 - 底部附加 compare 链接,空分类自动跳过 --- .github/workflows/release.yml | 68 ++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c041b1..8987a7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -613,6 +613,72 @@ jobs: sha256sum "${FILES[@]}" > SHA256SUMS fi + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate Changelog + id: changelog + shell: bash + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + # 获取上一个 tag + PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true) + if [ -z "$PREV_TAG" ]; then + echo "⚠️ 未找到上一个 tag,使用全部 commit" + RANGE="$TAG" + else + RANGE="${PREV_TAG}..${TAG}" + fi + + echo "📋 生成更新日志:$RANGE" + + # 提取 commit 消息(排除 merge commit) + COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true) + if [ -z "$COMMITS" ]; then + BODY="暂无提交记录。" + else + CAT_FEAT="" + CAT_FIX="" + CAT_PERF="" + CAT_REFACTOR="" + CAT_I18N="" + CAT_OTHER="" + + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in + ✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;; + 🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;; + ⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;; + ♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;; + 🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;; + 🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;; + *) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;; + esac + done <<< "$COMMITS" + + BODY="" + [ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n" + [ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n" + [ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n" + [ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n" + [ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n" + [ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n" + + # 附加 compare 链接 + if [ -n "$PREV_TAG" ]; then + REPO_URL="${{ github.server_url }}/${{ github.repository }}" + BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n" + fi + fi + + # 写入到文件避免多行环境变量问题 + printf '%b' "$BODY" > /tmp/changelog.md + echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT" + - name: Create Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') @@ -620,6 +686,6 @@ jobs: files: release-assets/* draft: true make_latest: true - generate_release_notes: true + body_path: ${{ steps.changelog.outputs.changelog_file }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}