diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 0c9dfb1..2e7f4f5 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -23,8 +23,8 @@ interface ConnectionInfoBase { * 创建新连接 (POST /api/v1/connections) */ export const createConnection = async (req: Request, res: Response): Promise => { - // 新增 proxy_id - const { name, host, port = 22, username, auth_method, password, private_key, passphrase, proxy_id } = req.body; + // 新增 proxy_id 和 tag_ids + const { name, host, port = 22, username, auth_method, password, private_key, passphrase, proxy_id, tag_ids } = req.body; const userId = req.session.userId; // 从会话获取用户 ID // 输入验证 (基础) @@ -88,14 +88,42 @@ export const createConnection = async (req: Request, res: Response): Promise 0) { + const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); + // 使用事务确保原子性 + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + try { + tag_ids.forEach((tagId: any) => { + if (typeof tagId === 'number' && tagId > 0) { + insertTagStmt.run(newConnectionId, tagId); + } else { + console.warn(`创建连接 ${newConnectionId} 时,提供的 tag_id 无效: ${tagId}`); + } + }); + db.run('COMMIT'); + } catch (tagError: any) { + console.error(`为连接 ${newConnectionId} 添加标签时出错:`, tagError); + db.run('ROLLBACK'); // 出错时回滚 + // 可以选择抛出错误或仅记录警告 + // throw new Error('处理标签关联失败'); + } finally { + insertTagStmt.finalize(); + } + }); + } + + // 返回成功响应 (包含 proxy_id 和 tag_ids) res.status(201).json({ message: '连接创建成功。', connection: { - id: result.lastID, + id: newConnectionId, name, host, port, username, auth_method, - proxy_id: proxy_id ?? null, // 返回 proxy_id + proxy_id: proxy_id ?? null, + tag_ids: Array.isArray(tag_ids) ? tag_ids.filter(id => typeof id === 'number' && id > 0) : [], // 返回有效的 tag_ids created_at: now, updated_at: now, last_connected_at: null } }); @@ -113,20 +141,28 @@ export const getConnections = async (req: Request, res: Response): Promise const userId = req.session.userId; // 虽然 MVP 只有一个用户,但保留以备将来使用 try { - // 查询数据库,排除敏感字段 encrypted_password, encrypted_private_key, encrypted_passphrase - // 注意:如果未来支持多用户,需要添加 WHERE user_id = ? 条件 - // 新增:包含 proxy_id - const connections = await new Promise<(ConnectionInfoBase & { proxy_id: number | null })[]>((resolve, reject) => { + // 更新查询以包含关联的标签 ID (使用 GROUP_CONCAT) + const connections = await new Promise<(ConnectionInfoBase & { proxy_id: number | null, tag_ids: number[] })[]>((resolve, reject) => { db.all( - `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at - FROM connections - ORDER BY name ASC`, - (err, rows: (ConnectionInfoBase & { proxy_id: number | null })[]) => { + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, + c.created_at, c.updated_at, c.last_connected_at, + GROUP_CONCAT(ct.tag_id) as tag_ids_str + FROM connections c + LEFT JOIN connection_tags ct ON c.id = ct.connection_id + GROUP BY c.id + ORDER BY c.name ASC`, + (err, rows: any[]) => { // 使用 any[] 因为 tag_ids_str 是字符串 if (err) { console.error('查询连接列表时出错:', err.message); return reject(new Error('获取连接列表失败')); } - resolve(rows); + // 处理 tag_ids_str,将其转换为数字数组 + const processedRows = rows.map(row => ({ + ...row, + tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : [] + })); + resolve(processedRows); } ); }); @@ -152,21 +188,29 @@ export const getConnectionById = async (req: Request, res: Response): Promise((resolve, reject) => { + // 更新查询以包含关联的标签 ID (使用 GROUP_CONCAT) + const connection = await new Promise<(ConnectionInfoBase & { proxy_id: number | null, tag_ids: number[] }) | null>((resolve, reject) => { db.get( - `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at - FROM connections - WHERE id = ?`, + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, + c.created_at, c.updated_at, c.last_connected_at, + GROUP_CONCAT(ct.tag_id) as tag_ids_str + FROM connections c + LEFT JOIN connection_tags ct ON c.id = ct.connection_id + WHERE c.id = ? + GROUP BY c.id`, // GROUP BY 仍然需要,即使只有一行 [connectionId], - (err, row: (ConnectionInfoBase & { proxy_id: number | null })) => { + (err, row: any) => { // 使用 any[] 因为 tag_ids_str 是字符串 if (err) { console.error(`查询连接 ${connectionId} 时出错:`, err.message); return reject(new Error('获取连接信息失败')); } - resolve(row || null); // 如果找不到则返回 null + if (row) { + // 处理 tag_ids_str + row.tag_ids = row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : []; + delete row.tag_ids_str; // 移除临时字段 + } + resolve(row || null); } ); }); @@ -188,8 +232,8 @@ export const getConnectionById = async (req: Request, res: Response): Promise => { const connectionId = parseInt(req.params.id, 10); - // 新增 proxy_id - const { name, host, port, username, auth_method, password, private_key, passphrase, proxy_id } = req.body; + // 新增 proxy_id 和 tag_ids + const { name, host, port, username, auth_method, password, private_key, passphrase, proxy_id, tag_ids } = req.body; const userId = req.session.userId; if (isNaN(connectionId)) { @@ -357,6 +401,61 @@ export const updateConnection = async (req: Request, res: Response): Promise err ? reject(err) : resolve(row || null) ); }); + + // 处理标签关联更新 + if (tag_ids !== undefined && Array.isArray(tag_ids)) { // 仅当提供了 tag_ids 时才处理 + const deleteStmt = db.prepare(`DELETE FROM connection_tags WHERE connection_id = ?`); + const insertStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); + + await new Promise((resolve, reject) => { + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + try { + // 1. 删除旧关联 + deleteStmt.run(connectionId, (err: Error | null) => { // 添加 err 类型 + if (err) throw err; // 抛出错误以触发 rollback + }); + deleteStmt.finalize(); // finalize delete statement + + // 2. 插入新关联 (如果 tag_ids 不为空) + if (tag_ids.length > 0) { + tag_ids.forEach((tagId: any) => { + if (typeof tagId === 'number' && tagId > 0) { + insertStmt.run(connectionId, tagId, (err: Error | null) => { // 添加 err 类型 + if (err) throw err; // 抛出错误以触发 rollback + }); + } else { + console.warn(`更新连接 ${connectionId} 时,提供的 tag_id 无效: ${tagId}`); + } + }); + } + insertStmt.finalize(); // finalize insert statement + db.run('COMMIT', (commitErr: Error | null) => { // 添加 commitErr 类型 + if (commitErr) throw commitErr; + resolve(); // 事务成功 + }); + } catch (tagError: any) { + console.error(`更新连接 ${connectionId} 的标签关联时出错:`, tagError); + db.run('ROLLBACK'); + // 将标签处理错误附加到主错误或单独处理 + reject(new Error('处理标签关联失败')); + } + }); + }); + } // 结束标签处理 + + // 在返回的 updatedConnection 中添加 tag_ids + if (updatedConnection) { + // 查询最新的 tag_ids + const currentTagIds = await new Promise((resolve, reject) => { + db.all('SELECT tag_id FROM connection_tags WHERE connection_id = ?', [connectionId], (err: Error | null, rows: { tag_id: number }[]) => { // 添加 err 类型 + if (err) return reject(err); + resolve(rows.map(r => r.tag_id)); + }); + }); + (updatedConnection as any).tag_ids = currentTagIds; // 添加 tag_ids 字段 + } + res.status(200).json({ message: '连接更新成功。', connection: updatedConnection }); } diff --git a/packages/backend/src/migrations.ts b/packages/backend/src/migrations.ts index f181618..4ef92a4 100644 --- a/packages/backend/src/migrations.ts +++ b/packages/backend/src/migrations.ts @@ -57,8 +57,18 @@ CREATE TABLE IF NOT EXISTS tags ( ); `; +// 新增:创建 connection_tags 关联表的 SQL +const createConnectionTagsTableSQL = ` +CREATE TABLE IF NOT EXISTS connection_tags ( + connection_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (connection_id, tag_id), + FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE, -- 删除连接时,自动删除关联 + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE -- 删除标签时,自动删除关联 +); +`; + // 未来可能需要的其他表 (根据项目文档) -// const createConnectionTagsTableSQL = \`...\`; // 连接与标签的关联表 // const createSettingsTableSQL = \`...\`; // 设置表 // const createAuditLogsTableSQL = \`...\`; // 审计日志表 // const createApiKeysTableSQL = \`...\`; // API 密钥表 @@ -170,6 +180,15 @@ export const runMigrations = async (db: Database): Promise => { }); }); + // 新增:创建 connection_tags 表 (如果不存在) + await new Promise((resolve, reject) => { + db.run(createConnectionTagsTableSQL, (err) => { + if (err) return reject(new Error(`创建 connection_tags 表时出错: ${err.message}`)); + console.log('Connection_Tags 表已检查/创建。'); + resolve(); + }); + }); + // Add other tables or columns here in the future console.log('数据库迁移检查完成。'); diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index 7c37ca6..3ab3259 100644 Binary files a/packages/data/nexus-terminal.db and b/packages/data/nexus-terminal.db differ diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index e734167..98732b1 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; import { useProxiesStore } from '../stores/proxies.store'; // 引入代理 Store +import { useTagsStore } from '../stores/tags.store'; // 引入标签 Store // 定义组件发出的事件 const emit = defineEmits(['close', 'connection-added', 'connection-updated']); @@ -16,8 +17,10 @@ const props = defineProps<{ const { t } = useI18n(); const connectionsStore = useConnectionsStore(); const proxiesStore = useProxiesStore(); // 获取代理 store 实例 +const tagsStore = useTagsStore(); // 获取标签 store 实例 const { isLoading: isConnLoading, error: connStoreError } = storeToRefs(connectionsStore); const { proxies, isLoading: isProxyLoading, error: proxyStoreError } = storeToRefs(proxiesStore); // 获取代理列表和状态 +const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tagsStore); // 获取标签列表和状态 // 表单数据模型 const initialFormData = { @@ -29,13 +32,15 @@ const initialFormData = { password: '', private_key: '', passphrase: '', - proxy_id: null as number | null, // 新增 proxy_id 字段 + proxy_id: null as number | null, + tag_ids: [] as number[], // 新增 tag_ids 字段 }; const formData = reactive({ ...initialFormData }); const formError = ref(null); // 表单级别的错误信息 -const isLoading = computed(() => isConnLoading.value || isProxyLoading.value); // 合并加载状态 -const storeError = computed(() => connStoreError.value || proxyStoreError.value); // 合并错误状态 +// 合并所有 store 的加载和错误状态 +const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value); +const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value); // 计算属性判断是否为编辑模式 const isEditMode = computed(() => !!props.connectionToEdit); @@ -64,7 +69,8 @@ watch(() => props.connectionToEdit, (newVal) => { formData.port = newVal.port; formData.username = newVal.username; formData.auth_method = newVal.auth_method; - formData.proxy_id = newVal.proxy_id ?? null; // 填充 proxy_id + formData.proxy_id = newVal.proxy_id ?? null; + formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // 填充 tag_ids (深拷贝) // 清空敏感字段 formData.password = ''; formData.private_key = ''; @@ -75,9 +81,10 @@ watch(() => props.connectionToEdit, (newVal) => { } }, { immediate: true }); -// 组件挂载时获取代理列表 +// 组件挂载时获取代理和标签列表 onMounted(() => { proxiesStore.fetchProxies(); + tagsStore.fetchTags(); // 获取标签列表 }); // 处理表单提交 @@ -137,6 +144,7 @@ const handleSubmit = async () => { username: formData.username, auth_method: formData.auth_method, proxy_id: formData.proxy_id || null, + tag_ids: formData.tag_ids || [], // 发送 tag_ids }; // 处理敏感字段 @@ -248,6 +256,20 @@ const handleSubmit = async () => {
{{ t('proxies.error', { error: proxyStoreError }) }}
+ +
+ +
+
{{ t('tags.loading') }}
+
{{ t('tags.error', { error: tagStoreError }) }}
+
{{ t('tags.noTags') }}
+ +
+
+
{{ formError || storeError }} @@ -315,6 +337,38 @@ textarea { box-sizing: border-box; /* Include padding and border in element's total width and height */ } +/* 标签选择样式 */ +.tag-checkbox-group { + max-height: 150px; /* 限制高度,出现滚动条 */ + overflow-y: auto; + border: 1px solid #ccc; + padding: 0.5rem; + border-radius: 4px; + margin-top: 0.5rem; +} + +.tag-checkbox-label { + display: block; /* 每个标签占一行 */ + margin-bottom: 0.3rem; + font-weight: normal; /* 普通字体 */ + cursor: pointer; +} + +.tag-checkbox-label input[type="checkbox"] { + margin-right: 0.5rem; + width: auto; /* 恢复复选框默认宽度 */ +} + +.loading-small, .error-small, .info-small { + font-size: 0.9em; + color: #666; + margin-top: 0.2rem; +} +.error-small { + color: red; +} + + .error-message { color: red; margin-bottom: 1rem; diff --git a/packages/frontend/src/components/ConnectionList.vue b/packages/frontend/src/components/ConnectionList.vue index 6b7f66c..400ca2d 100644 --- a/packages/frontend/src/components/ConnectionList.vue +++ b/packages/frontend/src/components/ConnectionList.vue @@ -1,24 +1,47 @@