feat: 添加标签管理模态框

This commit is contained in:
Baobhan Sith
2025-05-11 11:20:26 +08:00
parent 1eb1efde72
commit 598df938bf
40 changed files with 634 additions and 170 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ import {
listUserPasskeysHandler,
deleteUserPasskeyHandler,
updateUserPasskeyNameHandler, // 新增:更新 Passkey 名称的处理器
checkHasPasskeys // +++ 新增:检查是否有 Passkey 配置的处理器
checkHasPasskeys
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
@@ -94,3 +94,37 @@ export const deleteTag = async (id: number): Promise<boolean> => {
throw new Error('删除标签失败');
}
};
/**
* 更新标签与连接的关联关系
*/
export const updateTagConnections = async (tagId: number, connectionIds: number[]): Promise<void> => {
const db = await getDbInstance();
try {
// 开始事务
await runDb(db, 'BEGIN TRANSACTION');
// 1. 删除该标签旧的连接关联
const deleteSql = `DELETE FROM connection_tags WHERE tag_id = ?`;
await runDb(db, deleteSql, [tagId]);
// 2. 如果有新的连接 ID,则插入新的关联
if (connectionIds && connectionIds.length > 0) {
const insertSql = `INSERT INTO connection_tags (tag_id, connection_id) VALUES (?, ?)`;
// 使用 Promise.all 来并行执行插入操作,或者逐个执行
// 为简单起见,这里逐个执行,但对于大量数据,并行或批量插入更优
for (const connectionId of connectionIds) {
// 检查 connectionId 是否有效(例如,是否存在于 connections 表中)可以增加健壮性,但此处省略
await runDb(db, insertSql, [tagId, connectionId]);
}
}
// 提交事务
await runDb(db, 'COMMIT');
} catch (err: any) {
// 如果发生错误,回滚事务
await runDb(db, 'ROLLBACK');
console.error(`[仓库] 更新标签 ${tagId} 的连接关联时出错:`, err.message);
throw new Error(`更新标签连接关联失败: ${err.message}`);
}
};
@@ -54,7 +54,7 @@ interface ActiveUpload {
bytesWritten: number;
stream: WriteStream;
sessionId: string; // Link back to the session for cleanup
relativePath?: string; // +++ 新增:存储相对路径 +++
relativePath?: string;
}
export class SftpService {
@@ -646,7 +646,7 @@ export class SftpService {
}
}
// +++ 新增:复制文件或目录 +++
// +++ 复制文件或目录 +++
async copy(sessionId: string, sources: string[], destinationDir: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
@@ -720,7 +720,7 @@ export class SftpService {
}
}
// +++ 新增:移动文件或目录 +++
// +++ 移动文件或目录 +++
async move(sessionId: string, sources: string[], destinationDir: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
@@ -803,7 +803,7 @@ export class SftpService {
}
}
// +++ 新增:辅助方法 - 复制文件 +++
// +++ 辅助方法 - 复制文件 +++
private copyFile(sftp: SFTPWrapper, sourcePath: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const readStream = sftp.createReadStream(sourcePath);
@@ -833,7 +833,7 @@ export class SftpService {
});
}
// +++ 新增:辅助方法 - 递归复制目录 +++
// +++ 辅助方法 - 递归复制目录 +++
private async copyDirectoryRecursive(sftp: SFTPWrapper, sourcePath: string, destPath: string): Promise<void> {
try {
// Create destination directory
@@ -861,7 +861,7 @@ export class SftpService {
}
}
// +++ 新增:辅助方法 - 获取 Stats (Promise wrapper) +++
// +++ 辅助方法 - 获取 Stats (Promise wrapper) +++
private getStats(sftp: SFTPWrapper, path: string): Promise<Stats> {
return new Promise((resolve, reject) => {
sftp.lstat(path, (err, stats) => {
@@ -951,7 +951,7 @@ export class SftpService {
}
}
// +++ 新增:辅助方法 - 列出目录内容 (Promise wrapper) +++
// +++ 辅助方法 - 列出目录内容 (Promise wrapper) +++
private listDirectory(sftp: SFTPWrapper, path: string): Promise<SftpDirEntry[]> { // 使用本地接口 SftpDirEntry
return new Promise((resolve, reject) => {
sftp.readdir(path, (err, list) => { // list 的类型现在是 SftpDirEntry[]
@@ -964,7 +964,7 @@ export class SftpService {
});
}
// +++ 新增:辅助方法 - 执行重命名 (Promise wrapper) +++
// +++ 辅助方法 - 执行重命名 (Promise wrapper) +++
private performRename(sftp: SFTPWrapper, oldPath: string, newPath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rename(oldPath, newPath, (err) => {
@@ -977,7 +977,7 @@ export class SftpService {
});
}
// +++ 新增:辅助方法 - 格式化 Stats 为 FileListItem +++
// +++ 辅助方法 - 格式化 Stats 为 FileListItem +++
private formatStatsToFileListItem(itemPath: string, stats: Stats): any {
return {
filename: pathModule.basename(itemPath),
@@ -75,3 +75,23 @@ export const updateTag = async (id: number, name: string): Promise<TagData | nul
export const deleteTag = async (id: number): Promise<boolean> => {
return TagRepository.deleteTag(id);
};
/**
*
*/
export const updateTagConnections = async (tagId: number, connectionIds: number[]): Promise<void> => {
// 在服务层可以添加额外的业务逻辑,例如验证 tagId 和 connectionIds 的有效性
// 例如,检查标签是否存在,连接 ID 是否都存在于数据库中等。
// 此处为简化,直接调用 repository 方法。
// 确保 connectionIds 是一个数组,即使是空数组
const idsToUpdate = Array.isArray(connectionIds) ? connectionIds : [];
try {
await TagRepository.updateTagConnections(tagId, idsToUpdate);
} catch (error: any) {
// 服务层可以进一步处理或包装错误
console.error(`Service: 更新标签 ${tagId} 的连接关联时发生错误:`, error.message);
throw new Error(`服务层更新标签连接关联失败: ${error.message}`);
}
};
@@ -15,19 +15,19 @@ router.use(isAuthenticated);
router.get('/', settingsController.getAllSettings); // GET /api/v1/settings
router.put('/', settingsController.updateSettings); // PUT /api/v1/settings
// +++ 新增:焦点切换顺序路由 +++
// +++ 焦点切换顺序路由 +++
// GET /api/v1/settings/focus-switcher-sequence - 获取焦点切换顺序
router.get('/focus-switcher-sequence', settingsController.getFocusSwitcherSequence);
// PUT /api/v1/settings/focus-switcher-sequence - 更新焦点切换顺序
router.put('/focus-switcher-sequence', settingsController.setFocusSwitcherSequence);
// +++ 新增:导航栏可见性路由 +++
// +++ 导航栏可见性路由 +++
// GET /api/v1/settings/nav-bar-visibility - 获取导航栏可见性
router.get('/nav-bar-visibility', settingsController.getNavBarVisibility);
// PUT /api/v1/settings/nav-bar-visibility - 更新导航栏可见性
router.put('/nav-bar-visibility', settingsController.setNavBarVisibility);
// +++ 新增:布局树路由 +++
// +++ 布局树路由 +++
// GET /api/v1/settings/layout - 获取布局树
router.get('/layout', settingsController.getLayoutTree);
// PUT /api/v1/settings/layout - 更新布局树
@@ -41,38 +41,38 @@ router.get('/ip-blacklist', settingsController.getIpBlacklist);
router.delete('/ip-blacklist/:ip', settingsController.deleteIpFromBlacklist);
// +++ 新增:终端选中自动复制路由 +++
// +++ 终端选中自动复制路由 +++
// GET /api/v1/settings/auto-copy-on-select - 获取设置
router.get('/auto-copy-on-select', settingsController.getAutoCopyOnSelect);
// PUT /api/v1/settings/auto-copy-on-select - 更新设置
router.put('/auto-copy-on-select', settingsController.setAutoCopyOnSelect);
// +++ 新增:侧栏配置路由 +++
// +++ 侧栏配置路由 +++
// GET /api/v1/settings/sidebar - 获取侧栏配置
router.get('/sidebar', settingsController.getSidebarConfig);
// PUT /api/v1/settings/sidebar - 更新侧栏配置
router.put('/sidebar', settingsController.setSidebarConfig);
// +++ 新增:显示连接标签路由 +++
// +++ 显示连接标签路由 +++
// GET /api/v1/settings/show-connection-tags - 获取设置
router.get('/show-connection-tags', settingsController.getShowConnectionTags);
// PUT /api/v1/settings/show-connection-tags - 更新设置
router.put('/show-connection-tags', settingsController.setShowConnectionTags);
// +++ 新增:显示快捷指令标签路由 +++
// +++ 显示快捷指令标签路由 +++
// GET /api/v1/settings/show-quick-command-tags - 获取设置
router.get('/show-quick-command-tags', settingsController.getShowQuickCommandTags);
// PUT /api/v1/settings/show-quick-command-tags - 更新设置
router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags);
// +++ 新增:导出所有连接路由 +++
// +++ 导出所有连接路由 +++
// GET /api/v1/settings/export-connections - 导出所有连接为加密的 ZIP 文件
router.get('/export-connections', settingsController.exportAllConnections);
export default router;
// +++ 新增:CAPTCHA 配置路由 (需要认证更新) +++
// +++ CAPTCHA 配置路由 (需要认证更新) +++
// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置
// 注意:这个路由定义在 `export default router` 之后,这是不正确的。
// 我会将它移到 `export default router` 之前,并确保它也在 `isAuthenticated` 中间件的作用域内。
+1 -1
View File
@@ -10,7 +10,7 @@ router.use(isAuthenticated);
// GET /api/v1/sftp/download?connectionId=...&remotePath=...
router.get('/download', downloadFile);
// +++ 新增:GET /api/v1/sftp/download-directory?connectionId=...&remotePath=... +++
// +++ GET /api/v1/sftp/download-directory?connectionId=...&remotePath=... +++
router.get('/download-directory', downloadDirectory);
@@ -130,3 +130,40 @@ export const deleteTag = async (req: Request, res: Response): Promise<void> => {
res.status(500).json({ message: error.message || '删除标签时发生内部服务器错误。' });
}
};
/**
* (PUT /api/v1/tags/:id/connections)
*/
export const updateTagConnections = async (req: Request, res: Response): Promise<void> => {
const tagId = parseInt(req.params.id, 10);
const { connection_ids } = req.body; // 前端发送的是 connection_ids
if (isNaN(tagId)) {
res.status(400).json({ message: '无效的标签 ID。' });
return;
}
if (!Array.isArray(connection_ids)) {
res.status(400).json({ message: 'connection_ids 必须是一个数组。' });
return;
}
// 可选:验证 connection_ids 中的每个 ID 是否为数字
if (!connection_ids.every(id => typeof id === 'number')) {
res.status(400).json({ message: 'connection_ids 数组中的所有元素必须是数字。' });
return;
}
try {
await TagService.updateTagConnections(tagId, connection_ids);
res.status(200).json({ message: '标签的连接关联更新成功。' });
} catch (error: any) {
console.error(`Controller: 更新标签 ${tagId} 的连接关联时发生错误:`, error);
// 可以根据 TagService 抛出的错误类型来返回更具体的错误码和消息
if (error.message.includes('标签未找到')) { // 假设服务层或仓库层会检查标签是否存在
res.status(404).json({ message: '标签未找到。' });
} else {
res.status(500).json({ message: error.message || '更新标签连接关联时发生内部服务器错误。' });
}
}
};
+3 -1
View File
@@ -5,7 +5,8 @@ import {
getTags,
getTagById,
updateTag,
deleteTag
deleteTag,
updateTagConnections // +++ 导入新的控制器方法 +++
} from './tags.controller';
const router = Router();
@@ -19,5 +20,6 @@ router.get('/', getTags); // GET /api/v1/tags - 获取标签列表
router.get('/:id', getTagById); // GET /api/v1/tags/:id - 获取单个标签
router.put('/:id', updateTag); // PUT /api/v1/tags/:id - 更新标签
router.delete('/:id', deleteTag); // DELETE /api/v1/tags/:id - 删除标签
router.put('/:id/connections', updateTagConnections); // PUT /api/v1/tags/:id/connections - 更新标签的连接关联
export default router;
+4 -4
View File
@@ -18,15 +18,15 @@ import {
SshSuspendAutoTerminatedNotification,
SshMarkForSuspendRequest,
SshMarkedForSuspendAck,
SshUnmarkForSuspendRequest, // +++ 新增导入 +++
SshUnmarkedForSuspendAck, // +++ 新增导入 +++
SshUnmarkForSuspendRequest,
SshUnmarkedForSuspendAck,
ClientState
} from './types';
import { SshSuspendService } from '../services/ssh-suspend.service';
import { SftpService } from '../services/sftp.service';
import { cleanupClientConnection } from './utils';
import { clientStates } from './state';
import { temporaryLogStorageService } from '../services/temporary-log-storage.service'; // +++ 新增导入
import { temporaryLogStorageService } from '../services/temporary-log-storage.service';
// Handlers
import { handleRdpProxyConnection } from './handlers/rdp.handler';
@@ -233,7 +233,7 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ
// console.warn(`[WebSocket Handler][${type}] WebSocket 在发送 SSH_OUTPUT_CACHED_CHUNK 前已关闭 (会话 ${newFrontendSessionId})。`);
}
// +++ 新增:发送 ssh:connected 消息 +++
// +++ 发送 ssh:connected 消息 +++
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ssh:connected',
@@ -4,7 +4,7 @@ import { AuthenticatedWebSocket, ClientState } from '../types';
import { clientStates, sftpService, statusMonitorService, auditLogService, notificationService } from '../state';
import * as SshService from '../../services/ssh.service';
import { cleanupClientConnection } from '../utils';
import { temporaryLogStorageService } from '../../services/temporary-log-storage.service'; // +++ 新增导入
import { temporaryLogStorageService } from '../../services/temporary-log-storage.service';
import { startDockerStatusPolling } from './docker.handler';
import WebSocket from 'ws';
+4 -4
View File
@@ -110,7 +110,7 @@ export interface SshMarkForSuspendRequest {
type: "SSH_MARK_FOR_SUSPEND";
payload: {
sessionId: string; // The ID of the active SSH session to be marked
initialBuffer?: string; // +++ 新增:可选的初始屏幕缓冲区内容 +++
initialBuffer?: string; // +++ 可选的初始屏幕缓冲区内容 +++
};
}
@@ -205,7 +205,7 @@ export interface SshMarkedForSuspendAck {
};
}
export interface SshUnmarkedForSuspendAck { // +++ 新增 S2C 类型 +++
export interface SshUnmarkedForSuspendAck { // +++ S2C 类型 +++
type: "SSH_UNMARKED_FOR_SUSPEND_ACK";
payload: {
sessionId: string; // The ID of the session that was unmarked
@@ -231,7 +231,7 @@ export type SshSuspendClientToServerMessages =
| SshSuspendRemoveEntryRequest
| SshSuspendEditNameRequest
| SshMarkForSuspendRequest
| SshUnmarkForSuspendRequest; // +++ 新增到联合类型 +++
| SshUnmarkForSuspendRequest;
// Union type for all server-to-client messages for SSH Suspend
export type SshSuspendServerToClientMessages =
@@ -244,7 +244,7 @@ export type SshSuspendServerToClientMessages =
| SshSuspendNameEditedResponse
| SshSuspendAutoTerminatedNotification
| SshMarkedForSuspendAck
| SshUnmarkedForSuspendAck; // +++ 新增到联合类型 +++
| SshUnmarkedForSuspendAck;
// It might be useful to have a general type for incoming messages if not already present
// For example, if you have a main message handler:
+2 -2
View File
@@ -2,9 +2,9 @@ import { PortInfo, ClientState } from './types';
import { SftpService } from '../services/sftp.service';
import { StatusMonitorService } from '../services/status-monitor.service';
import { clientStates, sftpService, statusMonitorService } from './state';
import { sshSuspendService } from '../services/ssh-suspend.service'; // +++ 新增导入 +++
import { sshSuspendService } from '../services/ssh-suspend.service';
// --- 新增:解析 Ports 字符串的辅助函数 ---
// --- 解析 Ports 字符串的辅助函数 ---
export function parsePortsString(portsString: string | undefined | null): PortInfo[] {
if (!portsString) {
return [];