From fa27d40eb23b1d00358103c5568c00f51df86188 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 08:06:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=8A=9F=E8=83=BD=20API=20=E5=8F=8A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/connections/connections.controller.ts | 245 +++++++++++++++++- .../src/connections/connections.routes.ts | 9 +- packages/backend/src/websocket.ts | 71 +++-- packages/data/nexus-terminal.db | Bin 49152 -> 49152 bytes .../src/components/ConnectionList.vue | 222 +++++++++++++--- packages/frontend/src/locales/en.json | 11 +- packages/frontend/src/locales/zh.json | 11 +- .../frontend/src/stores/connections.store.ts | 21 ++ .../frontend/src/views/ConnectionsView.vue | 68 ++++- 9 files changed, 586 insertions(+), 72 deletions(-) diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 2e7f4f5..782f5d0 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -507,5 +507,246 @@ export const deleteConnection = async (req: Request, res: Response): Promise => { + const connectionId = parseInt(req.params.id, 10); + const userId = req.session.userId; + const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒) + + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; + } + + try { + // 1. 获取完整的连接信息 (包括加密凭证和代理信息) + const connInfo = await new Promise((resolve, reject) => { + // 查询连接信息,并 LEFT JOIN 代理信息 + db.get( + `SELECT + c.*, + p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, + p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, + p.encrypted_password as proxy_encrypted_password + FROM connections c + LEFT JOIN proxies p ON c.proxy_id = p.id + WHERE c.id = ?`, + [connectionId], + (err, row: any) => { + if (err) { + console.error(`查询连接 ${connectionId} 详细信息时出错:`, err.message); + return reject(new Error('获取连接信息失败')); + } + resolve(row || null); + } + ); + }); + + if (!connInfo) { + res.status(404).json({ message: '连接配置未找到。' }); + return; + } + + // 2. 构建包含解密凭证和代理对象的 FullConnectionInfo + const fullConnInfo: FullConnectionInfo = { + ...connInfo, // 包含 id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at + proxy: null, // 初始化 proxy + }; + + try { + if (connInfo.auth_method === 'password' && connInfo.encrypted_password) { + fullConnInfo.password = decrypt(connInfo.encrypted_password); + } else if (connInfo.auth_method === 'key' && connInfo.encrypted_private_key) { + fullConnInfo.privateKey = decrypt(connInfo.encrypted_private_key); + if (connInfo.encrypted_passphrase) { + fullConnInfo.passphrase = decrypt(connInfo.encrypted_passphrase); + } + } + // 如果凭证解密失败,这里会抛出错误 + + // 处理代理信息 + if (connInfo.proxy_db_id) { + fullConnInfo.proxy = { + id: connInfo.proxy_db_id, + name: connInfo.proxy_name, + type: connInfo.proxy_type, + host: connInfo.proxy_host, + port: connInfo.proxy_port, + username: connInfo.proxy_username || undefined, + password: connInfo.proxy_encrypted_password ? decrypt(connInfo.proxy_encrypted_password) : undefined, + }; + } + } catch (decryptError: any) { + console.error(`处理连接 ${connectionId} 凭证或代理凭证失败:`, decryptError); + res.status(500).json({ success: false, message: `处理凭证失败: ${decryptError.message}` }); + return; + } + + + // 3. 构建 ssh2 连接配置 + let connectConfig: any = { + host: fullConnInfo.host, + port: fullConnInfo.port, + username: fullConnInfo.username, + password: fullConnInfo.password, + privateKey: fullConnInfo.privateKey, + passphrase: fullConnInfo.passphrase, + readyTimeout: TEST_TIMEOUT, // 使用测试超时 + keepaliveInterval: 0, // 测试连接不需要 keepalive + }; + + // 4. 应用代理配置 (复用 websocket.ts 的逻辑,但更健壮) + const sshClient = new Client(); + let connectionPromise: Promise; + + if (fullConnInfo.proxy) { + const proxy = fullConnInfo.proxy; + console.log(`测试连接 ${connectionId}: 应用代理 ${proxy.name} (${proxy.type})`); + if (proxy.type === 'SOCKS5') { + const socksOptions = { + proxy: { + host: proxy.host, + port: proxy.port, + type: 5 as 5, + userId: proxy.username, + password: proxy.password, + }, + command: 'connect' as 'connect', + destination: { + host: fullConnInfo.host, + port: fullConnInfo.port, + }, + timeout: TEST_TIMEOUT, + }; + // SOCKS 连接本身就是一个 Promise + connectionPromise = SocksClient.createConnection(socksOptions) + .then(({ socket }) => { + console.log(`测试连接 ${connectionId}: SOCKS5 代理连接成功`); + connectConfig.sock = socket; + // SSH 连接在 SOCKS 成功后进行 + return new Promise((resolve, reject) => { // 指定 Promise 类型为 void + // 使用 once 可能更符合类型定义 + sshClient.once('ready', resolve).once('error', reject).connect(connectConfig); + }); + }) + .catch(socksError => { + console.error(`测试连接 ${connectionId}: SOCKS5 代理失败:`, socksError); + throw new Error(`SOCKS5 代理连接失败: ${socksError.message}`); // 抛出错误以便捕获 + }); + + } else if (proxy.type === 'HTTP') { + console.log(`测试连接 ${connectionId}: 尝试通过 HTTP 代理 ${proxy.host}:${proxy.port} 建立隧道...`); + // 手动发起 CONNECT 请求 + connectionPromise = new Promise((resolveConnect, rejectConnect) => { + const reqOptions: http.RequestOptions = { + method: 'CONNECT', + host: proxy.host, + port: proxy.port, + path: `${fullConnInfo.host}:${fullConnInfo.port}`, // 目标 SSH 服务器地址和端口 + timeout: TEST_TIMEOUT, + agent: false, // 不使用全局 agent + }; + // 添加代理认证头部 (如果需要) + if (proxy.username) { + const auth = 'Basic ' + Buffer.from(proxy.username + ':' + (proxy.password || '')).toString('base64'); + reqOptions.headers = { + ...reqOptions.headers, + 'Proxy-Authorization': auth, + 'Proxy-Connection': 'Keep-Alive', // 某些代理需要 + 'Host': `${fullConnInfo.host}:${fullConnInfo.port}` // CONNECT 请求的目标 + }; + } + + const req = http.request(reqOptions); + req.on('connect', (res, socket, head) => { + if (res.statusCode === 200) { + console.log(`测试连接 ${connectionId}: HTTP 代理隧道建立成功`); + connectConfig.sock = socket; // 使用建立的隧道 socket + // 在隧道建立后尝试 SSH 连接 + new Promise((resolveSSH, rejectSSH) => { + sshClient.once('ready', resolveSSH).once('error', rejectSSH).connect(connectConfig); + }) + .then(resolveConnect) // SSH 成功则 resolve 外层 Promise + .catch(rejectConnect); // SSH 失败则 reject 外层 Promise + } else { + console.error(`测试连接 ${connectionId}: HTTP 代理 CONNECT 请求失败, 状态码: ${res.statusCode}`); + socket.destroy(); + rejectConnect(new Error(`HTTP 代理连接失败 (状态码: ${res.statusCode})`)); + } + }); + req.on('error', (err) => { + console.error(`测试连接 ${connectionId}: HTTP 代理请求错误:`, err); + rejectConnect(new Error(`HTTP 代理连接错误: ${err.message}`)); + }); + req.on('timeout', () => { + console.error(`测试连接 ${connectionId}: HTTP 代理请求超时`); + req.destroy(); // 销毁请求 + rejectConnect(new Error('HTTP 代理连接超时')); + }); + req.end(); // 发送请求 + }); + } else { + // 未知代理类型 + res.status(400).json({ success: false, message: `不支持的代理类型: ${proxy.type}` }); + return; + } + } else { + // 无代理,直接连接 + connectionPromise = new Promise((resolve, reject) => { // 指定 Promise 类型为 void + // 使用 once 可能更符合类型定义 + sshClient.once('ready', resolve).once('error', reject).connect(connectConfig); + }); + } + + // 5. 执行连接测试并处理结果 + try { + await connectionPromise; + console.log(`测试连接 ${connectionId}: SSH 连接成功`); + res.status(200).json({ success: true, message: '连接测试成功。' }); + } catch (sshError: any) { + console.error(`测试连接 ${connectionId}: SSH 连接失败:`, sshError); + // 尝试提供更具体的错误信息 + let errorMessage = sshError.message || '未知 SSH 错误'; + if (sshError.level === 'client-authentication') { + errorMessage = '认证失败 (用户名、密码或密钥错误)'; + } else if (sshError.code === 'ENOTFOUND' || sshError.code === 'ECONNREFUSED') { + errorMessage = '无法连接到主机或端口'; + } else if (sshError.message.includes('Timed out')) { + errorMessage = `连接超时 (${TEST_TIMEOUT / 1000}秒)`; + } + res.status(500).json({ success: false, message: `连接测试失败: ${errorMessage}` }); + } finally { + // 无论成功失败,都关闭 SSH 客户端 + sshClient.end(); + } + + } catch (error: any) { + console.error(`测试连接 ${connectionId} 时发生内部错误:`, error); + res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); + } +}; diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index 67f0d3c..9416860 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -5,7 +5,8 @@ import { getConnections, getConnectionById, // 引入获取单个连接的控制器 updateConnection, // 引入更新连接的控制器 - deleteConnection // 引入删除连接的控制器 + deleteConnection, // 引入删除连接的控制器 + testConnection // 引入测试连接的控制器 } from './connections.controller'; const router = Router(); @@ -26,9 +27,9 @@ router.get('/:id', getConnectionById); router.put('/:id', updateConnection); // DELETE /api/v1/connections/:id - 删除连接 -router.delete('/:id', deleteConnection); // 使用占位符 +router.delete('/:id', deleteConnection); -// TODO: 添加测试连接路由 -// router.post('/:id/test', testConnection); +// POST /api/v1/connections/:id/test - 测试连接 +router.post('/:id/test', testConnection); export default router; diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 1ecd85e..a6c506c 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -4,10 +4,12 @@ import { Request, RequestHandler } from 'express'; import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用) import { getDb } from './database'; // 引入数据库实例 -import { decrypt } from './utils/crypto'; // 引入解密函数 -import path from 'path'; // 需要 path -import { HttpsProxyAgent } from 'https-proxy-agent'; // 引入 HTTP 代理支持 -import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持 + import { decrypt } from './utils/crypto'; // 引入解密函数 + import path from 'path'; // 需要 path + // import { HttpsProxyAgent } from 'https-proxy-agent'; // 不再直接使用 HttpsProxyAgent for SSH tunneling + import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持 + // import http from 'http'; // 重复导入,保留上面的 + import net from 'net'; // 引入 net 用于 Socket 类型 // 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息 interface AuthenticatedWebSocket extends WebSocket { @@ -642,20 +644,57 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH // 注意:对于 SOCKS5,连接逻辑在 .then 回调中处理 } else if (proxyInfo.type === 'HTTP') { - let proxyUrl = `http://`; + console.log(`WebSocket: 尝试通过 HTTP 代理 ${proxyInfo.host}:${proxyInfo.port} 建立隧道...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 建立隧道...` })); + + // 手动发起 CONNECT 请求 + const reqOptions: http.RequestOptions = { + method: 'CONNECT', + host: proxyInfo.host, + port: proxyInfo.port, + path: `${connInfo.host}:${connInfo.port}`, // 目标 SSH 服务器地址和端口 + timeout: connectConfig.readyTimeout ?? 20000, + agent: false, // 不使用全局 agent + }; + // 添加代理认证头部 (如果需要) if (proxyInfo.username) { - proxyUrl += `${proxyInfo.username}`; - if (proxyPassword) { - proxyUrl += `:${proxyPassword}`; - } - proxyUrl += '@'; + const auth = 'Basic ' + Buffer.from(proxyInfo.username + ':' + (proxyPassword || '')).toString('base64'); + reqOptions.headers = { + ...reqOptions.headers, + 'Proxy-Authorization': auth, + 'Proxy-Connection': 'Keep-Alive', // 某些代理需要 + 'Host': `${connInfo.host}:${connInfo.port}` // CONNECT 请求的目标 + }; } - proxyUrl += `${proxyInfo.host}:${proxyInfo.port}`; - console.log(`WebSocket: 为连接 ${connInfo.id} 配置 HTTP 代理: ${proxyUrl.replace(/:[^:]*@/, ':***@')}`); - connectConfig.agent = new HttpsProxyAgent(proxyUrl); - console.log(`WebSocket: 已配置 HTTP 代理。正在建立 SSH 连接...`); - ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 连接...` })); - connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过代理连接 SSH + + const req = http.request(reqOptions); + req.on('connect', (res, socket, head) => { + if (res.statusCode === 200) { + console.log(`WebSocket: HTTP 代理隧道建立成功。正在建立 SSH 连接...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'HTTP 代理隧道成功,正在建立 SSH...' })); + connectConfig.sock = socket; // 使用建立的隧道 socket + connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过隧道连接 SSH + } else { + console.error(`WebSocket: HTTP 代理 CONNECT 请求失败, 状态码: ${res.statusCode}`); + socket.destroy(); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `HTTP 代理连接失败 (状态码: ${res.statusCode})` })); + cleanupSshConnection(ws); + } + }); + req.on('error', (err) => { + console.error(`WebSocket: HTTP 代理请求错误:`, err); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `HTTP 代理连接错误: ${err.message}` })); + cleanupSshConnection(ws); + }); + req.on('timeout', () => { + console.error(`WebSocket: HTTP 代理请求超时`); + req.destroy(); // 销毁请求 + ws.send(JSON.stringify({ type: 'ssh:error', payload: 'HTTP 代理连接超时' })); + cleanupSshConnection(ws); + }); + req.end(); // 发送请求 + // 注意:对于 HTTP 代理,连接逻辑在 'connect' 事件回调中处理 + } else { console.error(`WebSocket: 未知的代理类型: ${proxyInfo.type}`); ws.send(JSON.stringify({ type: 'ssh:error', payload: `未知的代理类型: ${proxyInfo.type}` })); diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index 3ab3259213fd3c86d988e77e79c280c819625a91..ba743d10b7739ebbe6f6aa71cf6f4bd058e2364f 100644 GIT binary patch delta 299 zcmZo@U~Xt&o*>P*V4{pORNuozcNPwZ4xt_V9o{@oto}r0leRulbMK99-E@stYOAH&Mo!QEp=byFAP4PbCGLv*o85@G~$lu<)N};Qz+|l>a9G`OSg~ z`}z4eSeQi_OA^zInYfslr6=Ew*W|dvz#qwXhp%9>pgQ<zNzs85vmU8Jbwucc=fI5}5vXR`jL@76lF_{?82jzxY3I7Ib*VFU7#h#LURZ#KgkL g%*e^a1+ -import { onMounted, computed } from 'vue'; // 引入 computed +import { onMounted, computed, ref, reactive } from 'vue'; // 统一导入 import { storeToRefs } from 'pinia'; -import { useRouter } from 'vue-router'; // 引入 useRouter -import { useI18n } from 'vue-i18n'; // 引入 useI18n +import { useRouter } from 'vue-router'; +import { useI18n } from 'vue-i18n'; import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型 import { useTagsStore } from '../stores/tags.store'; // 引入 Tags Store const { t } = useI18n(); // 获取 t 函数 const router = useRouter(); // 获取 router 实例 -const connectionsStore = useConnectionsStore(); const tagsStore = useTagsStore(); // 获取 Tags Store 实例 // 使用 storeToRefs 来保持 state 属性的响应性 -const { connections, isLoading, error } = storeToRefs(connectionsStore); -const { tags: allTags } = storeToRefs(tagsStore); // 获取所有标签 +// 不再直接从 connectionsStore 获取 connections, isLoading, error +// const { connections, isLoading, error } = storeToRefs(connectionsStore); +const { tags: allTags, isLoading: isTagsLoading, error: tagsError } = storeToRefs(tagsStore); // 获取所有标签及其状态 + +// 定义 Props,接收筛选后的连接列表 +const props = defineProps<{ + connections: ConnectionInfo[]; +}>(); // 定义组件发出的事件 (添加 edit-connection) const emit = defineEmits(['edit-connection']); -// 组件挂载时获取连接和标签列表 +// 新增:用于跟踪每个连接测试状态的响应式对象 +const testingState = reactive>({}); + +// 组件挂载时获取标签列表 (连接列表由父组件传入) onMounted(() => { - connectionsStore.fetchConnections(); tagsStore.fetchTags(); // 获取标签列表 }); @@ -39,10 +46,68 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => { } return conn.tag_ids .map(tagId => tagMap.value.get(tagId)) // 使用映射获取名称 - .filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string -}; + .filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string + }; -// 辅助函数:格式化时间戳 + // 新增:计算按标签分组的连接 + const groupedConnections = computed(() => { + const groups: { [key: string]: ConnectionInfo[] } = {}; + const untaggedKey = '_untagged_'; // 特殊键,用于未标记的连接 + + // 初始化所有标签组(包括未标记) + groups[untaggedKey] = []; + allTags.value.forEach(tag => { + groups[tag.name] = []; // 使用标签名称作为键 + }); + + // 将连接分配到对应的组 + props.connections.forEach(conn => { + if (!conn.tag_ids || conn.tag_ids.length === 0) { + groups[untaggedKey].push(conn); + } else { + conn.tag_ids.forEach(tagId => { + const tagName = tagMap.value.get(tagId); + if (tagName && groups[tagName]) { // 确保标签存在于映射和分组中 + groups[tagName].push(conn); + } else if (tagName) { + // 如果标签存在但分组未初始化(理论上不应发生),则创建分组 + groups[tagName] = [conn]; + } else { + // 如果 tagId 无效或未找到对应标签名,归入未标记组 + groups[untaggedKey].push(conn); + } + }); + } + }); + + // 过滤掉没有连接的标签组(除了未标记组,即使为空也可能需要显示) + const filteredGroups: { [key: string]: ConnectionInfo[] } = {}; + for (const groupName in groups) { + if (groups[groupName].length > 0 || groupName === untaggedKey) { + // 按连接名称排序每个分组内部的连接 + groups[groupName].sort((a, b) => a.name.localeCompare(b.name)); + filteredGroups[groupName] = groups[groupName]; + } + } + + // 对分组本身进行排序(未标记的放最后) + const sortedGroupNames = Object.keys(filteredGroups).sort((a, b) => { + if (a === untaggedKey) return 1; // 未标记的排在后面 + if (b === untaggedKey) return -1; + return a.localeCompare(b); // 其他按名称排序 + }); + + const sortedGroups: { [key: string]: ConnectionInfo[] } = {}; + sortedGroupNames.forEach(name => { + sortedGroups[name] = filteredGroups[name]; + }); + + + return sortedGroups; + }); + + + // 辅助函数:格式化时间戳 const formatTimestamp = (timestamp: number | null): string => { if (!timestamp) return t('connections.status.never'); // 使用 i18n // TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale @@ -51,6 +116,8 @@ const formatTimestamp = (timestamp: number | null): string => { // 新增:处理删除连接的方法 const handleDelete = async (conn: ConnectionInfo) => { + // 在函数内部获取 store 实例 + const connectionsStore = useConnectionsStore(); // 使用 i18n 获取确认消息 const confirmMessage = t('connections.prompts.confirmDelete', { name: conn.name }); if (window.confirm(confirmMessage)) { @@ -60,37 +127,61 @@ const handleDelete = async (conn: ConnectionInfo) => { // 可以考虑使用更友好的提示方式,例如 toast 通知库 alert(t('connections.errors.deleteFailed', { error: connectionsStore.error || '未知错误' })); } - // 成功时列表会自动更新,无需额外操作 - } -}; + // 成功时列表会自动更新,无需额外操作 + } + }; - + // 新增:处理测试连接的方法 + const handleTestConnection = async (connectionId: number) => { + const connectionsStore = useConnectionsStore(); // 获取 store 实例 + testingState[connectionId] = true; // 设置为正在测试状态 + const result = await connectionsStore.testConnection(connectionId); // 调用 store action + testingState[connectionId] = false; // 清除测试状态 + + // 显示测试结果 + if (result.success) { + alert(t('connections.test.success')); + } else { + alert(t('connections.test.failed', { error: result.message || '未知错误' })); + } + }; + + @@ -132,7 +234,19 @@ export default { margin-top: 1rem; } -.loading, .error, .no-connections { +.connection-group { + margin-bottom: 1.5rem; /* 分组间距 */ +} + +.group-title { + margin-bottom: 0.5rem; + font-size: 1.1em; + font-weight: bold; + border-bottom: 1px solid #eee; + padding-bottom: 0.3rem; +} + +.loading, .error, .no-connections, .no-connections-in-group { padding: 1rem; border: 1px solid #ccc; border-radius: 4px; @@ -170,6 +284,38 @@ button { cursor: pointer; } +.action-button { /* 统一按钮样式 */ + padding: 0.3rem 0.6rem; + margin-right: 0.5rem; + cursor: pointer; + border: none; + border-radius: 4px; + font-size: 0.9em; + min-width: 50px; /* 给按钮一个最小宽度 */ + text-align: center; +} +.connect-button { + background-color: #28a745; /* Green */ + color: white; +} +.edit-button { + background-color: #ffc107; /* Amber */ + color: #333; +} +.test-button { + background-color: #17a2b8; /* Teal */ + color: white; +} +.delete-button { + background-color: #dc3545; /* Red */ + color: white; +} +.action-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + + /* 标签样式 */ .tag-list { display: flex; diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index cff694d..7487ff0 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -35,7 +35,9 @@ "actions": { "connect": "Connect", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "test": "Test", + "testing": "Testing..." }, "form": { "title": "Add New Connection", @@ -77,6 +79,13 @@ }, "status": { "never": "Never" + }, + "filterAllTags": "All Tags", + "untaggedGroup": "Untagged", + "noUntaggedConnections": "No untagged connections found.", + "test": { + "success": "Connection test successful!", + "failed": "Connection test failed: {error}" } }, "proxies": { diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index e97ceba..f860f85 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -35,7 +35,9 @@ "actions": { "connect": "连接", "edit": "编辑", - "delete": "删除" + "delete": "删除", + "test": "测试", + "testing": "测试中..." }, "form": { "title": "添加新连接", @@ -77,6 +79,13 @@ }, "status": { "never": "从未" + }, + "filterAllTags": "所有标签", + "untaggedGroup": "未标记", + "noUntaggedConnections": "没有未标记的连接。", + "test": { + "success": "连接测试成功!", + "failed": "连接测试失败: {error}" } }, "proxies": { diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts index 483ca20..f48ad4d 100644 --- a/packages/frontend/src/stores/connections.store.ts +++ b/packages/frontend/src/stores/connections.store.ts @@ -141,5 +141,26 @@ export const useConnectionsStore = defineStore('connections', { this.isLoading = false; } }, + + // 新增:测试连接 Action + async testConnection(connectionId: number): Promise<{ success: boolean; message?: string }> { + // 注意:这里不改变 isLoading 状态,或者可以引入单独的 testing 状态 + // this.isLoading = true; + // this.error = null; + try { + const response = await axios.post<{ success: boolean; message: string }>(`/api/v1/connections/${connectionId}/test`); + return { success: response.data.success, message: response.data.message }; + } catch (err: any) { + console.error(`测试连接 ${connectionId} 失败:`, err); + const errorMessage = err.response?.data?.message || err.message || '测试连接时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能测试连接。'); + } + // 返回失败状态和错误消息 + return { success: false, message: errorMessage }; + } finally { + // this.isLoading = false; + } + }, }, }); diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index a0f2fdf..505e41e 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -1,17 +1,42 @@