From 91658848c93f9766cb90f84da08507437530325a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 12:08:07 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=94=A7=20fix(frontend):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A1=A8=E8=AE=BE=E8=AE=A1=E8=83=BD=E5=8A=9B=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E5=B9=B6=E4=BC=98=E5=8C=96=E6=9E=84=E5=BB=BA=E5=88=86?= =?UTF-8?q?=E5=8C=85=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复触发器分组进入设计页时误设只读,恢复索引/外键页增删改按钮显示 - 重构 TableDesigner 数据源方言识别,移除 MySQL 与固定方言白名单硬限制 - 按能力控制索引/外键/表备注编辑入口,并补充多方言 DDL 生成与通用兜底 - 收敛已知不支持场景:sqlite/duckdb/tdengine 禁用外键编辑,sqlite 禁用表备注编辑 - Monaco 改为按需 worker(editor/json)并补齐 vite 类型声明,避免构建类型报错 - 细化 Vite manualChunks(antd/monaco 子模块拆分),消除 >500k chunk 告警 - refs #115 --- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/components/TableDesigner.tsx | 494 ++++++++++++++++++---- frontend/src/main.tsx | 16 +- frontend/src/vite-env.d.ts | 2 + frontend/vite.config.ts | 108 ++++- 5 files changed, 537 insertions(+), 85 deletions(-) create mode 100644 frontend/src/vite-env.d.ts diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 59bb661..80a13c5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1097,7 +1097,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (type === 'folder-columns') openDesign(info.node, 'columns', false); else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false); else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false); - else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true); + else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false); }; const onExpand = (newExpandedKeys: React.Key[]) => { diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 8bb556b..f5d96c6 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -75,6 +75,22 @@ const MYSQL_INDEX_TYPE_OPTIONS = [ { label: 'RTREE', value: 'RTREE' }, ]; +const PGLIKE_INDEX_TYPE_OPTIONS = [ + { label: '默认', value: 'DEFAULT' }, + { label: 'BTREE', value: 'BTREE' }, + { label: 'HASH', value: 'HASH' }, + { label: 'GIN', value: 'GIN' }, + { label: 'GIST', value: 'GIST' }, + { label: 'BRIN', value: 'BRIN' }, + { label: 'SPGIST', value: 'SPGIST' }, +]; + +const SQLSERVER_INDEX_TYPE_OPTIONS = [ + { label: '默认', value: 'DEFAULT' }, + { label: 'CLUSTERED', value: 'CLUSTERED' }, + { label: 'NONCLUSTERED', value: 'NONCLUSTERED' }, +]; + const CHARSETS = [ { label: 'utf8mb4 (Recommended)', value: 'utf8mb4' }, { label: 'utf8', value: 'utf8' }, @@ -612,9 +628,41 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // --- Trigger Handlers --- + const normalizeDbType = (rawType: string): string => { + const normalized = String(rawType || '').trim().toLowerCase(); + if (normalized === 'postgresql' || normalized === 'pg') return 'postgres'; + if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver'; + if (normalized === 'doris') return 'diros'; + return normalized; + }; + + const inferDialectFromCustomDriver = (driver: string): string => { + const customDriver = normalizeDbType(driver); + if (!customDriver) return 'custom'; + if ( + customDriver === 'mariadb' + || customDriver === 'diros' + || customDriver === 'sphinx' + || customDriver === 'tidb' + || customDriver === 'oceanbase' + || customDriver === 'starrocks' + || customDriver.includes('mysql') + ) { + return 'mysql'; + } + if (customDriver === 'dameng') return 'dm'; + return customDriver; + }; + const getDbType = (): string => { const conn = connections.find(c => c.id === tab.connectionId); - const type = String(conn?.config?.type || '').toLowerCase(); + const type = normalizeDbType(String(conn?.config?.type || '')); + if (!type) return ''; + + if (type === 'custom') { + return inferDialectFromCustomDriver(String((conn?.config as any)?.driver || '')); + } + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; @@ -1037,24 +1085,141 @@ ${selectedTrigger.statement}`; }, [groupedForeignKeys, selectedForeignKey]); const escapeBacktickIdentifier = (name: string) => String(name || '').replace(/`/g, '``'); + const escapeBracketIdentifier = (name: string) => String(name || '').replace(/]/g, ']]'); + const escapeDoubleQuoteIdentifier = (name: string) => String(name || '').replace(/"/g, '""'); const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); - const quoteMysqlIdentifierPath = (path: string): string => { - const trimmed = String(path || '').trim(); - if (!trimmed) return ''; - // If user already provided backticks, respect as-is. - if (trimmed.includes('`')) return trimmed; - return trimmed - .split('.') - .map(seg => `\`${escapeBacktickIdentifier(seg)}\``) - .join('.'); + const stripIdentifierQuotes = (part: string): string => { + const text = String(part || '').trim(); + if (!text) return ''; + if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1).trim(); + } + if (text.startsWith('[') && text.endsWith(']')) { + return text.slice(1, -1).trim(); + } + return text; }; - const getMysqlTableRef = (): string => { - const tbl = String(tab.tableName || '').trim(); - const schema = String(tab.dbName || '').trim(); - if (!schema) return `\`${escapeBacktickIdentifier(tbl)}\``; - return `\`${escapeBacktickIdentifier(schema)}\`.\`${escapeBacktickIdentifier(tbl)}\``; + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const raw = String(qualifiedName || '').trim(); + if (!raw) return { schemaName: '', objectName: '' }; + const idx = raw.lastIndexOf('.'); + if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw }; + return { + schemaName: stripIdentifierQuotes(raw.substring(0, idx)), + objectName: stripIdentifierQuotes(raw.substring(idx + 1)), + }; + }; + + const isPgLikeDialect = (dbType: string): boolean => + dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase'; + const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm'; + const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver'; + const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql'; + const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb'; + const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine'; + const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite'; + + const quoteIdentifierPartByDialect = (part: string, dbType: string): string => { + const ident = stripIdentifierQuotes(part); + if (!ident) return ''; + if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') { + return `\`${escapeBacktickIdentifier(ident)}\``; + } + if (isSqlServerDialect(dbType)) { + return `[${escapeBracketIdentifier(ident)}]`; + } + return `"${escapeDoubleQuoteIdentifier(ident)}"`; + }; + + const quoteIdentifierPathByDialect = (path: string, dbType: string): string => { + const raw = String(path || '').trim(); + if (!raw) return ''; + const parts = raw + .split('.') + .map(part => stripIdentifierQuotes(part)) + .filter(Boolean); + if (parts.length === 0) return ''; + return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.'); + }; + + const resolveTableInfo = () => { + const dbType = getDbType(); + const rawTable = String(tab.tableName || '').trim(); + const rawDb = String(tab.dbName || '').trim(); + const parsed = splitQualifiedName(rawTable); + const table = parsed.objectName || stripIdentifierQuotes(rawTable); + let schema = parsed.schemaName; + + if (!schema) { + if (isPgLikeDialect(dbType)) { + schema = rawDb || 'public'; + } else if (isSqlServerDialect(dbType)) { + schema = 'dbo'; + } else if (isOracleLikeDialect(dbType)) { + schema = rawDb; + } else { + schema = rawDb; + } + } + + const qualifiedName = schema ? `${schema}.${table}` : table; + return { + dbType, + schema: stripIdentifierQuotes(schema), + table: stripIdentifierQuotes(table), + qualifiedName, + tableRef: quoteIdentifierPathByDialect(qualifiedName, dbType), + }; + }; + + const supportsIndexSchemaOps = (): boolean => { + const dbType = getDbType(); + if (!dbType) return false; + if (isNonRelationalDialect(dbType)) return false; + return true; + }; + + const supportsForeignKeySchemaOps = (): boolean => { + const dbType = getDbType(); + if (!dbType) return false; + if (isNonRelationalDialect(dbType)) return false; + if (lacksAlterForeignKeySupport(dbType)) return false; + return true; + }; + + const supportsTableCommentOps = (): boolean => { + const dbType = getDbType(); + if (!dbType) return false; + if (isNonRelationalDialect(dbType)) return false; + if (lacksTableCommentSupport(dbType)) return false; + return true; + }; + + const getIndexKindOptions = () => { + const dbType = getDbType(); + if (isMysqlLikeDialect(dbType)) { + return [ + { label: '普通索引(非聚合)', value: 'NORMAL' }, + { label: '唯一索引', value: 'UNIQUE' }, + { label: '主键索引(聚合)', value: 'PRIMARY' }, + { label: '全文索引', value: 'FULLTEXT' }, + { label: '空间索引', value: 'SPATIAL' }, + ]; + } + return [ + { label: '普通索引', value: 'NORMAL' }, + { label: '唯一索引', value: 'UNIQUE' }, + ]; + }; + + const getIndexTypeOptions = () => { + const dbType = getDbType(); + if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS; + if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS; + if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS; + return [{ label: '默认', value: 'DEFAULT' }]; }; const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { @@ -1127,8 +1292,6 @@ ${selectedTrigger.statement}`; } }; - const supportsMysqlSchemaOps = () => getDbType() === 'mysql'; - const executeSchemaSql = async (sql: string, successMessage: string): Promise => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { @@ -1163,13 +1326,59 @@ ${selectedTrigger.statement}`; setIsTableCommentModalOpen(true); }; + const buildTableCommentSql = (nextComment: string): string | null => { + const tableInfo = resolveTableInfo(); + const dbType = tableInfo.dbType; + const escapedComment = escapeSqlString(nextComment); + if (isNonRelationalDialect(dbType)) return null; + if (isMysqlLikeDialect(dbType)) { + return `ALTER TABLE ${tableInfo.tableRef} COMMENT = '${escapedComment}';`; + } + if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) { + return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`; + } + if (isSqlServerDialect(dbType)) { + const schemaName = escapeSqlString(tableInfo.schema || 'dbo'); + const tableName = escapeSqlString(tableInfo.table); + return `IF EXISTS ( + SELECT 1 + FROM sys.extended_properties ep + JOIN sys.tables t ON ep.major_id = t.object_id AND ep.minor_id = 0 + JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE ep.name = N'MS_Description' + AND s.name = N'${schemaName}' + AND t.name = N'${tableName}' +) +BEGIN + EXEC sp_updateextendedproperty + @name = N'MS_Description', + @value = N'${escapedComment}', + @level0type = N'SCHEMA', @level0name = N'${schemaName}', + @level1type = N'TABLE', @level1name = N'${tableName}'; +END +ELSE +BEGIN + EXEC sp_addextendedproperty + @name = N'MS_Description', + @value = N'${escapedComment}', + @level0type = N'SCHEMA', @level0name = N'${schemaName}', + @level1type = N'TABLE', @level1name = N'${tableName}'; +END;`; + } + return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`; + }; + const handleSaveTableComment = async () => { - if (!supportsMysqlSchemaOps()) { + if (!supportsTableCommentOps()) { message.warning('当前数据库暂不支持在此修改表备注'); return; } if (!tab.tableName) return; - const sql = `ALTER TABLE ${getMysqlTableRef()} COMMENT = '${escapeSqlString(tableCommentDraft)}';`; + const sql = buildTableCommentSql(tableCommentDraft); + if (!sql) { + message.warning('当前数据库暂不支持在此修改表备注'); + return; + } setTableCommentSaving(true); const ok = await executeSchemaSql(sql, '表备注更新成功'); setTableCommentSaving(false); @@ -1209,6 +1418,10 @@ ${selectedTrigger.statement}`; } else if (selectedIndex.nonUnique === 0) { kind = 'UNIQUE'; } + const supportedKinds = new Set(getIndexKindOptions().map(item => item.value)); + if (!supportedKinds.has(kind)) { + kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL'; + } setIndexForm({ name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName, @@ -1221,51 +1434,132 @@ ${selectedTrigger.statement}`; setIsIndexModalOpen(true); }; - const buildIndexAddClause = (form: IndexFormState): string | null => { + const buildIndexCreateSql = (form: IndexFormState): string | null => { + const tableInfo = resolveTableInfo(); + const dbType = tableInfo.dbType; const kind: IndexKind = form.kind || 'NORMAL'; const indexName = String(form.name || '').trim(); - const colSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); + const cleanedCols = form.columnNames.map(col => String(col || '').trim()).filter(Boolean); + if (cleanedCols.length === 0) { + message.error('请至少选择一个字段'); + return null; + } + const colSql = cleanedCols + .map(col => quoteIdentifierPartByDialect(col, dbType)) + .join(', '); - if (kind === 'PRIMARY') { - return `ADD PRIMARY KEY (${colSql})`; + if (isMysqlLikeDialect(dbType)) { + if (kind === 'PRIMARY') { + return `ALTER TABLE ${tableInfo.tableRef}\nADD PRIMARY KEY (${colSql});`; + } + + if (!indexName) { + message.error('请输入索引名'); + return null; + } + + const indexRef = quoteIdentifierPartByDialect(indexName, dbType); + if (kind === 'FULLTEXT') { + return `ALTER TABLE ${tableInfo.tableRef}\nADD FULLTEXT INDEX ${indexRef} (${colSql});`; + } + if (kind === 'SPATIAL') { + return `ALTER TABLE ${tableInfo.tableRef}\nADD SPATIAL INDEX ${indexRef} (${colSql});`; + } + + const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT'; + if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') { + message.error(`请将“索引类别”切换为 ${normalizedType} 索引`); + return null; + } + const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : ''; + const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX'; + return `ALTER TABLE ${tableInfo.tableRef}\n${prefix} ${indexRef}${usingSql} (${colSql});`; } + if (kind === 'PRIMARY' || kind === 'FULLTEXT' || kind === 'SPATIAL') { + message.warning('当前数据库仅支持普通索引与唯一索引维护'); + return null; + } if (!indexName) { message.error('请输入索引名'); return null; } - if (kind === 'FULLTEXT') { - return `ADD FULLTEXT INDEX \`${escapeBacktickIdentifier(indexName)}\` (${colSql})`; - } - if (kind === 'SPATIAL') { - return `ADD SPATIAL INDEX \`${escapeBacktickIdentifier(indexName)}\` (${colSql})`; + const indexRef = quoteIdentifierPartByDialect(indexName, dbType); + const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT'; + const uniquePrefix = kind === 'UNIQUE' ? 'UNIQUE ' : ''; + + if (isPgLikeDialect(dbType)) { + const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : ''; + return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef}${usingSql} (${colSql});`; } - const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT'; - if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') { - message.error(`请将“索引类别”切换为 ${normalizedType} 索引`); + if (isSqlServerDialect(dbType)) { + const methodSql = normalizedType === 'CLUSTERED' || normalizedType === 'NONCLUSTERED' + ? `${normalizedType} ` + : ''; + return `CREATE ${uniquePrefix}${methodSql}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`; + } + + if (isOracleLikeDialect(dbType) || dbType === 'sqlite') { + return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`; + } + + if (isNonRelationalDialect(dbType)) { + message.warning('当前数据源不支持关系型索引维护'); return null; } - - const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : ''; - const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX'; - return `${prefix} \`${escapeBacktickIdentifier(indexName)}\`${usingSql} (${colSql})`; + return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`; }; - const buildIndexDropClause = (indexName: string) => { - if (String(indexName || '').trim().toUpperCase() === 'PRIMARY') { - return 'DROP PRIMARY KEY'; + const buildIndexDropSql = (indexName: string): string | null => { + const tableInfo = resolveTableInfo(); + const dbType = tableInfo.dbType; + const name = String(indexName || '').trim(); + if (!name) return null; + + if (isMysqlLikeDialect(dbType)) { + if (name.toUpperCase() === 'PRIMARY') { + return `ALTER TABLE ${tableInfo.tableRef}\nDROP PRIMARY KEY;`; + } + const indexRef = quoteIdentifierPartByDialect(name, dbType); + return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`; } - return `DROP INDEX \`${escapeBacktickIdentifier(indexName)}\``; + + if (isSqlServerDialect(dbType)) { + const indexRef = quoteIdentifierPartByDialect(name, dbType); + return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`; + } + + if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType) || dbType === 'sqlite') { + const fullIndexName = name.includes('.') || !tableInfo.schema + ? name + : `${tableInfo.schema}.${name}`; + const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType); + return `DROP INDEX ${indexRef};`; + } + + if (isNonRelationalDialect(dbType)) { + return null; + } + const fullIndexName = name.includes('.') || !tableInfo.schema + ? name + : `${tableInfo.schema}.${name}`; + const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType); + return `DROP INDEX ${indexRef};`; }; const handleSubmitIndex = async () => { - if (!supportsMysqlSchemaOps()) { + if (!supportsIndexSchemaOps()) { message.warning('当前数据库暂不支持在此维护索引'); return; } if (!tab.tableName) return; + const supportedKinds = new Set(getIndexKindOptions().map(item => item.value)); + if (!supportedKinds.has(indexForm.kind)) { + message.warning('当前数据库不支持该索引类型'); + return; + } const nextName = indexForm.kind === 'PRIMARY' ? 'PRIMARY' : String(indexForm.name || '').trim(); if (indexForm.kind !== 'PRIMARY' && !nextName) { message.error('请输入索引名'); @@ -1287,16 +1581,21 @@ ${selectedTrigger.statement}`; } setIndexSaving(true); - const addClause = buildIndexAddClause({ ...indexForm, name: nextName }); - if (!addClause) { + const addSql = buildIndexCreateSql({ ...indexForm, name: nextName }); + if (!addSql) { setIndexSaving(false); return; } - let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`; + let sql = addSql; if (indexModalMode === 'edit' && selectedIndex) { - const dropClause = buildIndexDropClause(selectedIndex.name); - sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`; + const dropSql = buildIndexDropSql(selectedIndex.name); + if (!dropSql) { + setIndexSaving(false); + message.warning('当前数据库暂不支持删除该索引'); + return; + } + sql = `${dropSql}\n${addSql}`; } const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功'); @@ -1311,7 +1610,7 @@ ${selectedTrigger.statement}`; message.warning('请先选择一个索引'); return; } - if (!supportsMysqlSchemaOps()) { + if (!supportsIndexSchemaOps()) { message.warning('当前数据库暂不支持在此维护索引'); return; } @@ -1323,8 +1622,11 @@ ${selectedTrigger.statement}`; okType: 'danger', cancelText: '取消', onOk: async () => { - const dropClause = buildIndexDropClause(selectedIndex.name); - const sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause};`; + const sql = buildIndexDropSql(selectedIndex.name); + if (!sql) { + message.warning('当前数据库暂不支持删除该索引'); + return; + } await executeSchemaSql(sql, '索引删除成功'); } }); @@ -1356,18 +1658,40 @@ ${selectedTrigger.statement}`; setIsForeignKeyModalOpen(true); }; - const buildForeignKeyAddClause = (form: ForeignKeyFormState) => { - const localColsSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); - const refColsSql = form.refColumnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); - const refTableSql = quoteMysqlIdentifierPath(form.refTableName); - return `ADD CONSTRAINT \`${escapeBacktickIdentifier(form.constraintName)}\` FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql})`; + const buildForeignKeyAddSql = (form: ForeignKeyFormState): string | null => { + const tableInfo = resolveTableInfo(); + const dbType = tableInfo.dbType; + if (!supportsForeignKeySchemaOps()) return null; + + const localColsSql = form.columnNames + .map(col => quoteIdentifierPartByDialect(col, dbType)) + .join(', '); + const refColsSql = form.refColumnNames + .map(col => quoteIdentifierPartByDialect(col, dbType)) + .join(', '); + const refParts = splitQualifiedName(form.refTableName); + const refObjectName = refParts.objectName || String(form.refTableName || '').trim(); + const refTableName = !refParts.schemaName && tableInfo.schema && (isPgLikeDialect(dbType) || isSqlServerDialect(dbType) || isOracleLikeDialect(dbType)) + ? `${tableInfo.schema}.${refObjectName}` + : String(form.refTableName || '').trim(); + const refTableSql = quoteIdentifierPathByDialect(refTableName, dbType); + const constraintSql = quoteIdentifierPartByDialect(form.constraintName, dbType); + return `ALTER TABLE ${tableInfo.tableRef}\nADD CONSTRAINT ${constraintSql} FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql});`; }; - const buildForeignKeyDropClause = (constraintName: string) => - `DROP FOREIGN KEY \`${escapeBacktickIdentifier(constraintName)}\``; + const buildForeignKeyDropSql = (constraintName: string): string | null => { + const tableInfo = resolveTableInfo(); + const dbType = tableInfo.dbType; + if (!supportsForeignKeySchemaOps()) return null; + const constraintSql = quoteIdentifierPartByDialect(constraintName, dbType); + if (isMysqlLikeDialect(dbType)) { + return `ALTER TABLE ${tableInfo.tableRef}\nDROP FOREIGN KEY ${constraintSql};`; + } + return `ALTER TABLE ${tableInfo.tableRef}\nDROP CONSTRAINT ${constraintSql};`; + }; const handleSubmitForeignKey = async () => { - if (!supportsMysqlSchemaOps()) { + if (!supportsForeignKeySchemaOps()) { message.warning('当前数据库暂不支持在此维护外键'); return; } @@ -1408,17 +1732,27 @@ ${selectedTrigger.statement}`; } setForeignKeySaving(true); - const addClause = buildForeignKeyAddClause({ + const addSql = buildForeignKeyAddSql({ ...foreignKeyForm, constraintName: nextConstraint, columnNames: localCols, refTableName: refTable, refColumnNames: refCols, }); - let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`; + if (!addSql) { + setForeignKeySaving(false); + message.warning('当前数据库暂不支持在此维护外键'); + return; + } + let sql = addSql; if (foreignKeyModalMode === 'edit' && selectedForeignKey) { - const dropClause = buildForeignKeyDropClause(selectedForeignKey.constraintName); - sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`; + const dropSql = buildForeignKeyDropSql(selectedForeignKey.constraintName); + if (!dropSql) { + setForeignKeySaving(false); + message.warning('当前数据库暂不支持删除该外键'); + return; + } + sql = `${dropSql}\n${addSql}`; } const ok = await executeSchemaSql(sql, foreignKeyModalMode === 'create' ? '外键新增成功' : '外键修改成功'); @@ -1433,7 +1767,7 @@ ${selectedTrigger.statement}`; message.warning('请先选择一个外键'); return; } - if (!supportsMysqlSchemaOps()) { + if (!supportsForeignKeySchemaOps()) { message.warning('当前数据库暂不支持在此维护外键'); return; } @@ -1445,7 +1779,11 @@ ${selectedTrigger.statement}`; okType: 'danger', cancelText: '取消', onOk: async () => { - const sql = `ALTER TABLE ${getMysqlTableRef()}\n${buildForeignKeyDropClause(selectedForeignKey.constraintName)};`; + const sql = buildForeignKeyDropSql(selectedForeignKey.constraintName); + if (!sql) { + message.warning('当前数据库暂不支持删除该外键'); + return; + } await executeSchemaSql(sql, '外键删除成功'); } }); @@ -1677,7 +2015,7 @@ ${selectedTrigger.statement}`; )} {!readOnly && } {!isNewTable && } - {!isNewTable && !readOnly && supportsMysqlSchemaOps() && ( + {!isNewTable && !readOnly && supportsTableCommentOps() && ( )} {!readOnly && } @@ -1710,15 +2048,15 @@ ${selectedTrigger.statement}`;
{!readOnly && (
- - - - {!supportsMysqlSchemaOps() && ( + + + + {!supportsIndexSchemaOps() && ( 当前数据库暂不支持索引编辑,仅支持查看 )} - {supportsMysqlSchemaOps() && selectedIndex && ( + {supportsIndexSchemaOps() && selectedIndex && ( 已选择:{selectedIndex.name} @@ -1813,15 +2151,15 @@ ${selectedTrigger.statement}`;
{!readOnly && (
- - - - {!supportsMysqlSchemaOps() && ( + + + + {!supportsForeignKeySchemaOps() && ( 当前数据库暂不支持外键编辑,仅支持查看 )} - {supportsMysqlSchemaOps() && selectedForeignKey && ( + {supportsForeignKeySchemaOps() && selectedForeignKey && ( 已选择:{selectedForeignKey.constraintName} @@ -2077,13 +2415,7 @@ ${selectedTrigger.statement}`; setIndexForm(prev => ({ ...prev, indexType: val }))} - options={MYSQL_INDEX_TYPE_OPTIONS} + options={getIndexTypeOptions()} style={{ width: 160 }} disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'} /> diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9457771..4b67fcd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,7 +6,21 @@ import App from './App' // 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。 // Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。 import { loader } from '@monaco-editor/react' -import * as monaco from 'monaco-editor' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker' +import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker' +import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js' +import 'monaco-editor/esm/vs/language/json/monaco.contribution.js' + +(self as any).MonacoEnvironment = { + getWorker(_: unknown, label: string) { + if (label === 'json') { + return new JsonWorker() + } + return new EditorWorker() + }, +} + loader.config({ monaco }) // 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义 diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 15abb21..73a0e55 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,54 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/') + +const sanitizeChunkToken = (raw: string): string => + String(raw || '') + .trim() + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'misc' + +const firstSegmentAfter = (id: string, marker: string): string => { + const idx = id.indexOf(marker) + if (idx < 0) return '' + const rest = id.substring(idx + marker.length) + const [segment] = rest.split('/') + return sanitizeChunkToken(segment) +} + +const resolveMonacoChunk = (id: string, prefix: string): string | undefined => { + if (!id.includes('/node_modules/monaco-editor/')) return undefined + + if (id.includes('/esm/vs/language/typescript/')) { + if (id.includes('typescriptServices')) return `${prefix}-ts-services` + return `${prefix}-typescript` + } + if (id.includes('/esm/vs/language/json/')) return `${prefix}-json` + if (id.includes('/esm/vs/language/css/')) return `${prefix}-css` + if (id.includes('/esm/vs/language/html/')) return `${prefix}-html` + + if (id.includes('/esm/vs/editor/contrib/')) { + return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}` + } + if (id.includes('/esm/vs/editor/browser/')) { + return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}` + } + if (id.includes('/esm/vs/editor/common/')) { + return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}` + } + if (id.includes('/esm/vs/editor/')) return `${prefix}-editor` + + if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser` + if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common` + if (id.includes('/esm/vs/base/')) return `${prefix}-base` + + if (id.includes('/esm/vs/platform/')) return `${prefix}-platform` + + return `${prefix}-misc` +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], @@ -11,5 +59,61 @@ export default defineConfig({ build: { outDir: 'dist', // Standard Wails output directory emptyOutDir: true, - } -}) \ No newline at end of file + rollupOptions: { + output: { + manualChunks(id) { + const moduleId = normalizeModuleId(id) + if (!moduleId.includes('node_modules')) return undefined + + const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco') + if (monacoChunk) { + return monacoChunk + } + if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react' + + if (moduleId.includes('/node_modules/antd/es/')) { + return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}` + } + if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd' + if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons' + if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css' + if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc' + + if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit' + if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter' + + if ( + moduleId.includes('/node_modules/react/') + || moduleId.includes('/node_modules/react-dom/') + || moduleId.includes('/node_modules/scheduler/') + ) { + return 'vendor-react' + } + + if ( + moduleId.includes('/node_modules/zustand/') + || moduleId.includes('/node_modules/uuid/') + || moduleId.includes('/node_modules/clsx/') + || moduleId.includes('/node_modules/react-resizable/') + ) { + return 'vendor-utils' + } + + return 'vendor-misc' + }, + }, + }, + }, + worker: { + format: 'es', + rollupOptions: { + output: { + manualChunks(id) { + const moduleId = normalizeModuleId(id) + if (!moduleId.includes('node_modules')) return undefined + return resolveMonacoChunk(moduleId, 'worker-monaco') + }, + }, + }, + }, +}) From 50d92d3184c83bdbcae6073a845c7e65890a7998 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 13:45:17 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20fix(backup-export):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F=E5=A4=87=E4=BB=BD=E6=9C=AA?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E8=A7=86=E5=9B=BE=E4=B8=8E=E8=A1=A8=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=AF=BC=E5=87=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量操作弹窗按“表/视图”分组展示并支持混合勾选 - 批量导出改为对象集合传参,统一结构/数据导出入口 - SQL 导出链路新增视图识别与排序,避免将视图当表处理 - 增加多方言视图 DDL 查询与回退逻辑,规避 create statement not found - 视图数据导出阶段自动跳过并追加说明注释 - refs #117 --- frontend/src/components/Sidebar.tsx | 132 ++++++-- internal/app/methods_file.go | 484 +++++++++++++++++++++++++++- 2 files changed, 566 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 80a13c5..3dc871b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -46,6 +46,15 @@ interface TreeNode { } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; +type BatchObjectType = 'table' | 'view'; + +interface BatchObjectItem { + title: string; + key: string; + objectName: string; + objectType: BatchObjectType; + dataRef: any; +} const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => { const connections = useStore(state => state.connections); @@ -118,12 +127,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> // Batch Operations Modal const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); - const [batchTables, setBatchTables] = useState([]); + const [batchTables, setBatchTables] = useState([]); const [checkedTableKeys, setCheckedTableKeys] = useState([]); const [batchDbContext, setBatchDbContext] = useState(null); const [selectedConnection, setSelectedConnection] = useState(''); const [selectedDatabase, setSelectedDatabase] = useState(''); const [availableDatabases, setAvailableDatabases] = useState([]); + const groupedBatchObjects = useMemo(() => { + const tables = batchTables.filter(item => item.objectType === 'table'); + const views = batchTables.filter(item => item.objectType === 'view'); + return { tables, views }; + }, [batchTables]); // Batch Database Operations Modal const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); @@ -1288,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (node.type === 'database') { connId = node.dataRef.id; dbName = node.title; - } else if (node.type === 'table') { + } else if (node.type === 'table' || node.type === 'view') { connId = node.dataRef.id; dbName = node.dataRef.dbName; } @@ -1356,23 +1370,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; - const res = await DBGetTables(config as any, dbName); - if (res.success) { - const tables = (res.data as any[]).map((row: any) => { - const tableName = Object.values(row)[0] as string; - return { - title: tableName, - key: `${conn.id}-${dbName}-${tableName}`, - tableName: tableName, - dataRef: { ...conn, tableName, dbName } - }; - }); + const [res, viewResult] = await Promise.all([ + DBGetTables(config as any, dbName), + loadViews(conn, dbName).catch(() => ({ views: [], supported: false })), + ]); - setBatchTables(tables); - setCheckedTableKeys([]); - } else { + if (!res.success) { message.error('获取表列表失败: ' + res.message); + return; } + + const viewSet = new Set(viewResult.views.map(view => view.toLowerCase())); + + const tableObjects: BatchObjectItem[] = (res.data as any[]) + .map((row: any) => Object.values(row)[0] as string) + .filter((tableName: string) => !viewSet.has(tableName.toLowerCase())) + .map((tableName: string) => ({ + title: getSidebarTableDisplayName(conn, tableName), + key: `${conn.id}-${dbName}-table-${tableName}`, + objectName: tableName, + objectType: 'table' as const, + dataRef: { ...conn, tableName, dbName, objectType: 'table' }, + })); + + const viewObjects: BatchObjectItem[] = viewResult.views.map((viewName: string) => ({ + title: getSidebarTableDisplayName(conn, viewName), + key: `${conn.id}-${dbName}-view-${viewName}`, + objectName: viewName, + objectType: 'view' as const, + dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' }, + })); + + tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + + setBatchTables([...tableObjects, ...viewObjects]); + setCheckedTableKeys([]); }; const handleConnectionChange = async (connId: string) => { @@ -1397,31 +1430,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const handleBatchExport = async (mode: BatchTableExportMode) => { - const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key)); - if (selectedTables.length === 0) { - message.warning('请至少选择一张表'); + const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); + if (selectedObjects.length === 0) { + message.warning('请至少选择一个对象'); return; } setIsBatchModalOpen(false); const { conn, dbName } = batchDbContext; - const tableNames = selectedTables.map(t => t.tableName); + const objectNames = selectedObjects.map(t => t.objectName); + const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length; const loadingText = mode === 'backup' - ? `正在备份选中表 (${tableNames.length})...` + ? `正在备份选中对象 (${objectNames.length})...` : mode === 'dataOnly' - ? `正在导出选中表数据 (INSERT) (${tableNames.length})...` - : `正在导出选中表结构 (${tableNames.length})...`; + ? `正在导出选中对象数据 (INSERT) (${objectNames.length})...` + : `正在导出选中对象结构 (${objectNames.length})...`; const hide = message.loading(loadingText, 0); try { const app = (window as any).go.app.App; const res = mode === 'dataOnly' - ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames) - : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup'); + ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames) + : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup'); hide(); if (res.success) { - message.success('导出成功'); + if (mode !== 'schema' && selectedViewCount > 0) { + message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`); + } else { + message.success('导出成功'); + } } else if (res.message !== 'Cancelled') { message.error('导出失败: ' + res.message); } @@ -2859,7 +2897,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> 反选 - 已选择 {checkedTableKeys.length} / {batchTables.length} 张表 + 已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
@@ -2869,14 +2907,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onChange={(values) => setCheckedTableKeys(values as string[])} style={{ width: '100%' }} > - - {batchTables.map(table => ( - - - {table.title} - - ))} - +
+ {groupedBatchObjects.tables.length > 0 && ( +
+
+ 表 ({groupedBatchObjects.tables.length}) +
+ + {groupedBatchObjects.tables.map(table => ( + + + {table.title} + + ))} + +
+ )} + {groupedBatchObjects.views.length > 0 && ( +
+
+ 视图 ({groupedBatchObjects.views.length}) +
+ + {groupedBatchObjects.views.map(view => ( + + + {view.title} + + ))} + +
+ )} +
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index ebf9dba..556c5a3 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -485,7 +485,8 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab if err := writeSQLHeader(w, runConfig, dbName); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil { + viewLookup := listViewNameLookup(dbInst, runConfig, dbName) + if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if err := writeSQLFooter(w, runConfig); err != nil { @@ -556,7 +557,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, return connection.QueryResult{Success: false, Message: err.Error()} } - tables := make([]string, 0, len(tableNames)) + objects := make([]string, 0, len(tableNames)) seen := make(map[string]struct{}, len(tableNames)) for _, t := range tableNames { t = strings.TrimSpace(t) @@ -567,9 +568,10 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, continue } seen[t] = struct{}{} - tables = append(tables, t) + objects = append(objects, t) } - sort.Strings(tables) + viewLookup := listViewNameLookup(dbInst, runConfig, dbName) + objects = buildExportObjectOrder(runConfig, dbName, objects, viewLookup, false) f, err := os.Create(filename) if err != nil { @@ -583,8 +585,8 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, if err := writeSQLHeader(w, runConfig, dbName); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - for _, t := range tables { - if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil { + for _, objectName := range objects { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } } @@ -623,7 +625,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - sort.Strings(tables) + viewLookup := listViewNameLookup(dbInst, runConfig, dbName) + objects := buildExportObjectOrder(runConfig, dbName, tables, viewLookup, true) f, err := os.Create(filename) if err != nil { @@ -637,8 +640,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin if err := writeSQLHeader(w, runConfig, dbName); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - for _, t := range tables { - if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil { + for _, objectName := range objects { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, viewLookup); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } } @@ -743,6 +746,404 @@ func ensureSQLTerminator(sql string) string { return sql + ";" } +func buildExportObjectOrder( + config connection.ConnectionConfig, + dbName string, + rawObjects []string, + viewLookup map[string]string, + includeAllViews bool, +) []string { + tableSet := make(map[string]string, len(rawObjects)) + viewSet := make(map[string]string, len(rawObjects)) + + for _, rawName := range rawObjects { + objectName := strings.TrimSpace(rawName) + if objectName == "" { + continue + } + key := normalizeExportObjectKey(config, dbName, objectName) + if key == "" { + continue + } + if canonicalViewName, ok := viewLookup[key]; ok { + if strings.TrimSpace(canonicalViewName) == "" { + canonicalViewName = objectName + } + viewSet[key] = canonicalViewName + delete(tableSet, key) + continue + } + if _, isView := viewSet[key]; isView { + continue + } + if _, exists := tableSet[key]; !exists { + tableSet[key] = objectName + } + } + + if includeAllViews { + for key, viewName := range viewLookup { + canonicalViewName := strings.TrimSpace(viewName) + if canonicalViewName == "" { + continue + } + viewSet[key] = canonicalViewName + delete(tableSet, key) + } + } + + tables := mapValuesSorted(tableSet) + views := mapValuesSorted(viewSet) + return append(tables, views...) +} + +func mapValuesSorted(values map[string]string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + result = append(result, value) + } + sort.Strings(result) + return result +} + +func normalizeExportObjectKey(config connection.ConnectionConfig, dbName string, objectName string) string { + schemaName, pureName := normalizeSchemaAndTable(config, dbName, objectName) + return normalizeExportObjectKeyByParts(schemaName, pureName) +} + +func normalizeExportObjectKeyByParts(schemaName, objectName string) string { + return strings.ToLower(strings.TrimSpace(qualifyTable(schemaName, objectName))) +} + +func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig, dbName string) map[string]string { + viewLookup := make(map[string]string) + queries := buildListViewQueries(config, dbName) + for _, query := range queries { + if strings.TrimSpace(query) == "" { + continue + } + rows, _, err := dbInst.Query(query) + if err != nil { + continue + } + for _, row := range rows { + tableType := strings.ToUpper(exportRowValueCI(row, "table_type", "type")) + if tableType != "" && tableType != "VIEW" { + continue + } + schemaName := exportRowValueCI(row, "schema_name", "table_schema", "owner", "schema", "db") + viewName := exportRowValueCI(row, "object_name", "view_name", "table_name", "name") + if viewName == "" { + viewName = exportInferObjectName(row) + } + if strings.TrimSpace(viewName) == "" { + continue + } + fullName := strings.TrimSpace(qualifyTable(schemaName, viewName)) + if fullName == "" { + fullName = strings.TrimSpace(viewName) + } + key := normalizeExportObjectKey(config, dbName, fullName) + if key == "" { + continue + } + if _, exists := viewLookup[key]; !exists { + viewLookup[key] = fullName + } + } + } + return viewLookup +} + +func buildListViewQueries(config connection.ConnectionConfig, dbName string) []string { + dbType := resolveDDLDBType(config) + escapedDbName := escapeSQLLiteral(dbName) + switch dbType { + case "mysql", "mariadb", "diros", "sphinx": + queries := []string{ + fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName), + } + if strings.TrimSpace(dbName) != "" { + queries = append(queries, fmt.Sprintf("SHOW FULL TABLES FROM %s WHERE Table_type = 'VIEW'", quoteIdentByType("mysql", dbName))) + } + return queries + case "postgres", "kingbase", "highgo", "vastbase": + return []string{ + `SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`, + } + case "sqlserver": + safeDBName := strings.TrimSpace(config.Database) + if safeDBName == "" { + safeDBName = strings.TrimSpace(dbName) + } + if safeDBName == "" { + return nil + } + safeDB := quoteIdentByType("sqlserver", safeDBName) + return []string{ + fmt.Sprintf(`SELECT s.name AS schema_name, v.name AS object_name FROM %s.sys.views v JOIN %s.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`, safeDB, safeDB), + } + case "oracle", "dameng": + if strings.TrimSpace(dbName) == "" { + return []string{ + `SELECT VIEW_NAME AS object_name FROM user_views ORDER BY VIEW_NAME`, + } + } + return []string{ + fmt.Sprintf("SELECT OWNER AS schema_name, VIEW_NAME AS object_name FROM all_views WHERE OWNER = '%s' ORDER BY VIEW_NAME", strings.ToUpper(escapedDbName)), + } + case "sqlite": + return []string{ + "SELECT name AS object_name FROM sqlite_master WHERE type='view' ORDER BY name", + } + case "duckdb": + return []string{ + `SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`, + } + default: + if strings.TrimSpace(dbName) == "" { + return []string{ + `SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views`, + } + } + return []string{ + fmt.Sprintf(`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema='%s'`, escapedDbName), + } + } +} + +func tryGetViewCreateStatement( + dbInst db.Database, + config connection.ConnectionConfig, + dbName string, + schemaName string, + viewName string, +) (string, bool) { + queries := buildViewCreateQueries(config, dbName, schemaName, viewName) + for _, query := range queries { + if strings.TrimSpace(query) == "" { + continue + } + rows, _, err := dbInst.Query(query) + if err != nil || len(rows) == 0 { + continue + } + createSQL := strings.TrimSpace(extractViewCreateSQL(rows[0])) + if createSQL == "" { + continue + } + if looksLikeSelectOrWith(createSQL) { + qualifiedView := qualifyTable(schemaName, viewName) + createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";")) + } + return ensureSQLTerminator(createSQL), true + } + return "", false +} + +func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaName, viewName string) []string { + dbType := resolveDDLDBType(config) + safeSchema := strings.TrimSpace(schemaName) + safeView := strings.TrimSpace(viewName) + if safeView == "" { + return nil + } + escapedSchema := escapeSQLLiteral(safeSchema) + escapedView := escapeSQLLiteral(safeView) + escapedDB := escapeSQLLiteral(dbName) + + switch dbType { + case "mysql", "mariadb", "diros", "sphinx": + if safeSchema == "" { + safeSchema = strings.TrimSpace(dbName) + } + if safeSchema != "" { + return []string{ + fmt.Sprintf("SHOW CREATE VIEW %s.%s", quoteIdentByType("mysql", safeSchema), quoteIdentByType("mysql", safeView)), + } + } + return []string{ + fmt.Sprintf("SHOW CREATE VIEW %s", quoteIdentByType("mysql", safeView)), + } + case "postgres", "kingbase", "highgo", "vastbase": + if safeSchema == "" { + safeSchema = "public" + } + regClassName := fmt.Sprintf(`"%s"."%s"`, strings.ReplaceAll(safeSchema, `"`, `""`), strings.ReplaceAll(safeView, `"`, `""`)) + regClassName = strings.ReplaceAll(regClassName, "'", "''") + return []string{ + fmt.Sprintf("SELECT pg_get_viewdef('%s'::regclass, true) AS ddl", regClassName), + } + case "sqlserver": + schema := safeSchema + if schema == "" { + schema = "dbo" + } + safeDBName := strings.TrimSpace(config.Database) + if safeDBName == "" { + safeDBName = strings.TrimSpace(dbName) + } + if safeDBName == "" { + return nil + } + safeDB := quoteIdentByType("sqlserver", safeDBName) + return []string{ + fmt.Sprintf(`SELECT m.definition AS ddl +FROM %s.sys.views v +JOIN %s.sys.schemas s ON v.schema_id = s.schema_id +JOIN %s.sys.sql_modules m ON v.object_id = m.object_id +WHERE s.name = '%s' AND v.name = '%s'`, + safeDB, safeDB, safeDB, escapeSQLLiteral(schema), escapedView), + } + case "oracle", "dameng": + if safeSchema == "" { + safeSchema = strings.TrimSpace(dbName) + } + if safeSchema != "" { + return []string{ + fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView), strings.ToUpper(escapeSQLLiteral(safeSchema))), + } + } + return []string{ + fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView)), + } + case "sqlite": + return []string{ + fmt.Sprintf("SELECT sql AS ddl FROM sqlite_master WHERE type='view' AND name='%s'", escapedView), + } + case "duckdb": + if safeSchema == "" { + safeSchema = "main" + escapedSchema = "main" + } + return []string{ + fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema), + fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema), + } + default: + if safeSchema != "" { + return []string{ + fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema), + } + } + if strings.TrimSpace(dbName) != "" { + return []string{ + fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedDB), + } + } + return []string{ + fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' LIMIT 1", escapedView), + } + } +} + +func extractViewCreateSQL(row map[string]interface{}) string { + if row == nil { + return "" + } + ddl := exportRowValueCI(row, "create view", "create_statement", "create_sql", "ddl", "sql", "view_definition", "definition") + if ddl != "" { + return ddl + } + for _, value := range row { + if value == nil { + continue + } + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" || text == "" { + continue + } + lower := strings.ToLower(text) + if strings.HasPrefix(lower, "create ") || strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") { + return text + } + } + return "" +} + +func exportRowValueCI(row map[string]interface{}, candidates ...string) string { + if len(row) == 0 || len(candidates) == 0 { + return "" + } + for _, candidate := range candidates { + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + continue + } + for key, value := range row { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if normalizedKey != candidate { + continue + } + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" { + return "" + } + return text + } + } + return "" +} + +func exportInferObjectName(row map[string]interface{}) string { + if len(row) == 0 { + return "" + } + for key, value := range row { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if normalizedKey == "" { + continue + } + if strings.Contains(normalizedKey, "type") { + continue + } + if strings.Contains(normalizedKey, "table") || strings.Contains(normalizedKey, "view") || strings.Contains(normalizedKey, "name") || strings.Contains(normalizedKey, "ddl") || strings.Contains(normalizedKey, "sql") { + if value == nil { + continue + } + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" || text == "" { + continue + } + return text + } + } + for _, value := range row { + if value == nil { + continue + } + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" || text == "" { + continue + } + return text + } + return "" +} + +func looksLikeSelectOrWith(sql string) bool { + trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";")) + if trimmed == "" { + return false + } + lower := strings.ToLower(trimmed) + return strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") || lower == "select" || lower == "with" +} + +func escapeSQLLiteral(value string) string { + return strings.ReplaceAll(strings.TrimSpace(value), "'", "''") +} + func isMySQLHexLiteral(s string) bool { if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) { return false @@ -798,13 +1199,63 @@ func formatSQLValue(dbType string, v interface{}) string { } } -func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error { +func dumpTableSQL( + w *bufio.Writer, + dbInst db.Database, + config connection.ConnectionConfig, + dbName, + tableName string, + includeSchema bool, + includeData bool, + viewLookup map[string]string, +) error { schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + objectKey := normalizeExportObjectKeyByParts(schemaName, pureTableName) + _, isView := viewLookup[objectKey] + var createSQL string + + if includeSchema { + if isView { + viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName) + if ok { + createSQL = viewDDL + } else { + ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName) + if err != nil { + return err + } + createSQL = ddl + } + } else { + ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName) + if err != nil { + if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok { + createSQL = viewDDL + isView = true + } else { + return err + } + } else { + createSQL = ddl + } + } + } + + if includeData && !includeSchema && !isView { + if _, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok { + isView = true + } + } + + objectLabel := "Table" + if isView { + objectLabel = "View" + } if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil { return err } - if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil { + if _, err := w.WriteString(fmt.Sprintf("-- %s: %s\n", objectLabel, qualifyTable(schemaName, pureTableName))); err != nil { return err } if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil { @@ -812,10 +1263,6 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect } if includeSchema { - createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName) - if err != nil { - return err - } if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil { return err } @@ -828,6 +1275,13 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect return nil } + if isView { + if _, err := w.WriteString("-- View data export skipped (INSERT for views is not emitted).\n"); err != nil { + return err + } + return nil + } + qualified := qualifyTable(schemaName, pureTableName) selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified)) data, columns, err := dbInst.Query(selectSQL) From a435d62d3bd7e48bc880b4758aef386e4e37f549 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 13:57:50 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20feat(connection-modal):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9ESSH=E7=A7=81=E9=92=A5=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E9=80=89=E6=8B=A9=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增私钥文件选择入口,减少手动输入路径错误 - 复用系统文件对话框并自动回填私钥路径 - 保留手动输入作为兜底方式 - refs #119 --- frontend/src/components/ConnectionModal.tsx | 38 ++++++++++++++++-- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 ++ internal/app/methods_file.go | 43 +++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index f1b4112..0224149 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } 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 } from '@ant-design/icons'; import { useStore } from '../store'; -import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App'; +import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; @@ -71,6 +71,7 @@ const ConnectionModal: React.FC<{ const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null); const [driverStatusMap, setDriverStatusMap] = useState>({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); + const [selectingSSHKey, setSelectingSSHKey] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); @@ -578,6 +579,30 @@ const ConnectionModal: React.FC<{ } }; + const handleSelectSSHKeyFile = async () => { + if (selectingSSHKey) { + return; + } + try { + setSelectingSSHKey(true); + const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim(); + const res = await SelectSSHKeyFile(currentPath); + if (res?.success) { + const data = res.data || {}; + const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); + if (selectedPath) { + form.setFieldValue('sshKeyPath', selectedPath); + } + } else if (res?.message !== 'Cancelled') { + message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`); + } + } catch (e: any) { + message.error(`选择私钥文件失败: ${e?.message || String(e)}`); + } finally { + setSelectingSSHKey(false); + } + }; + useEffect(() => { if (open) { setTestResult(null); // Reset test result @@ -1493,8 +1518,15 @@ const ConnectionModal: React.FC<{
- - + + + + + + +
)} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index f954704..03d829f 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -156,6 +156,8 @@ export function SelectDriverDownloadDirectory(arg1:string):Promise; +export function SelectSSHKeyFile(arg1:string):Promise; + export function SetWindowTranslucency(arg1:number,arg2:number):Promise; export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index fee9789..23560b5 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -306,6 +306,10 @@ export function SelectDriverPackageFile(arg1) { return window['go']['app']['App']['SelectDriverPackageFile'](arg1); } +export function SelectSSHKeyFile(arg1) { + return window['go']['app']['App']['SelectSSHKeyFile'](arg1); +} + export function SetWindowTranslucency(arg1, arg2) { return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 556c5a3..67275dd 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "os" + "path/filepath" "sort" "strconv" "strings" @@ -77,6 +78,48 @@ func (a *App) ImportConfigFile() connection.QueryResult { return connection.QueryResult{Success: true, Data: string(content)} } +func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentPath) + if defaultDir == "" { + if home, err := os.UserHomeDir(); err == nil { + defaultDir = filepath.Join(home, ".ssh") + } + } + if filepath.Ext(defaultDir) != "" { + defaultDir = filepath.Dir(defaultDir) + } + if defaultDir != "" && !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择 SSH 私钥文件", + DefaultDirectory: defaultDir, + Filters: []runtime.FileFilter{ + { + DisplayName: "私钥文件", + Pattern: "*.pem;*.key;*.ppk;*id_rsa*", + }, + { + DisplayName: "所有文件", + Pattern: "*", + }, + }, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} +} + // PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据 func (a *App) PreviewImportFile(filePath string) connection.QueryResult { if filePath == "" { From 66a3113fa8de8f0cbebe5459fa1db87503ed653d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 14:13:27 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix(datagrid-mysql):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DMySQL=E8=A1=8C=E7=BC=96=E8=BE=91=E6=97=B6date?= =?UTF-8?q?time=E7=A9=BA=E5=80=BC=E6=8F=90=E4=BA=A4=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL - 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp - 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景 - refs #113 --- frontend/src/components/DataGrid.tsx | 61 +++++++++++++++++++- internal/db/mysql_impl.go | 85 ++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 04fde1b..7dee7a7 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -90,6 +90,14 @@ const normalizeDateTimeString = (val: string) => { return `${match[1]} ${match[2]}`; }; +const isTemporalColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + if (raw.includes('datetime') || raw.includes('timestamp')) return true; + const base = raw.split(/[ (]/)[0]; + return base === 'date' || base === 'time' || base === 'year'; +}; + // --- Helper: Format Value --- const formatCellValue = (val: any) => { try { @@ -764,6 +772,35 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMap]); + const normalizeCommitCellValue = useCallback( + (columnName: string, value: any, mode: 'insert' | 'update') => { + if (value === undefined) return undefined; + const normalizedName = String(columnName || '').trim(); + const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; + const temporal = isTemporalColumnType(meta?.type); + + if (!temporal) { + return value; + } + + if (value === null) { + return null; + } + + if (typeof value === 'string') { + const raw = value.trim(); + if (raw === '') { + // INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。 + return mode === 'insert' ? undefined : null; + } + return normalizeDateTimeString(value); + } + + return value; + }, + [columnMetaMap, columnMetaMapByLowerName] + ); + const renderColumnTitle = useCallback((name: string): React.ReactNode => { const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; @@ -1814,7 +1851,17 @@ const DataGrid: React.FC = ({ const updates: any[] = []; const deletes: any[] = []; - addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); }); + addedRows.forEach(row => { + const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; + const normalizedValues: Record = {}; + Object.entries(vals).forEach(([col, val]) => { + const normalizedVal = normalizeCommitCellValue(col, val, 'insert'); + if (normalizedVal !== undefined) { + normalizedValues[col] = normalizedVal; + } + }); + inserts.push(normalizedValues); + }); deletedRowKeys.forEach(keyStr => { // Find original data const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr); @@ -1847,8 +1894,16 @@ const DataGrid: React.FC = ({ }); } - if (Object.keys(values).length === 0) return; - updates.push({ keys: pkData, values }); + const normalizedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + const normalizedVal = normalizeCommitCellValue(col, val, 'update'); + if (normalizedVal !== undefined) { + normalizedValues[col] = normalizedVal; + } + }); + + if (Object.keys(normalizedValues).length === 0) return; + updates.push({ keys: pkData, values: normalizedValues }); }); if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) { diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index 44b269d..2c6a332 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e return fmt.Errorf("connection not open") } + columnTypeMap := m.loadColumnTypeMap(tableName) + tx, err := m.conn.Begin() if err != nil { return err @@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var args []interface{} for k, v := range pk { wheres = append(wheres, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(wheres) == 0 { continue @@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e for k, v := range update.Values { sets = append(sets, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(sets) == 0 { @@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var wheres []string for k, v := range update.Keys { wheres = append(wheres, fmt.Sprintf("`%s` = ?", k)) - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap)) } if len(wheres) == 0 { @@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e var args []interface{} for k, v := range row { + normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap) + if omit { + continue + } cols = append(cols, fmt.Sprintf("`%s`", k)) placeholders = append(placeholders, "?") - args = append(args, normalizeMySQLDateTimeValue(v)) + args = append(args, normalizedValue) } if len(cols) == 0 { + query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName) + res, err := tx.Exec(query) + if err != nil { + return fmt.Errorf("insert error: %v", err) + } + if affected, err := res.RowsAffected(); err == nil && affected == 0 { + return fmt.Errorf("插入未生效:未影响任何行") + } continue } @@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} { return value } +func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string { + result := map[string]string{} + table := strings.TrimSpace(tableName) + if table == "" { + return result + } + + columns, err := m.GetColumns("", table) + if err != nil { + logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err) + return result + } + + for _, col := range columns { + name := strings.ToLower(strings.TrimSpace(col.Name)) + if name == "" { + continue + } + result[name] = strings.TrimSpace(col.Type) + } + return result +} + +func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) { + columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))])) + if !isMySQLTemporalColumnType(columnType) { + return value, false + } + text, ok := value.(string) + if ok && strings.TrimSpace(text) == "" { + // INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP)。 + return nil, true + } + return normalizeMySQLDateTimeValue(value), false +} + +func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} { + columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))])) + if !isMySQLTemporalColumnType(columnType) { + return value + } + text, ok := value.(string) + if ok && strings.TrimSpace(text) == "" { + return nil + } + return normalizeMySQLDateTimeValue(value) +} + +func isMySQLTemporalColumnType(columnType string) bool { + raw := strings.ToLower(strings.TrimSpace(columnType)) + if raw == "" { + return false + } + if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") { + return true + } + base := raw + if idx := strings.IndexAny(base, "( "); idx >= 0 { + base = base[:idx] + } + return base == "date" || base == "time" || base == "year" +} + func hasTimezoneOffset(text string) bool { pos := strings.LastIndexAny(text, "+-") if pos < 0 || pos < 10 || pos+1 >= len(text) { From bec5013a4474e1213173c50e08292de7b869bf9f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 14:23:36 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=90=9B=20fix(update-windows):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=8F=98=E9=87=8F=E8=BD=AC=E4=B9=89=E5=AF=BC=E8=87=B4?= =?UTF-8?q?TARGET=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 buildWindowsScript 改为模板占位符替换,避免 fmt.Sprintf 吞掉批处理百分号 - 修正 for %%I/%%F 语法,消除“此时不应有 TARGET~nxI”报错 - 保留原有更新重试与日志流程,不改变下载与安装主链路 - refs #112 --- internal/app/methods_update.go | 80 ++++++++++--------- .../app/methods_update_windows_script_test.go | 40 ++++++++++ 2 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 internal/app/methods_update_windows_script_test.go diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 3db56fa..20f1fdc 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" stdRuntime "runtime" + "strconv" "strings" "time" @@ -857,55 +858,55 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error { } func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string { - return fmt.Sprintf(`@echo off + script := `@echo off setlocal EnableExtensions EnableDelayedExpansion -set "SOURCE=%s" -set "TARGET=%s" -set "STAGED=%s" -set "LOG_FILE=%s" -set PID=%d +set "SOURCE=__GONAVI_UPDATE_SOURCE__" +set "TARGET=__GONAVI_UPDATE_TARGET__" +set "STAGED=__GONAVI_UPDATE_STAGED__" +set "LOG_FILE=__GONAVI_UPDATE_LOG__" +set PID=__GONAVI_UPDATE_PID__ call :log updater started -if not exist "%%SOURCE%%" ( - call :log source file not found: %%SOURCE%% +if not exist "%SOURCE%" ( + call :log source file not found: %SOURCE% exit /b 1 ) -for %%I in ("%%TARGET%%") do set "TARGET_NAME=%%~nxI" -for %%I in ("%%SOURCE%%") do set "SOURCE_EXT=%%~xI" +for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI" +for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI" set "SOURCE_EXE=" -if /I "%%SOURCE_EXT%%"==".zip" ( - set "EXTRACT_DIR=%%STAGED%%\_extract" - if exist "%%EXTRACT_DIR%%" ( - rmdir /S /Q "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1 +if /I "%SOURCE_EXT%"==".zip" ( + set "EXTRACT_DIR=%STAGED%\_extract" + if exist "%EXTRACT_DIR%" ( + rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1 ) - mkdir "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1 - powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%%LOG_FILE%%" 2>&1 - if %%ERRORLEVEL%% NEQ 0 ( - call :log expand zip failed: %%SOURCE%% + mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1 + powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1 + if %ERRORLEVEL% NEQ 0 ( + call :log expand zip failed: %SOURCE% exit /b 1 ) - if exist "%%EXTRACT_DIR%%\%%TARGET_NAME%%" ( - set "SOURCE_EXE=%%EXTRACT_DIR%%\%%TARGET_NAME%%" + if exist "%EXTRACT_DIR%\%TARGET_NAME%" ( + set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%" ) else ( - for /R "%%EXTRACT_DIR%%" %%F in (*.exe) do ( + for /R "%EXTRACT_DIR%" %%F in (*.exe) do ( if not defined SOURCE_EXE ( set "SOURCE_EXE=%%~fF" ) ) ) if not defined SOURCE_EXE ( - call :log no executable found in portable zip: %%SOURCE%% + call :log no executable found in portable zip: %SOURCE% exit /b 1 ) ) else ( - set "SOURCE_EXE=%%SOURCE%%" + set "SOURCE_EXE=%SOURCE%" ) :waitloop -tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul -if %%ERRORLEVEL%%==0 ( +tasklist /FI "PID eq %PID%" | find "%PID%" >nul +if %ERRORLEVEL%==0 ( timeout /t 1 /nobreak >nul goto waitloop ) @@ -913,11 +914,11 @@ call :log host process exited set /a RETRY=0 :move_retry -move /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1 -if %%ERRORLEVEL%%==0 goto move_done +move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1 +if %ERRORLEVEL%==0 goto move_done -copy /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1 -if %%ERRORLEVEL%%==0 goto move_done +copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1 +if %ERRORLEVEL%==0 goto move_done set /a RETRY+=1 if !RETRY! LSS 20 ( @@ -929,23 +930,30 @@ call :log replace failed after retries (portable mode, no elevation): check dire exit /b 1 :move_done -start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1 -if %%ERRORLEVEL%% NEQ 0 ( +start "" "%TARGET%" >> "%LOG_FILE%" 2>&1 +if %ERRORLEVEL% NEQ 0 ( call :log cmd start failed, trying powershell Start-Process - powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1 - if %%ERRORLEVEL%% NEQ 0 ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1 + if %ERRORLEVEL% NEQ 0 ( call :log relaunch failed exit /b 1 ) ) -rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1 +rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1 call :log update finished exit /b 0 :log -echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%" +echo [%date% %time%] %*>>"%LOG_FILE%" exit /b 0 -`, source, target, stagedDir, logPath, pid) +` + return strings.NewReplacer( + "__GONAVI_UPDATE_SOURCE__", source, + "__GONAVI_UPDATE_TARGET__", target, + "__GONAVI_UPDATE_STAGED__", stagedDir, + "__GONAVI_UPDATE_LOG__", logPath, + "__GONAVI_UPDATE_PID__", strconv.Itoa(pid), + ).Replace(script) } func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string { diff --git a/internal/app/methods_update_windows_script_test.go b/internal/app/methods_update_windows_script_test.go new file mode 100644 index 0000000..9313497 --- /dev/null +++ b/internal/app/methods_update_windows_script_test.go @@ -0,0 +1,40 @@ +package app + +import ( + "strings" + "testing" +) + +func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) { + script := buildWindowsScript( + `C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`, + `C:\Program Files\GoNavi\GoNavi.exe`, + `C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`, + `C:\Program Files\GoNavi\logs\update-install.log`, + 13579, + ) + + mustContain := []string{ + `for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`, + `for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`, + `for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`, + `set "SOURCE_EXE=%%~fF"`, + } + for _, want := range mustContain { + if !strings.Contains(script, want) { + t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script) + } + } + + mustNotContain := []string{ + `for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`, + `for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`, + `for /R "%EXTRACT_DIR%" %F in (*.exe) do (`, + `set "SOURCE_EXE=%~fF"`, + } + for _, bad := range mustNotContain { + if strings.Contains(script, bad) { + t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script) + } + } +}