feat: 实现连接测试功能 API 及前端调用
This commit is contained in:
@@ -507,5 +507,246 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: 实现 testConnection
|
// --- 新增:测试连接功能 ---
|
||||||
// export const testConnection = ...
|
import { Client } from 'ssh2'; // 引入 ssh2 Client
|
||||||
|
import { SocksClient } from 'socks'; // 引入 SOCKS 客户端
|
||||||
|
// import { HttpsProxyAgent } from 'https-proxy-agent'; // 不再直接使用 HttpsProxyAgent
|
||||||
|
import http from 'http'; // 引入 http 用于手动 CONNECT
|
||||||
|
import net from 'net'; // 引入 net 用于 Socket 类型
|
||||||
|
|
||||||
|
// 辅助接口:包含解密后的凭证和代理信息
|
||||||
|
interface FullConnectionInfo extends ConnectionInfoBase {
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
proxy?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'SOCKS5' | 'HTTP';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接 (POST /api/v1/connections/:id/test)
|
||||||
|
*/
|
||||||
|
export const testConnection = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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<any | null>((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<void>;
|
||||||
|
|
||||||
|
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<void>((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<void>((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<void>((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<void>((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 || '测试连接时发生内部服务器错误。' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getConnections,
|
getConnections,
|
||||||
getConnectionById, // 引入获取单个连接的控制器
|
getConnectionById, // 引入获取单个连接的控制器
|
||||||
updateConnection, // 引入更新连接的控制器
|
updateConnection, // 引入更新连接的控制器
|
||||||
deleteConnection // 引入删除连接的控制器
|
deleteConnection, // 引入删除连接的控制器
|
||||||
|
testConnection // 引入测试连接的控制器
|
||||||
} from './connections.controller';
|
} from './connections.controller';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -26,9 +27,9 @@ router.get('/:id', getConnectionById);
|
|||||||
router.put('/:id', updateConnection);
|
router.put('/:id', updateConnection);
|
||||||
|
|
||||||
// DELETE /api/v1/connections/:id - 删除连接
|
// DELETE /api/v1/connections/:id - 删除连接
|
||||||
router.delete('/:id', deleteConnection); // 使用占位符
|
router.delete('/:id', deleteConnection);
|
||||||
|
|
||||||
// TODO: 添加测试连接路由
|
// POST /api/v1/connections/:id/test - 测试连接
|
||||||
// router.post('/:id/test', testConnection);
|
router.post('/:id/test', testConnection);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { Request, RequestHandler } from 'express';
|
|||||||
import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats
|
import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats
|
||||||
import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用)
|
import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用)
|
||||||
import { getDb } from './database'; // 引入数据库实例
|
import { getDb } from './database'; // 引入数据库实例
|
||||||
import { decrypt } from './utils/crypto'; // 引入解密函数
|
import { decrypt } from './utils/crypto'; // 引入解密函数
|
||||||
import path from 'path'; // 需要 path
|
import path from 'path'; // 需要 path
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent'; // 引入 HTTP 代理支持
|
// import { HttpsProxyAgent } from 'https-proxy-agent'; // 不再直接使用 HttpsProxyAgent for SSH tunneling
|
||||||
import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持
|
import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持
|
||||||
|
// import http from 'http'; // 重复导入,保留上面的
|
||||||
|
import net from 'net'; // 引入 net 用于 Socket 类型
|
||||||
|
|
||||||
// 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息
|
// 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息
|
||||||
interface AuthenticatedWebSocket extends WebSocket {
|
interface AuthenticatedWebSocket extends WebSocket {
|
||||||
@@ -642,20 +644,57 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
|
|||||||
// 注意:对于 SOCKS5,连接逻辑在 .then 回调中处理
|
// 注意:对于 SOCKS5,连接逻辑在 .then 回调中处理
|
||||||
|
|
||||||
} else if (proxyInfo.type === 'HTTP') {
|
} 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) {
|
if (proxyInfo.username) {
|
||||||
proxyUrl += `${proxyInfo.username}`;
|
const auth = 'Basic ' + Buffer.from(proxyInfo.username + ':' + (proxyPassword || '')).toString('base64');
|
||||||
if (proxyPassword) {
|
reqOptions.headers = {
|
||||||
proxyUrl += `:${proxyPassword}`;
|
...reqOptions.headers,
|
||||||
}
|
'Proxy-Authorization': auth,
|
||||||
proxyUrl += '@';
|
'Proxy-Connection': 'Keep-Alive', // 某些代理需要
|
||||||
|
'Host': `${connInfo.host}:${connInfo.port}` // CONNECT 请求的目标
|
||||||
|
};
|
||||||
}
|
}
|
||||||
proxyUrl += `${proxyInfo.host}:${proxyInfo.port}`;
|
|
||||||
console.log(`WebSocket: 为连接 ${connInfo.id} 配置 HTTP 代理: ${proxyUrl.replace(/:[^:]*@/, ':***@')}`);
|
const req = http.request(reqOptions);
|
||||||
connectConfig.agent = new HttpsProxyAgent(proxyUrl);
|
req.on('connect', (res, socket, head) => {
|
||||||
console.log(`WebSocket: 已配置 HTTP 代理。正在建立 SSH 连接...`);
|
if (res.statusCode === 200) {
|
||||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 连接...` }));
|
console.log(`WebSocket: HTTP 代理隧道建立成功。正在建立 SSH 连接...`);
|
||||||
connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过代理连接 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 {
|
} else {
|
||||||
console.error(`WebSocket: 未知的代理类型: ${proxyInfo.type}`);
|
console.error(`WebSocket: 未知的代理类型: ${proxyInfo.type}`);
|
||||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `未知的代理类型: ${proxyInfo.type}` }));
|
ws.send(JSON.stringify({ type: 'ssh:error', payload: `未知的代理类型: ${proxyInfo.type}` }));
|
||||||
|
|||||||
Binary file not shown.
@@ -1,25 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, computed } from 'vue'; // 引入 computed
|
import { onMounted, computed, ref, reactive } from 'vue'; // 统一导入
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRouter } from 'vue-router'; // 引入 useRouter
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
|
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
|
||||||
import { useTagsStore } from '../stores/tags.store'; // 引入 Tags Store
|
import { useTagsStore } from '../stores/tags.store'; // 引入 Tags Store
|
||||||
|
|
||||||
const { t } = useI18n(); // 获取 t 函数
|
const { t } = useI18n(); // 获取 t 函数
|
||||||
const router = useRouter(); // 获取 router 实例
|
const router = useRouter(); // 获取 router 实例
|
||||||
const connectionsStore = useConnectionsStore();
|
|
||||||
const tagsStore = useTagsStore(); // 获取 Tags Store 实例
|
const tagsStore = useTagsStore(); // 获取 Tags Store 实例
|
||||||
// 使用 storeToRefs 来保持 state 属性的响应性
|
// 使用 storeToRefs 来保持 state 属性的响应性
|
||||||
const { connections, isLoading, error } = storeToRefs(connectionsStore);
|
// 不再直接从 connectionsStore 获取 connections, isLoading, error
|
||||||
const { tags: allTags } = storeToRefs(tagsStore); // 获取所有标签
|
// const { connections, isLoading, error } = storeToRefs(connectionsStore);
|
||||||
|
const { tags: allTags, isLoading: isTagsLoading, error: tagsError } = storeToRefs(tagsStore); // 获取所有标签及其状态
|
||||||
|
|
||||||
|
// 定义 Props,接收筛选后的连接列表
|
||||||
|
const props = defineProps<{
|
||||||
|
connections: ConnectionInfo[];
|
||||||
|
}>();
|
||||||
|
|
||||||
// 定义组件发出的事件 (添加 edit-connection)
|
// 定义组件发出的事件 (添加 edit-connection)
|
||||||
const emit = defineEmits(['edit-connection']);
|
const emit = defineEmits(['edit-connection']);
|
||||||
|
|
||||||
// 组件挂载时获取连接和标签列表
|
// 新增:用于跟踪每个连接测试状态的响应式对象
|
||||||
|
const testingState = reactive<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// 组件挂载时获取标签列表 (连接列表由父组件传入)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connectionsStore.fetchConnections();
|
|
||||||
tagsStore.fetchTags(); // 获取标签列表
|
tagsStore.fetchTags(); // 获取标签列表
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,10 +46,68 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
|
|||||||
}
|
}
|
||||||
return conn.tag_ids
|
return conn.tag_ids
|
||||||
.map(tagId => tagMap.value.get(tagId)) // 使用映射获取名称
|
.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 => {
|
const formatTimestamp = (timestamp: number | null): string => {
|
||||||
if (!timestamp) return t('connections.status.never'); // 使用 i18n
|
if (!timestamp) return t('connections.status.never'); // 使用 i18n
|
||||||
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
|
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
|
||||||
@@ -51,6 +116,8 @@ const formatTimestamp = (timestamp: number | null): string => {
|
|||||||
|
|
||||||
// 新增:处理删除连接的方法
|
// 新增:处理删除连接的方法
|
||||||
const handleDelete = async (conn: ConnectionInfo) => {
|
const handleDelete = async (conn: ConnectionInfo) => {
|
||||||
|
// 在函数内部获取 store 实例
|
||||||
|
const connectionsStore = useConnectionsStore();
|
||||||
// 使用 i18n 获取确认消息
|
// 使用 i18n 获取确认消息
|
||||||
const confirmMessage = t('connections.prompts.confirmDelete', { name: conn.name });
|
const confirmMessage = t('connections.prompts.confirmDelete', { name: conn.name });
|
||||||
if (window.confirm(confirmMessage)) {
|
if (window.confirm(confirmMessage)) {
|
||||||
@@ -60,37 +127,61 @@ const handleDelete = async (conn: ConnectionInfo) => {
|
|||||||
// 可以考虑使用更友好的提示方式,例如 toast 通知库
|
// 可以考虑使用更友好的提示方式,例如 toast 通知库
|
||||||
alert(t('connections.errors.deleteFailed', { error: connectionsStore.error || '未知错误' }));
|
alert(t('connections.errors.deleteFailed', { error: connectionsStore.error || '未知错误' }));
|
||||||
}
|
}
|
||||||
// 成功时列表会自动更新,无需额外操作
|
// 成功时列表会自动更新,无需额外操作
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
// 新增:处理测试连接的方法
|
||||||
|
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 || '未知错误' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="connection-list">
|
<div class="connection-list">
|
||||||
<!-- 标题移到父组件 ConnectionsView.vue 中 -->
|
<!-- 移除顶部的加载/错误/无数据状态,这些由父组件处理 -->
|
||||||
<div v-if="isLoading" class="loading">{{ t('connections.loading') }}</div>
|
<!-- <div v-if="isLoading" class="loading">{{ t('connections.loading') }}</div> -->
|
||||||
<div v-else-if="error" class="error">{{ t('connections.error', { error: error }) }}</div>
|
<!-- <div v-else-if="error" class="error">{{ t('connections.error', { error: error }) }}</div> -->
|
||||||
<div v-else-if="connections.length === 0" class="no-connections">
|
<!-- <div v-else-if="connections.length === 0" class="no-connections"> -->
|
||||||
{{ t('connections.noConnections') }}
|
<!-- {{ t('connections.noConnections') }} -->
|
||||||
</div>
|
<!-- </div> -->
|
||||||
<table v-else>
|
<div v-if="tagsError" class="error">{{ t('tags.error', { error: tagsError }) }}</div> <!-- 显示标签加载错误 -->
|
||||||
<thead>
|
|
||||||
<tr>
|
<!-- 遍历分组 -->
|
||||||
<th>{{ t('connections.table.name') }}</th>
|
<div v-for="(groupConnections, groupName) in groupedConnections" :key="groupName" class="connection-group">
|
||||||
|
<h4 class="group-title">
|
||||||
|
{{ groupName === '_untagged_' ? t('connections.untaggedGroup') : groupName }}
|
||||||
|
({{ groupConnections.length }})
|
||||||
|
</h4>
|
||||||
|
<table v-if="groupConnections.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('connections.table.name') }}</th>
|
||||||
<th>{{ t('connections.table.host') }}</th>
|
<th>{{ t('connections.table.host') }}</th>
|
||||||
<th>{{ t('connections.table.port') }}</th>
|
<th>{{ t('connections.table.port') }}</th>
|
||||||
<th>{{ t('connections.table.user') }}</th>
|
<th>{{ t('connections.table.user') }}</th>
|
||||||
<th>{{ t('connections.table.authMethod') }}</th>
|
<th>{{ t('connections.table.authMethod') }}</th>
|
||||||
<th>{{ t('connections.table.tags') }}</th> <!-- 新增标签列 -->
|
<th>{{ t('connections.table.tags') }}</th> <!-- 新增标签列 -->
|
||||||
<th>{{ t('connections.table.lastConnected') }}</th>
|
<th>{{ t('connections.table.lastConnected') }}</th>
|
||||||
<th>{{ t('connections.table.actions') }}</th>
|
<th>{{ t('connections.table.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="conn in connections" :key="conn.id">
|
<!-- 遍历分组内的连接 -->
|
||||||
<td>{{ conn.name }}</td>
|
<tr v-for="conn in groupConnections" :key="conn.id">
|
||||||
<td>{{ conn.host }}</td>
|
<td>{{ conn.name }}</td>
|
||||||
|
<td>{{ conn.host }}</td>
|
||||||
<td>{{ conn.port }}</td>
|
<td>{{ conn.port }}</td>
|
||||||
<td>{{ conn.username }}</td>
|
<td>{{ conn.username }}</td>
|
||||||
<td>{{ conn.auth_method }}</td>
|
<td>{{ conn.auth_method }}</td>
|
||||||
@@ -104,13 +195,24 @@ const handleDelete = async (conn: ConnectionInfo) => {
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
|
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
|
<button @click="connectToServer(conn.id)" class="action-button connect-button">{{ t('connections.actions.connect') }}</button>
|
||||||
<button @click="emit('edit-connection', conn)">{{ t('connections.actions.edit') }}</button>
|
<button @click="emit('edit-connection', conn)" class="action-button edit-button">{{ t('connections.actions.edit') }}</button>
|
||||||
<button @click="handleDelete(conn)">{{ t('connections.actions.delete') }}</button>
|
<button @click="handleTestConnection(conn.id)" class="action-button test-button" :disabled="testingState[conn.id]">{{ testingState[conn.id] ? t('connections.actions.testing') : t('connections.actions.test') }}</button> <!-- 新增测试按钮 -->
|
||||||
|
<button @click="handleDelete(conn)" class="action-button delete-button">{{ t('connections.actions.delete') }}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<!-- 如果未标记组为空,可以显示提示 -->
|
||||||
|
<div v-else-if="groupName === '_untagged_'" class="no-connections-in-group">
|
||||||
|
{{ t('connections.noUntaggedConnections') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 如果所有分组都为空(即 props.connections 为空),显示整体提示 -->
|
||||||
|
<div v-if="Object.keys(groupedConnections).length === 0 || (Object.keys(groupedConnections).length === 1 && groupedConnections['_untagged_']?.length === 0)" class="no-connections">
|
||||||
|
{{ t('connections.noConnections') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,7 +234,19 @@ export default {
|
|||||||
margin-top: 1rem;
|
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;
|
padding: 1rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -170,6 +284,38 @@ button {
|
|||||||
cursor: pointer;
|
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 {
|
.tag-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete"
|
"delete": "Delete",
|
||||||
|
"test": "Test",
|
||||||
|
"testing": "Testing..."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Add New Connection",
|
"title": "Add New Connection",
|
||||||
@@ -77,6 +79,13 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"never": "Never"
|
"never": "Never"
|
||||||
|
},
|
||||||
|
"filterAllTags": "All Tags",
|
||||||
|
"untaggedGroup": "Untagged",
|
||||||
|
"noUntaggedConnections": "No untagged connections found.",
|
||||||
|
"test": {
|
||||||
|
"success": "Connection test successful!",
|
||||||
|
"failed": "Connection test failed: {error}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proxies": {
|
"proxies": {
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"connect": "连接",
|
"connect": "连接",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除"
|
"delete": "删除",
|
||||||
|
"test": "测试",
|
||||||
|
"testing": "测试中..."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "添加新连接",
|
"title": "添加新连接",
|
||||||
@@ -77,6 +79,13 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"never": "从未"
|
"never": "从未"
|
||||||
|
},
|
||||||
|
"filterAllTags": "所有标签",
|
||||||
|
"untaggedGroup": "未标记",
|
||||||
|
"noUntaggedConnections": "没有未标记的连接。",
|
||||||
|
"test": {
|
||||||
|
"success": "连接测试成功!",
|
||||||
|
"failed": "连接测试失败: {error}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proxies": {
|
"proxies": {
|
||||||
|
|||||||
@@ -141,5 +141,26 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
this.isLoading = false;
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed, onMounted } from 'vue'; // 引入 computed 和 onMounted
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { storeToRefs } from 'pinia'; // 引入 storeToRefs
|
||||||
import ConnectionList from '../components/ConnectionList.vue';
|
import ConnectionList from '../components/ConnectionList.vue';
|
||||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||||
import { ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo
|
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 和 Store
|
||||||
|
import { useTagsStore } from '../stores/tags.store'; // 引入 Tags Store
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const showForm = ref(false); // 重命名,控制表单显示状态
|
const connectionsStore = useConnectionsStore(); // 获取 Connections Store
|
||||||
const editingConnection = ref<ConnectionInfo | null>(null); // 存储正在编辑的连接
|
const tagsStore = useTagsStore(); // 获取 Tags Store
|
||||||
|
const { connections } = storeToRefs(connectionsStore); // 获取连接列表
|
||||||
|
const { tags } = storeToRefs(tagsStore); // 获取标签列表
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const editingConnection = ref<ConnectionInfo | null>(null);
|
||||||
|
const selectedTagId = ref<number | null>(null); // 用于存储选中的标签 ID,null 表示所有
|
||||||
|
|
||||||
|
// 计算筛选后的连接列表
|
||||||
|
const filteredConnections = computed(() => {
|
||||||
|
if (selectedTagId.value === null) {
|
||||||
|
return connections.value; // 返回所有连接
|
||||||
|
}
|
||||||
|
return connections.value.filter(conn =>
|
||||||
|
conn.tag_ids?.includes(selectedTagId.value!) // 筛选包含选中标签 ID 的连接
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件挂载时获取连接和标签列表
|
||||||
|
onMounted(() => {
|
||||||
|
connectionsStore.fetchConnections(); // 添加获取连接列表的调用
|
||||||
|
tagsStore.fetchTags();
|
||||||
|
});
|
||||||
|
|
||||||
const handleConnectionAdded = () => {
|
const handleConnectionAdded = () => {
|
||||||
showForm.value = false; // 使用新变量名
|
showForm.value = false; // 使用新变量名
|
||||||
// ConnectionList 组件会自动从 store 获取更新后的列表
|
// ConnectionList 组件会自动从 store 获取更新后的列表
|
||||||
|
// 如果添加后需要清除筛选,可以在这里设置 selectedTagId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 新增:处理编辑成功后的逻辑
|
// 新增:处理编辑成功后的逻辑
|
||||||
@@ -43,7 +68,16 @@ const closeForm = () => {
|
|||||||
<div class="connections-view">
|
<div class="connections-view">
|
||||||
<h2>{{ t('connections.title') }}</h2>
|
<h2>{{ t('connections.title') }}</h2>
|
||||||
|
|
||||||
<button @click="openAddForm" v-if="!showForm">{{ t('connections.addConnection') }}</button>
|
<div class="actions-bar">
|
||||||
|
<button @click="openAddForm" v-if="!showForm">{{ t('connections.addConnection') }}</button>
|
||||||
|
<!-- 标签筛选下拉框 -->
|
||||||
|
<select v-model="selectedTagId" class="tag-filter-select">
|
||||||
|
<option :value="null">{{ t('connections.filterAllTags') }}</option>
|
||||||
|
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
|
||||||
|
{{ tag.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 添加/编辑连接表单 (条件渲染) -->
|
<!-- 添加/编辑连接表单 (条件渲染) -->
|
||||||
<AddConnectionForm
|
<AddConnectionForm
|
||||||
@@ -54,8 +88,8 @@ const closeForm = () => {
|
|||||||
@connection-updated="handleConnectionUpdated"
|
@connection-updated="handleConnectionUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 连接列表,监听 edit-connection 事件 -->
|
<!-- 连接列表,传入筛选后的列表 -->
|
||||||
<ConnectionList @edit-connection="handleEditRequest" />
|
<ConnectionList :connections="filteredConnections" @edit-connection="handleEditRequest" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -64,9 +98,23 @@ const closeForm = () => {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.actions-bar {
|
||||||
margin-bottom: 1rem;
|
display: flex;
|
||||||
|
justify-content: space-between; /* 让按钮和下拉框分开 */
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar button {
|
||||||
|
/* margin-bottom: 1rem; */ /* 移除按钮的下边距 */
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-filter-select {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px; /* 给下拉框一个最小宽度 */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user