Merge pull request #121 from Syngnat/release/0.4.7

Release/0.4.7
This commit is contained in:
Syngnat
2026-02-26 14:28:10 +08:00
committed by GitHub
13 changed files with 1410 additions and 181 deletions

View File

@@ -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<Record<string, DriverStatusSnapshot>>({});
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(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<{
<Input.Password placeholder="密码" />
</Form.Item>
</div>
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Input placeholder="绝对路径" />
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="sshKeyPath" noStyle>
<Input placeholder="绝对路径" />
</Form.Item>
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
...
</Button>
</Space.Compact>
</Form.Item>
</div>
)}

View File

@@ -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<DataGridProps> = ({
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<DataGridProps> = ({
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<string, any> = {};
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<DataGridProps> = ({
});
}
if (Object.keys(values).length === 0) return;
updates.push({ keys: pkData, values });
const normalizedValues: Record<string, any> = {};
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) {

View File

@@ -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<any[]>([]);
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
const [batchDbContext, setBatchDbContext] = useState<any>(null);
const [selectedConnection, setSelectedConnection] = useState<string>('');
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
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);
@@ -1097,7 +1111,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[]) => {
@@ -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 }>
</Button>
<span style={{ color: '#999' }}>
{checkedTableKeys.length} / {batchTables.length}
{checkedTableKeys.length} / {batchTables.length}
</span>
</Space>
</div>
@@ -2869,14 +2907,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onChange={(values) => setCheckedTableKeys(values as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{batchTables.map(table => (
<Checkbox key={table.key} value={table.key}>
<TableOutlined style={{ marginRight: 8 }} />
{table.title}
</Checkbox>
))}
</Space>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{groupedBatchObjects.tables.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.tables.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.tables.map(table => (
<Checkbox key={table.key} value={table.key}>
<TableOutlined style={{ marginRight: 8 }} />
{table.title}
</Checkbox>
))}
</Space>
</div>
)}
{groupedBatchObjects.views.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.views.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.views.map(view => (
<Checkbox key={view.key} value={view.key}>
<EyeOutlined style={{ marginRight: 8 }} />
{view.title}
</Checkbox>
))}
</Space>
</div>
)}
</div>
</Checkbox.Group>
</div>
</>

View File

@@ -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<boolean> => {
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 && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}></Button>}
{!isNewTable && !readOnly && supportsMysqlSchemaOps() && (
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button icon={<EditOutlined />} onClick={openTableCommentModal}></Button>
)}
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}></Button>}
@@ -1710,15 +2048,15 @@ ${selectedTrigger.statement}`;
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!selectedIndex} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedIndex} onClick={handleDeleteIndex}></Button>
{!supportsMysqlSchemaOps() && (
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}></Button>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsMysqlSchemaOps() && selectedIndex && (
{supportsIndexSchemaOps() && selectedIndex && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedIndex.name}
</span>
@@ -1813,15 +2151,15 @@ ${selectedTrigger.statement}`;
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} onClick={openCreateForeignKeyModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!selectedForeignKey} onClick={openEditForeignKeyModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedForeignKey} onClick={handleDeleteForeignKey}></Button>
{!supportsMysqlSchemaOps() && (
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}></Button>
{!supportsForeignKeySchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsMysqlSchemaOps() && selectedForeignKey && (
{supportsForeignKeySchemaOps() && selectedForeignKey && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedForeignKey.constraintName}
</span>
@@ -2077,13 +2415,7 @@ ${selectedTrigger.statement}`;
<Space wrap>
<Select
value={indexForm.kind}
options={[
{ label: '普通索引(非聚合)', value: 'NORMAL' },
{ label: '唯一索引', value: 'UNIQUE' },
{ label: '主键索引(聚合)', value: 'PRIMARY' },
{ label: '全文索引', value: 'FULLTEXT' },
{ label: '空间索引', value: 'SPATIAL' },
]}
options={getIndexKindOptions()}
onChange={(val: IndexKind) =>
setIndexForm(prev => ({
...prev,
@@ -2097,7 +2429,7 @@ ${selectedTrigger.statement}`;
<Select
value={indexForm.indexType}
onChange={(val) => 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'}
/>

View File

@@ -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 中重复定义

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -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,
}
})
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')
},
},
},
},
})

View File

@@ -156,6 +156,8 @@ export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.Qu
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -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);
}

View File

@@ -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 == "" {
@@ -485,7 +528,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 +600,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 +611,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 +628,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 +668,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 +683,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 +789,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 == "<nil>" {
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 == "<nil>" {
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 == "<nil>" {
continue
}
return text
}
}
for _, value := range row {
if value == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" || text == "<nil>" {
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 +1242,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 +1306,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 +1318,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)

View File

@@ -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 {

View File

@@ -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)
}
}
}

View File

@@ -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) {