feat: 添加标签管理模态框
This commit is contained in:
@@ -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` 中间件的作用域内。
|
||||
|
||||
@@ -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 || '更新标签连接关联时发生内部服务器错误。' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,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 [];
|
||||
|
||||
Reference in New Issue
Block a user