diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 4e49a41..3936a96 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -265,6 +265,24 @@ const definedMigrations: Migration[] = [ timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); ` + }, + { + id: 8, + name: 'Create favorite_paths table', + check: async (db: Database): Promise => { + const tableAlreadyExists = await tableExists(db, 'favorite_paths'); + return !tableAlreadyExists; // Only run if the table does NOT exist + }, + sql: ` + CREATE TABLE IF NOT EXISTS favorite_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NULL, + path TEXT NOT NULL, + last_used_at INTEGER NULL; + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + ` } ]; diff --git a/packages/backend/src/database/schema.registry.ts b/packages/backend/src/database/schema.registry.ts index 7e33455..201b745 100644 --- a/packages/backend/src/database/schema.registry.ts +++ b/packages/backend/src/database/schema.registry.ts @@ -74,6 +74,7 @@ export const tableDefinitions: TableDefinition[] = [ { name: 'command_history', sql: schemaSql.createCommandHistoryTableSQL }, { name: 'path_history', sql: schemaSql.createPathHistoryTableSQL }, { name: 'quick_commands', sql: schemaSql.createQuickCommandsTableSQL }, + { name: 'favorite_paths', sql: schemaSql.createFavoritePathsTableSQL }, // Added Favorite Paths table // Appearance related tables (often depend on others or have init logic) { diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 55fe339..c67dfbc 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -239,4 +239,14 @@ CREATE TABLE IF NOT EXISTS appearance_settings ( created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); +`; +export const createFavoritePathsTableSQL = ` +CREATE TABLE IF NOT EXISTS favorite_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NULL, + path TEXT NOT NULL, + last_used_at INTEGER NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); `; \ No newline at end of file diff --git a/packages/backend/src/favorite-paths/favorite-paths.controller.ts b/packages/backend/src/favorite-paths/favorite-paths.controller.ts new file mode 100644 index 0000000..2b1f69a --- /dev/null +++ b/packages/backend/src/favorite-paths/favorite-paths.controller.ts @@ -0,0 +1,227 @@ +import { Request, Response } from 'express'; +import * as FavoritePathsService from '../services/favorite-paths.service'; +import { FavoritePathSortBy } from '../services/favorite-paths.service'; + +/** + * 处理添加新收藏路径的请求 + */ +export const createFavoritePath = async (req: Request, res: Response): Promise => { + const { name, path } = req.body; + + if (!path || typeof path !== 'string' || path.trim().length === 0) { + res.status(400).json({ message: '路径内容不能为空' }); + return; + } + if (name !== null && typeof name !== 'string') { + res.status(400).json({ message: '名称必须是字符串或 null' }); + return; + } + + try { + const newId = await FavoritePathsService.addFavoritePath(name, path); + const newFavoritePath = await FavoritePathsService.getFavoritePathById(newId); + if (newFavoritePath) { + res.status(201).json({ message: '收藏路径已添加', favoritePath: newFavoritePath }); + } else { + console.error(`[Controller] 添加收藏路径后未能找到 ID: ${newId}`); + res.status(201).json({ message: '收藏路径已添加,但无法检索新记录', id: newId }); + } + } catch (error: any) { + console.error('[Controller] 添加收藏路径失败:', error.message); + res.status(500).json({ message: error.message || '无法添加收藏路径' }); + } +}; + +/** + * 处理获取所有收藏路径的请求 (支持排序) + */ +export const getAllFavoritePaths = async (req: Request, res: Response): Promise => { + const sortBy = req.query.sortBy as FavoritePathSortBy | undefined; + const validSortByOptions: FavoritePathSortBy[] = ['name', 'last_used_at']; + const validSortBy: FavoritePathSortBy = sortBy && validSortByOptions.includes(sortBy) ? sortBy : 'name'; + + try { + const favoritePaths = await FavoritePathsService.getAllFavoritePaths(validSortBy); + res.status(200).json(favoritePaths); + } catch (error: any) { + console.error('获取收藏路径控制器出错:', error); + res.status(500).json({ message: error.message || '无法获取收藏路径' }); + } +}; + +/** + * 处理根据 ID 获取单个收藏路径的请求 + */ +export const getFavoritePathById = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ message: '无效的 ID' }); + return; + } + + try { + const favoritePath = await FavoritePathsService.getFavoritePathById(id); + if (favoritePath) { + res.status(200).json(favoritePath); + } else { + res.status(404).json({ message: '未找到指定的收藏路径' }); + } + } catch (error: any) { + console.error('获取单个收藏路径控制器出错:', error); + res.status(500).json({ message: error.message || '无法获取收藏路径' }); + } +}; + + +/** + * 处理更新收藏路径的请求 + */ +export const updateFavoritePath = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + const { name, path } = req.body; + + if (isNaN(id)) { + res.status(400).json({ message: '无效的 ID' }); + return; + } + if (!path || typeof path !== 'string' || path.trim().length === 0) { + res.status(400).json({ message: '路径内容不能为空' }); + return; + } + if (name !== null && typeof name !== 'string') { + res.status(400).json({ message: '名称必须是字符串或 null' }); + return; + } + + try { + const success = await FavoritePathsService.updateFavoritePath(id, name, path); + if (success) { + const updatedFavoritePath = await FavoritePathsService.getFavoritePathById(id); + if (updatedFavoritePath) { + res.status(200).json({ message: '收藏路径已更新', favoritePath: updatedFavoritePath }); + } else { + console.error(`[Controller] 更新收藏路径后未能找到 ID: ${id}`); + res.status(200).json({ message: '收藏路径已更新,但无法检索更新后的记录' }); + } + } else { + const pathExists = await FavoritePathsService.getFavoritePathById(id); + if (!pathExists) { + res.status(404).json({ message: '未找到要更新的收藏路径' }); + } else { + console.error(`[Controller] 更新收藏路径 ${id} 失败,但路径存在。`); + res.status(500).json({ message: '更新收藏路径时发生未知错误' }); + } + } + } catch (error: any) { + console.error('更新收藏路径控制器出错:', error); + res.status(500).json({ message: error.message || '无法更新收藏路径' }); + } +}; + +/** + * 处理删除收藏路径的请求 + */ +export const deleteFavoritePath = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ message: '无效的 ID' }); + return; + } + + try { + const success = await FavoritePathsService.deleteFavoritePath(id); + if (success) { + res.status(200).json({ message: '收藏路径已删除' }); + } else { + res.status(404).json({ message: '未找到要删除的收藏路径' }); + } + } catch (error: any) { + console.error('删除收藏路径控制器出错:', error); + res.status(500).json({ message: error.message || '无法删除收藏路径' }); + } +}; + +/** + * 处理更新收藏路径上次使用时间的请求 + */ +export const incrementUsage = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ message: '无效的 ID' }); + return; + } + + try { + // 更新上次使用时间 + const lastUsedSuccess = await FavoritePathsService.updateFavoritePathLastUsed(id); + + if (lastUsedSuccess) { + const updatedPath = await FavoritePathsService.getFavoritePathById(id); + if (updatedPath) { + res.status(200).json({ message: '上次使用时间已更新', favoritePath: updatedPath }); + } else { + // 这种情况理论上不应该发生,因为 updateFavoritePathLastUsed 内部应该处理了路径不存在的情况 + // 收藏路径 (ID: ${id}) 上次使用时间已更新,但无法检索该路径。 + console.error(`[Controller] 收藏路径 (ID: ${id}) 上次使用时间已更新,但无法检索该路径。`); + res.status(404).json({ message: '上次使用时间已更新,但无法检索更新后的收藏路径' }); + } + } else { + // 更新失败,检查路径是否存在以提供更具体的错误信息 + const pathExists = await FavoritePathsService.getFavoritePathById(id); + if (!pathExists) { + res.status(404).json({ message: '未找到要更新上次使用时间的收藏路径' }); + } else { + // 路径存在,但更新操作失败 + console.warn(`[Controller] 尝试更新收藏路径 (ID: ${id}) 的上次使用时间失败,但路径存在。`); + res.status(500).json({ message: '更新上次使用时间失败' }); + } + } + } catch (error: any) { + console.error('更新收藏路径上次使用时间控制器出错:', error); + res.status(500).json({ message: error.message || '无法更新上次使用时间' }); + } +}; + +/** + * 处理更新收藏路径上次使用时间戳的请求 (PUT) + */ +export const updateLastUsedTimestamp = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ message: '无效的 ID' }); + return; + } + + try { + // 更新上次使用时间 + const success = await FavoritePathsService.updateFavoritePathLastUsed(id); + + if (success) { + const updatedPath = await FavoritePathsService.getFavoritePathById(id); + if (updatedPath) { + res.status(200).json({ message: '上次使用时间戳已更新', favoritePath: updatedPath }); + } else { + // 这种情况理论上不应该发生,因为 updateFavoritePathLastUsed 内部应该处理了路径不存在的情况 + console.error(`[Controller] 收藏路径 (ID: ${id}) 上次使用时间戳已更新,但无法检索该路径。`); + res.status(404).json({ message: '上次使用时间戳已更新,但无法检索更新后的收藏路径' }); + } + } else { + // 更新失败,检查路径是否存在以提供更具体的错误信息 + const pathExists = await FavoritePathsService.getFavoritePathById(id); + if (!pathExists) { + res.status(404).json({ message: '未找到要更新上次使用时间戳的收藏路径' }); + } else { + // 路径存在,但更新操作失败 + console.warn(`[Controller] 尝试更新收藏路径 (ID: ${id}) 的上次使用时间戳失败,但路径存在。`); + res.status(500).json({ message: '更新上次使用时间戳失败' }); + } + } + } catch (error: any) { + console.error('更新收藏路径上次使用时间戳控制器出错:', error); + res.status(500).json({ message: error.message || '无法更新上次使用时间戳' }); + } +}; \ No newline at end of file diff --git a/packages/backend/src/favorite-paths/favorite-paths.routes.ts b/packages/backend/src/favorite-paths/favorite-paths.routes.ts new file mode 100644 index 0000000..6693d00 --- /dev/null +++ b/packages/backend/src/favorite-paths/favorite-paths.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import * as FavoritePathsController from './favorite-paths.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; + +const router = Router(); + +// 应用认证中间件,确保所有路径收藏相关的API都需要用户认证 +router.use(isAuthenticated); + +// 定义路由 +router.post('/', FavoritePathsController.createFavoritePath); // POST /api/favorite-paths +router.get('/', FavoritePathsController.getAllFavoritePaths); // GET /api/favorite-paths?sortBy=name|usage_count +router.get('/:id', FavoritePathsController.getFavoritePathById); // GET /api/favorite-paths/:id +router.put('/:id', FavoritePathsController.updateFavoritePath); // PUT /api/favorite-paths/:id +router.delete('/:id', FavoritePathsController.deleteFavoritePath); // DELETE /api/favorite-paths/:id +router.put('/:id/update-last-used', FavoritePathsController.updateLastUsedTimestamp); // PUT /api/favorite-paths/:id/update-last-used + +export default router; \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a467e5e..2414c16 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -55,6 +55,7 @@ import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes'; import { transfersRoutes } from './transfers/transfers.routes'; import pathHistoryRoutes from './path-history/path-history.routes'; +import favoritePathsRouter from './favorite-paths/favorite-paths.routes'; import { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; @@ -261,6 +262,7 @@ const startServer = () => { app.use('/api/v1/ssh-suspend', sshSuspendRouter); app.use('/api/v1/transfers', transfersRoutes()); app.use('/api/v1/path-history', pathHistoryRoutes); + app.use('/api/v1/favorite-paths', favoritePathsRouter); // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { diff --git a/packages/backend/src/repositories/favorite-paths.repository.ts b/packages/backend/src/repositories/favorite-paths.repository.ts new file mode 100644 index 0000000..1d6ea45 --- /dev/null +++ b/packages/backend/src/repositories/favorite-paths.repository.ts @@ -0,0 +1,123 @@ +import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; + +// 定义收藏路径接口 +export interface FavoritePath { + id: number; + name: string | null; // 名称可选 + path: string; + last_used_at?: number | null; // 上次使用时间,允许为空 + created_at: number; // Unix 时间戳 (秒) + updated_at: number; // Unix 时间戳 (秒) +} + +/** + * 添加一条新的收藏路径 + * @param name - 路径名称 (可选) + * @param path - 路径内容 + * @returns 返回插入记录的 ID + */ +export const addFavoritePath = async (name: string | null, path: string): Promise => { + const sql = `INSERT INTO favorite_paths (name, path, created_at, updated_at) VALUES (?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; + try { + const db = await getDbInstance(); + const result = await runDb(db, sql, [name, path]); + if (typeof result.lastID !== 'number' || result.lastID <= 0) { + throw new Error('添加收藏路径后未能获取有效的 lastID'); + } + return result.lastID; + } catch (err: any) { + console.error('添加收藏路径时出错:', err.message); + throw new Error('无法添加收藏路径'); + } +}; + +/** + * 更新指定的收藏路径 + * @param id - 要更新的记录 ID + * @param name - 新的路径名称 (可选) + * @param path - 新的路径内容 + * @returns 返回是否成功更新 (true/false) + */ +export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise => { + const sql = `UPDATE favorite_paths SET name = ?, path = ?, updated_at = strftime('%s', 'now') WHERE id = ?`; + try { + const db = await getDbInstance(); + const result = await runDb(db, sql, [name, path, id]); + return result.changes > 0; + } catch (err: any) { + console.error('更新收藏路径时出错:', err.message); + throw new Error('无法更新收藏路径'); + } +}; + +/** + * 根据 ID 删除指定的收藏路径 + * @param id - 要删除的记录 ID + * @returns 返回是否成功删除 (true/false) + */ +export const deleteFavoritePath = async (id: number): Promise => { + const sql = `DELETE FROM favorite_paths WHERE id = ?`; + try { + const db = await getDbInstance(); + const result = await runDb(db, sql, [id]); + return result.changes > 0; + } catch (err: any) { + console.error('删除收藏路径时出错:', err.message); + throw new Error('无法删除收藏路径'); + } +}; + +/** + * 获取所有收藏路径 + * @param sortBy - 排序字段 ('name' 或 'usage_count') + * @returns 返回包含所有收藏路径条目的数组 + */ +export const getAllFavoritePaths = async (sortBy: 'name' | 'last_used_at' = 'name'): Promise => { + let orderByClause = 'ORDER BY name ASC'; // 默认按名称升序 + if (sortBy === 'last_used_at') { + orderByClause = 'ORDER BY last_used_at DESC, name ASC'; // 按上次使用时间降序,同时间的按名称升序 + } + const sql = `SELECT id, name, path, last_used_at, created_at, updated_at FROM favorite_paths ${orderByClause}`; + try { + const db = await getDbInstance(); + const rows = await allDb(db, sql); + return rows; + } catch (err: any) { + console.error('获取收藏路径时出错:', err.message); + throw new Error('无法获取收藏路径'); + } +}; + +/** + * 更新指定收藏路径的上次使用时间 + * @param id - 要更新的记录 ID + * @returns 返回是否成功更新 (true/false) + */ +export const updateFavoritePathLastUsedAt = async (id: number): Promise => { + const sql = `UPDATE favorite_paths SET last_used_at = strftime('%s', 'now'), updated_at = strftime('%s', 'now') WHERE id = ?`; + try { + const db = await getDbInstance(); + const result = await runDb(db, sql, [id]); + return result.changes > 0; + } catch (err: any) { + console.error('更新收藏路径上次使用时间时出错:', err.message); + throw new Error('无法更新收藏路径上次使用时间'); + } +}; + +/** + * 根据 ID 查找收藏路径 + * @param id - 要查找的记录 ID + * @returns 返回找到的收藏路径条目,如果未找到则返回 undefined + */ +export const findFavoritePathById = async (id: number): Promise => { + const sql = `SELECT id, name, path, last_used_at, created_at, updated_at FROM favorite_paths WHERE id = ?`; + try { + const db = await getDbInstance(); + const row = await getDbRow(db, sql, [id]); + return row; + } catch (err: any) { + console.error('查找收藏路径时出错:', err.message); + throw new Error('无法查找收藏路径'); + } +}; \ No newline at end of file diff --git a/packages/backend/src/services/favorite-paths.service.ts b/packages/backend/src/services/favorite-paths.service.ts new file mode 100644 index 0000000..877a81d --- /dev/null +++ b/packages/backend/src/services/favorite-paths.service.ts @@ -0,0 +1,75 @@ +import * as FavoritePathsRepository from '../repositories/favorite-paths.repository'; +import { FavoritePath } from '../repositories/favorite-paths.repository'; + +// 定义排序类型 +export type FavoritePathSortBy = 'name' | 'last_used_at'; + +/** + * 添加收藏路径 + * @param name - 路径名称 (可选) + * @param path - 路径内容 + * @returns 返回添加记录的 ID + */ +export const addFavoritePath = async (name: string | null, path: string): Promise => { + if (!path || path.trim().length === 0) { + throw new Error('路径内容不能为空'); + } + // 如果 name 是空字符串,则视为 null + const finalName = name && name.trim().length > 0 ? name.trim() : null; + const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim()); + return favoritePathId; +}; + +/** + * 更新收藏路径 + * @param id - 要更新的记录 ID + * @param name - 新的路径名称 (可选) + * @param path - 新的路径内容 + * @returns 返回是否成功更新 (更新行数 > 0) + */ +export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise => { + if (!path || path.trim().length === 0) { + throw new Error('路径内容不能为空'); + } + const finalName = name && name.trim().length > 0 ? name.trim() : null; + const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim()); + return pathUpdated; +}; + +/** + * 删除收藏路径 + * @param id - 要删除的记录 ID + * @returns 返回是否成功删除 (删除行数 > 0) + */ +export const deleteFavoritePath = async (id: number): Promise => { + const changes = await FavoritePathsRepository.deleteFavoritePath(id); + return changes; +}; + +/** + * 获取所有收藏路径,并按指定方式排序 + * @param sortBy - 排序字段 ('name' 或 'usage_count') + * @returns 返回排序后的收藏路径数组 + */ +export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name'): Promise => { + return FavoritePathsRepository.getAllFavoritePaths(sortBy); +}; + +/** + * 更新收藏路径的上次使用时间 + * @param id - 收藏路径的ID + * @returns Promise - 操作是否成功 + */ +export const updateFavoritePathLastUsed = async (id: number): Promise => { + // 未来可能在这里添加额外的业务逻辑或验证 + return FavoritePathsRepository.updateFavoritePathLastUsedAt(id); +}; + +/** + * 根据 ID 获取单个收藏路径 + * @param id - 记录 ID + * @returns 返回找到的收藏路径,或 undefined + */ +export const getFavoritePathById = async (id: number): Promise => { + return FavoritePathsRepository.findFavoritePathById(id); +}; \ No newline at end of file diff --git a/packages/frontend/src/components/AddEditFavoritePathForm.vue b/packages/frontend/src/components/AddEditFavoritePathForm.vue new file mode 100644 index 0000000..e2a2d25 --- /dev/null +++ b/packages/frontend/src/components/AddEditFavoritePathForm.vue @@ -0,0 +1,171 @@ + + + + + \ No newline at end of file diff --git a/packages/frontend/src/components/FavoritePathsModal.vue b/packages/frontend/src/components/FavoritePathsModal.vue new file mode 100644 index 0000000..fb0cc47 --- /dev/null +++ b/packages/frontend/src/components/FavoritePathsModal.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 7393417..593d455 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -18,8 +18,9 @@ import FileManagerContextMenu from './FileManagerContextMenu.vue'; import FileManagerActionModal from './FileManagerActionModal.vue'; import type { FileListItem } from '../types/sftp.types'; import type { WebSocketMessage } from '../types/websocket.types'; -import PathHistoryDropdown from './PathHistoryDropdown.vue'; -import { usePathHistoryStore } from '../stores/pathHistory.store'; +import PathHistoryDropdown from './PathHistoryDropdown.vue'; +import { usePathHistoryStore } from '../stores/pathHistory.store'; +import FavoritePathsModal from './FavoritePathsModal.vue'; // +++ Import FavoritePathsModal +++ type SftpManagerInstance = ReturnType; @@ -130,6 +131,10 @@ const fileListContainerRef = ref(null); // 文件列表 const dropOverlayRef = ref(null); // +++ 拖拽蒙版引用 +++ // const scrollIntervalId = ref(null); // 已移至 useFileManagerDragAndDrop +// +++ Favorite Paths Modal State +++ +const showFavoritePathsModal = ref(false); +const favoritePathsButtonRef = ref(null); // Ref for the trigger button + // +++ Path History Refs +++ const showPathHistoryDropdown = ref(false); const pathInputWrapperRef = ref(null); // Wrapper for path input and dropdown @@ -1548,8 +1553,23 @@ const handleOpenEditorClick = () => { // 暂时使用 triggerPopup,传递空字符串表示空编辑器 // 后续可能需要 fileEditorStore.triggerEmptyPopup(props.sessionId); fileEditorStore.triggerPopup('', props.sessionId); // 修复:传递空字符串而不是 null -}; - + }; + + // +++ Favorite Paths Modal Logic +++ + const toggleFavoritePathsModal = () => { + showFavoritePathsModal.value = !showFavoritePathsModal.value; + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Toggled FavoritePathsModal. Visible: ${showFavoritePathsModal.value}`); + }; + + const handleNavigateToPathFromFavorites = (path: string) => { + if (currentSftpManager.value) { + currentSftpManager.value.loadDirectory(path); + // Optionally, add to local path history if not already handled by the store/modal + // pathHistoryStore.addPath(path); + } + showFavoritePathsModal.value = false; // Close modal after navigation + }; +