diff --git a/web/static/css/style.css b/web/static/css/style.css index 21ee7ac6..bdb9342c 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -10129,7 +10129,8 @@ header { .webshell-db-schema-tree { flex: 1; min-height: 0; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; padding: 10px; } .webshell-db-sidebar-hint { @@ -10154,6 +10155,9 @@ header { padding: 8px 10px; font-size: 0.82rem; color: var(--text-primary); + min-width: 0; + white-space: nowrap; + overflow: hidden; } .webshell-db-group-title::-webkit-details-marker { display: none; @@ -10162,13 +10166,22 @@ header { margin-left: auto; font-size: 0.74rem; color: var(--text-secondary); + flex: 0 0 auto; } .webshell-db-group-items { border-top: 1px solid var(--border-color); display: flex; flex-direction: column; max-height: 260px; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; +} +.webshell-db-table-node { + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + min-width: 0; +} +.webshell-db-table-node:last-child { + border-bottom: 0; } .webshell-db-table-item { border: 0; @@ -10181,13 +10194,57 @@ header { cursor: pointer; color: var(--text-secondary); font-size: 0.8rem; + list-style: none; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} +.webshell-db-table-item::-webkit-details-marker { + display: none; } .webshell-db-table-item:hover { background: rgba(0, 102, 255, 0.06); color: var(--text-primary); } +.webshell-db-column-list { + display: flex; + flex-direction: column; + margin: 0 0 4px; +} +.webshell-db-column-item { + border: 0; + background: transparent; + text-align: left; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px 6px 24px; + cursor: pointer; + color: var(--text-secondary); + font-size: 0.78rem; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} +.webshell-db-column-item:hover { + background: rgba(0, 102, 255, 0.06); + color: var(--text-primary); +} +.webshell-db-column-empty { + padding: 4px 10px 8px 24px; + font-size: 0.74rem; + color: var(--text-secondary); +} .webshell-db-icon { opacity: 0.85; + flex: 0 0 auto; +} +.webshell-db-label { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .webshell-db-main { min-width: 0; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ad8c7b01..3658c853 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -393,7 +393,8 @@ "dbSchema": "Database Schema", "dbLoadSchema": "Load Schema", "dbNoSchema": "No schema yet, click Load Schema", - "dbSelectTableHint": "Click a table to generate SELECT SQL", + "dbSelectTableHint": "Click a table to expand columns and generate SQL", + "dbNoColumns": "No column details", "dbResultTable": "Result Table", "dbClearSql": "Clear SQL", "dbTemplateSql": "SQL Template", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index d63a1477..61adece6 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -393,7 +393,8 @@ "dbSchema": "数据库结构", "dbLoadSchema": "加载结构", "dbNoSchema": "暂无数据库结构,请先加载", - "dbSelectTableHint": "点击表名可自动生成查询 SQL", + "dbSelectTableHint": "点击表名可展开列信息并生成查询 SQL", + "dbNoColumns": "暂无列信息", "dbResultTable": "结果表格", "dbClearSql": "清空 SQL", "dbTemplateSql": "示例 SQL", diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 0a5ff5cc..110e08ba 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -118,7 +118,8 @@ function wsT(key) { 'webshell.dbSchema': '数据库结构', 'webshell.dbLoadSchema': '加载结构', 'webshell.dbNoSchema': '暂无数据库结构,请先加载', - 'webshell.dbSelectTableHint': '点击表名可生成查询 SQL', + 'webshell.dbSelectTableHint': '点击表名可展开列信息并生成查询 SQL', + 'webshell.dbNoColumns': '暂无列信息', 'webshell.dbResultTable': '结果表格', 'webshell.dbClearSql': '清空 SQL', 'webshell.dbTemplateSql': '示例 SQL', @@ -948,6 +949,10 @@ function webshellDbQuoteIdentifier(type, name) { return '"' + v.replace(/"/g, '""') + '"'; } +function webshellDbQuoteLiteral(value) { + return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'"; +} + function buildWebshellDbCommand(cfg, isTestOnly, options) { options = options || {}; var type = cfg.type || 'mysql'; @@ -998,19 +1003,53 @@ function buildWebshellDbSchemaCommand(cfg) { var type = cfg.type || 'mysql'; var schemaSQL = ''; if (type === 'mysql') { - schemaSQL = "SELECT SCHEMA_NAME AS db_name, '' AS table_name FROM INFORMATION_SCHEMA.SCHEMATA UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY db_name, table_name;"; + schemaSQL = "SELECT SCHEMA_NAME AS db_name, '' AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.SCHEMATA UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS ORDER BY db_name, table_name, column_name;"; } else if (type === 'pgsql') { - schemaSQL = "SELECT table_schema AS db_name, table_name FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') ORDER BY table_schema, table_name;"; + schemaSQL = "SELECT table_schema AS db_name, table_name, '' AS column_name FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') UNION ALL SELECT table_schema AS db_name, table_name, column_name FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY db_name, table_name, column_name;"; } else if (type === 'sqlite') { - schemaSQL = "SELECT 'main' AS db_name, name AS table_name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;"; + schemaSQL = "SELECT 'main' AS db_name, name AS table_name, '' AS column_name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' UNION ALL SELECT 'main' AS db_name, m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p ON 1=1 WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY db_name, table_name, column_name;"; } else if (type === 'mssql') { - schemaSQL = "SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME;"; + schemaSQL = "SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS ORDER BY db_name, table_name, column_name;"; } else { return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; } return buildWebshellDbCommand(cfg, false, { sql: schemaSQL }); } +function buildWebshellDbColumnsCommand(cfg, dbName, tableName) { + var type = cfg.type || 'mysql'; + var sql = ''; + if (type === 'mysql') { + sql = "SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " AND TABLE_NAME=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ORDINAL_POSITION;"; + } else if (type === 'pgsql') { + sql = "SELECT column_name FROM information_schema.columns WHERE table_schema=" + webshellDbQuoteLiteral(dbName) + " AND table_name=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ordinal_position;"; + } else if (type === 'sqlite') { + sql = "SELECT name AS column_name FROM pragma_table_info(" + webshellDbQuoteLiteral(tableName) + ") ORDER BY cid;"; + } else if (type === 'mssql') { + sql = "SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " AND TABLE_NAME=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ORDINAL_POSITION;"; + } else { + return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; + } + return buildWebshellDbCommand(cfg, false, { sql: sql }); +} + +function buildWebshellDbColumnsByDatabaseCommand(cfg, dbName) { + var type = cfg.type || 'mysql'; + var sql = ''; + if (type === 'mysql') { + sql = "SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " ORDER BY TABLE_NAME, ORDINAL_POSITION;"; + } else if (type === 'pgsql') { + sql = "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema=" + webshellDbQuoteLiteral(dbName) + " ORDER BY table_name, ordinal_position;"; + } else if (type === 'sqlite') { + sql = "SELECT m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p ON 1=1 WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid;"; + } else if (type === 'mssql') { + sql = "SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " ORDER BY TABLE_NAME, ORDINAL_POSITION;"; + } else { + return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; + } + return buildWebshellDbCommand(cfg, false, { sql: sql }); +} + function parseWebshellDbExecOutput(rawOutput) { var raw = String(rawOutput || ''); var rc = null; @@ -1033,6 +1072,7 @@ function parseWebshellDbSchema(rawOutput) { var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); var dbIdx = headers.indexOf('db_name'); var tableIdx = headers.indexOf('table_name'); + var columnIdx = headers.indexOf('column_name'); if (dbIdx < 0 || tableIdx < 0) return {}; var schema = {}; for (var i = 1; i < lines.length; i++) { @@ -1040,13 +1080,115 @@ function parseWebshellDbSchema(rawOutput) { if (cols.length !== headers.length) continue; var db = cols[dbIdx] || 'default'; var table = cols[tableIdx] || ''; - if (!schema[db]) schema[db] = []; - if (table) schema[db].push(table); + var column = columnIdx >= 0 ? (cols[columnIdx] || '') : ''; + if (!schema[db]) schema[db] = { tables: {} }; + if (!table) continue; + if (!schema[db].tables[table]) schema[db].tables[table] = []; + if (column && schema[db].tables[table].indexOf(column) < 0) { + schema[db].tables[table].push(column); + } } - Object.keys(schema).forEach(function (dbName) { - schema[dbName].sort(function (a, b) { return a.localeCompare(b); }); + return normalizeWebshellDbSchema(schema); +} + +function parseWebshellDbColumns(rawOutput) { + var text = String(rawOutput || '').trim(); + if (!text) return []; + var lines = text.split(/\r?\n/).filter(function (line) { + return line && line.trim() && !/^\(\d+\s+rows?\)$/i.test(line.trim()) && !/^[-+\s|]+$/.test(line.trim()); }); - return schema; + if (lines.length < 2) return []; + var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); + if (!delimiter) { + if (String(lines[0] || '').trim().toLowerCase() !== 'column_name') return []; + var plainColumns = []; + for (var p = 1; p < lines.length; p++) { + var plainName = String(lines[p] || '').trim(); + if (!plainName || plainColumns.indexOf(plainName) >= 0) continue; + plainColumns.push(plainName); + } + return plainColumns; + } + var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); + var colIdx = headers.indexOf('column_name'); + if (colIdx < 0) return []; + var columns = []; + for (var i = 1; i < lines.length; i++) { + var cols = lines[i].split(delimiter).map(function (s) { return String(s || '').trim(); }); + if (cols.length !== headers.length) continue; + var name = cols[colIdx] || ''; + if (!name || columns.indexOf(name) >= 0) continue; + columns.push(name); + } + return columns; +} + +function parseWebshellDbTableColumns(rawOutput) { + var text = String(rawOutput || '').trim(); + if (!text) return {}; + var lines = text.split(/\r?\n/).filter(function (line) { + return line && line.trim() && !/^\(\d+\s+rows?\)$/i.test(line.trim()) && !/^[-+\s|]+$/.test(line.trim()); + }); + if (lines.length < 2) return {}; + var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); + if (!delimiter) return {}; + var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); + var tableIdx = headers.indexOf('table_name'); + var colIdx = headers.indexOf('column_name'); + if (tableIdx < 0 || colIdx < 0) return {}; + var tableColumns = {}; + for (var i = 1; i < lines.length; i++) { + var cols = lines[i].split(delimiter).map(function (s) { return String(s || '').trim(); }); + if (cols.length !== headers.length) continue; + var tableName = cols[tableIdx] || ''; + var colName = cols[colIdx] || ''; + if (!tableName || !colName) continue; + if (!tableColumns[tableName]) tableColumns[tableName] = []; + if (tableColumns[tableName].indexOf(colName) >= 0) continue; + tableColumns[tableName].push(colName); + } + return tableColumns; +} + +function normalizeWebshellDbSchema(rawSchema) { + if (!rawSchema || typeof rawSchema !== 'object') return {}; + var normalized = {}; + Object.keys(rawSchema).forEach(function (dbName) { + var dbEntry = rawSchema[dbName]; + var tableMap = {}; + + if (Array.isArray(dbEntry)) { + dbEntry.forEach(function (tableName) { + var t = String(tableName || '').trim(); + if (!t) return; + if (!tableMap[t]) tableMap[t] = []; + }); + } else if (dbEntry && typeof dbEntry === 'object') { + var tablesSource = (dbEntry.tables && typeof dbEntry.tables === 'object') ? dbEntry.tables : dbEntry; + Object.keys(tablesSource).forEach(function (tableName) { + if (tableName === 'tables') return; + var t = String(tableName || '').trim(); + if (!t) return; + var rawColumns = tablesSource[tableName]; + var columns = Array.isArray(rawColumns) ? rawColumns : []; + var uniqColumns = []; + columns.forEach(function (colName) { + var c = String(colName || '').trim(); + if (!c || uniqColumns.indexOf(c) >= 0) return; + uniqColumns.push(c); + }); + uniqColumns.sort(function (a, b) { return a.localeCompare(b); }); + tableMap[t] = uniqColumns; + }); + } + + var sortedTables = {}; + Object.keys(tableMap).sort(function (a, b) { return a.localeCompare(b); }).forEach(function (tableName) { + sortedTables[tableName] = tableMap[tableName]; + }); + normalized[dbName] = { tables: sortedTables }; + }); + return normalized; } function simplifyWebshellAiError(rawMessage) { @@ -1549,6 +1691,15 @@ function selectWebshell(id, stateReady) { var dbPassEl = document.getElementById('webshell-db-pass'); var dbNameEl = document.getElementById('webshell-db-name'); var dbSqliteEl = document.getElementById('webshell-db-sqlite-path'); + var dbColumnsLoading = {}; + var dbColumnsBatchLoading = {}; + var dbColumnsBatchLoaded = {}; + + function resetDbColumnLoadCache() { + dbColumnsLoading = {}; + dbColumnsBatchLoading = {}; + dbColumnsBatchLoaded = {}; + } function setDbActionButtonsDisabled(disabled) { if (dbRunBtn) dbRunBtn.disabled = disabled; @@ -1599,6 +1750,7 @@ function selectWebshell(id, stateReady) { saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); + resetDbColumnLoadCache(); renderDbSchemaTree(); return; } @@ -1623,6 +1775,7 @@ function selectWebshell(id, stateReady) { saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); + resetDbColumnLoadCache(); renderDbSchemaTree(); } }); @@ -1632,8 +1785,15 @@ function selectWebshell(id, stateReady) { function renderDbSchemaTree() { if (!dbSchemaTreeEl) return; var cfg = getWebshellDbConfig(conn); - var schema = (cfg && cfg.schema && typeof cfg.schema === 'object') ? cfg.schema : {}; + var schema = normalizeWebshellDbSchema((cfg && cfg.schema && typeof cfg.schema === 'object') ? cfg.schema : {}); var dbs = Object.keys(schema).sort(function (a, b) { return a.localeCompare(b); }); + var openTableKeys = {}; + dbSchemaTreeEl.querySelectorAll('.webshell-db-table-node[open]').forEach(function (node) { + var openDb = node.getAttribute('data-db') || ''; + var openTable = node.getAttribute('data-table') || ''; + if (!openDb || !openTable) return; + openTableKeys[openDb + '::' + openTable] = true; + }); if (!dbs.length) { dbSchemaTreeEl.innerHTML = '