diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index fe3b98f..4e49a41 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -227,13 +227,12 @@ const definedMigrations: Migration[] = [ ANALYZE; -- 重新分析数据库模式 ` }, - // --- 未来可以添加更多迁移 --- { id: 6, name: 'Create passkeys table for WebAuthn credentials', check: async (db: Database): Promise => { const passkeysTableAlreadyExists = await tableExists(db, 'passkeys'); - return !passkeysTableAlreadyExists; // Only run if the table does NOT exist + return !passkeysTableAlreadyExists; }, sql: ` CREATE TABLE IF NOT EXISTS passkeys ( @@ -251,6 +250,21 @@ const definedMigrations: Migration[] = [ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); ` + }, + { + id: 7, + name: 'Create path_history table', + check: async (db: Database): Promise => { + const tableAlreadyExists = await tableExists(db, 'path_history'); + return !tableAlreadyExists; + }, + sql: ` + CREATE TABLE IF NOT EXISTS path_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + timestamp 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 a23f1d5..7e33455 100644 --- a/packages/backend/src/database/schema.registry.ts +++ b/packages/backend/src/database/schema.registry.ts @@ -72,6 +72,7 @@ export const tableDefinitions: TableDefinition[] = [ // Other utilities { name: 'ip_blacklist', sql: schemaSql.createIpBlacklistTableSQL }, { name: 'command_history', sql: schemaSql.createCommandHistoryTableSQL }, + { name: 'path_history', sql: schemaSql.createPathHistoryTableSQL }, { name: 'quick_commands', sql: schemaSql.createQuickCommandsTableSQL }, // 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 3ca3925..55fe339 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -158,6 +158,14 @@ CREATE TABLE IF NOT EXISTS command_history ( ); `; +export const createPathHistoryTableSQL = ` +CREATE TABLE IF NOT EXISTS path_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +`; + export const createQuickCommandsTableSQL = ` CREATE TABLE IF NOT EXISTS quick_commands ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4a211eb..a467e5e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -54,6 +54,7 @@ import sshKeysRouter from './ssh_keys/ssh_keys.routes'; 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 { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; @@ -258,7 +259,8 @@ const startServer = () => { app.use('/api/v1/ssh-keys', sshKeysRouter); app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); app.use('/api/v1/ssh-suspend', sshSuspendRouter); - app.use('/api/v1/transfers', transfersRoutes()); + app.use('/api/v1/transfers', transfersRoutes()); + app.use('/api/v1/path-history', pathHistoryRoutes); // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { diff --git a/packages/backend/src/path-history/path-history.controller.ts b/packages/backend/src/path-history/path-history.controller.ts new file mode 100644 index 0000000..689a90b --- /dev/null +++ b/packages/backend/src/path-history/path-history.controller.ts @@ -0,0 +1,73 @@ +import { Request, Response } from 'express'; +import * as PathHistoryService from '../services/path-history.service'; + +/** + * 处理添加新路径历史记录的请求 + */ +export const addPath = async (req: Request, res: Response): Promise => { + const { path } = req.body; + + if (!path || typeof path !== 'string' || path.trim().length === 0) { + res.status(400).json({ message: '路径不能为空' }); + return; + } + + try { + const newId = await PathHistoryService.addPathHistory(path); + res.status(201).json({ id: newId, message: '路径已添加到历史记录' }); + } catch (error: any) { + console.error('添加路径历史记录控制器出错:', error); + res.status(500).json({ message: error.message || '无法添加路径历史记录' }); + } +}; + +/** + * 处理获取所有路径历史记录的请求 + */ +export const getAllPaths = async (req: Request, res: Response): Promise => { + try { + const history = await PathHistoryService.getAllPathHistory(); + // Repository 返回的是升序(旧->新) + res.status(200).json(history); + } catch (error: any) { + console.error('获取路径历史记录控制器出错:', error); + res.status(500).json({ message: error.message || '无法获取路径历史记录' }); + } +}; + +/** + * 处理根据 ID 删除路径历史记录的请求 + */ +export const deletePath = 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 PathHistoryService.deletePathHistoryById(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 clearAllPaths = async (req: Request, res: Response): Promise => { + try { + const count = await PathHistoryService.clearAllPathHistory(); + res.status(200).json({ count, message: `已清空 ${count} 条路径历史记录` }); + } 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/path-history/path-history.routes.ts b/packages/backend/src/path-history/path-history.routes.ts new file mode 100644 index 0000000..d24c2e6 --- /dev/null +++ b/packages/backend/src/path-history/path-history.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import * as PathHistoryController from './path-history.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; // 更新认证中间件 + +const router = Router(); + +// 应用认证中间件到所有路径历史路由 +router.use(isAuthenticated); + +router.post('/', PathHistoryController.addPath); +router.get('/', PathHistoryController.getAllPaths); +router.delete('/:id', PathHistoryController.deletePath); +router.delete('/', PathHistoryController.clearAllPaths); // 更新清空路由 + +export default router; \ No newline at end of file diff --git a/packages/backend/src/repositories/path-history.repository.ts b/packages/backend/src/repositories/path-history.repository.ts new file mode 100644 index 0000000..8544c1e --- /dev/null +++ b/packages/backend/src/repositories/path-history.repository.ts @@ -0,0 +1,100 @@ +import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; + +// 定义路径历史记录的接口 +export interface PathHistoryEntry { + id: number; + path: string; + timestamp: number; // Unix 时间戳 (秒) +} + +type DbPathHistoryRow = PathHistoryEntry; + +/** + * 插入或更新一条路径历史记录。 + * 如果路径已存在,则更新其时间戳;否则,插入新记录。 + * @param path - 要添加或更新的路径字符串 + * @returns 返回插入或更新记录的 ID + */ +export const upsertPath = async (path: string): Promise => { + const now = Math.floor(Date.now() / 1000); // 获取当前时间戳 + const db = await getDbInstance(); + + try { + // 1. 尝试更新现有记录的时间戳 + const updateSql = `UPDATE path_history SET timestamp = ? WHERE path = ?`; + const updateResult = await runDb(db, updateSql, [now, path]); + + if (updateResult.changes > 0) { + // 更新成功,需要获取被更新记录的 ID + const selectSql = `SELECT id FROM path_history WHERE path = ? ORDER BY timestamp DESC LIMIT 1`; + const row = await getDbRow<{ id: number }>(db, selectSql, [path]); + if (row) { + return row.id; + } else { + // This case should theoretically not happen if update succeeded + throw new Error('更新成功但无法找到记录 ID'); + } + } else { + // 2. 没有记录被更新,说明路径不存在,执行插入 + const insertSql = `INSERT INTO path_history (path, timestamp) VALUES (?, ?)`; + const insertResult = await runDb(db, insertSql, [path, now]); + // Ensure lastID is valid before returning + if (typeof insertResult.lastID !== 'number' || insertResult.lastID <= 0) { + throw new Error('插入新路径历史记录后未能获取有效的 lastID'); + } + return insertResult.lastID; + } + } catch (err: any) { + console.error('Upsert 路径历史记录时出错:', err.message); + throw new Error('无法更新或插入路径历史记录'); + } +}; + +/** + * 获取所有路径历史记录,按时间戳升序排列(最旧的在前) + * @returns 返回包含所有历史记录条目的数组 + */ +export const getAllPaths = async (): Promise => { + const sql = `SELECT id, path, timestamp FROM path_history ORDER BY timestamp ASC`; + try { + const db = await getDbInstance(); + const rows = await allDb(db, sql); + return rows; + } catch (err: any) { + console.error('获取路径历史记录时出错:', err.message); + throw new Error('无法获取路径历史记录'); + } +}; + +/** + * 根据 ID 删除指定的路径历史记录 + * @param id - 要删除的记录 ID + * @returns 返回是否成功删除 (true/false) + */ +export const deletePathById = async (id: number): Promise => { + const sql = `DELETE FROM path_history 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('无法删除路径历史记录'); + } +}; + +/** + * 清空所有路径历史记录 + * @returns 返回删除的行数 + */ +export const clearAllPaths = async (): Promise => { + const sql = `DELETE FROM path_history`; + try { + const db = await getDbInstance(); + const result = await runDb(db, sql); + return result.changes; + } catch (err: any) { + console.error('清空路径历史记录时出错:', err.message); + throw new Error('无法清空路径历史记录'); + } +}; \ No newline at end of file diff --git a/packages/backend/src/services/path-history.service.ts b/packages/backend/src/services/path-history.service.ts new file mode 100644 index 0000000..6f47a0f --- /dev/null +++ b/packages/backend/src/services/path-history.service.ts @@ -0,0 +1,43 @@ +import * as PathHistoryRepository from '../repositories/path-history.repository'; +import { PathHistoryEntry } from '../repositories/path-history.repository'; + +/** + * 添加一条路径历史记录 + * @param path - 要添加的路径 + * @returns 返回添加记录的 ID + */ +export const addPathHistory = async (path: string): Promise => { + // 可以在这里添加额外的业务逻辑,例如校验路径格式、长度限制等 + if (!path || path.trim().length === 0) { + throw new Error('路径不能为空'); + } + + // 调用 upsertPath 来处理插入或更新时间戳 + return PathHistoryRepository.upsertPath(path.trim()); +}; + +/** + * 获取所有路径历史记录 + * @returns 返回所有历史记录条目数组,按时间戳升序 + */ +export const getAllPathHistory = async (): Promise => { + return PathHistoryRepository.getAllPaths(); +}; + +/** + * 根据 ID 删除一条路径历史记录 + * @param id - 要删除的记录 ID + * @returns 返回是否成功删除 (删除行数 > 0) + */ +export const deletePathHistoryById = async (id: number): Promise => { + const success = await PathHistoryRepository.deletePathById(id); + return success; +}; + +/** + * 清空所有路径历史记录 + * @returns 返回删除的记录条数 + */ +export const clearAllPathHistory = async (): Promise => { + return PathHistoryRepository.clearAllPaths(); +}; \ No newline at end of file diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index bd725a7..7393417 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -18,6 +18,8 @@ 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'; type SftpManagerInstance = ReturnType; @@ -98,6 +100,7 @@ const fileEditorStore = useFileEditorStore(); // 实例化 File Editor Store // const sessionStore = useSessionStore(); // 已在上面实例化 const settingsStore = useSettingsStore(); // +++ 实例化 Settings Store +++ const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++ +const pathHistoryStore = usePathHistoryStore(); // +++ 实例化 PathHistoryStore +++ // 从 Settings Store 获取共享设置 const { @@ -127,6 +130,12 @@ const fileListContainerRef = ref(null); // 文件列表 const dropOverlayRef = ref(null); // +++ 拖拽蒙版引用 +++ // const scrollIntervalId = ref(null); // 已移至 useFileManagerDragAndDrop +// +++ Path History Refs +++ +const showPathHistoryDropdown = ref(false); +const pathInputWrapperRef = ref(null); // Wrapper for path input and dropdown +const pathHistoryDropdownRef = ref | null>(null); +const { selectedIndex: pathSelectedIndex, filteredHistory: filteredPathHistory } = storeToRefs(pathHistoryStore); // Reactive store state + // +++ 操作模态框状态 +++ const isActionModalVisible = ref(false); const currentActionType = ref<'delete' | 'rename' | 'chmod' | 'newFile' | 'newFolder' | null>(null); @@ -1066,7 +1075,8 @@ watch(() => focusSwitcherStore.activateFileManagerSearchTrigger, (newValue, oldV // --- 监听 sessionId prop 的变化 --- watch(() => props.sessionId, (newSessionId, oldSessionId) => { if (newSessionId && newSessionId !== oldSessionId) { - + closePathHistory(); // 关闭可能打开的路径历史下拉菜单 + pathHistoryStore.setSearchTerm(''); // 清空搜索词 // 1. 重新初始化 SFTP 管理器 initializeSftpManager(newSessionId, props.instanceId); @@ -1098,6 +1108,7 @@ onMounted(() => { const focusSearchActionWrapper = async (): Promise => { if (props.sessionId === sessionStore.activeSessionId) { console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Executing search focus action for active session.`); + closePathHistory(); // Close path history if open return focusSearchInput(); } else { console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Search focus action skipped for inactive session.`); @@ -1122,26 +1133,28 @@ onMounted(() => { } }; unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper); + document.addEventListener('click', handleClickOutsidePathInput); }); onBeforeUnmount(() => { - // 注销搜索框动作 - if (unregisterSearchFocusAction) { - unregisterSearchFocusAction(); - console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered search focus action on unmount.`); - } - unregisterSearchFocusAction = null; + // 注销搜索框动作 + if (unregisterSearchFocusAction) { + unregisterSearchFocusAction(); + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered search focus action on unmount.`); + } + unregisterSearchFocusAction = null; - // 注销路径编辑框动作 - if (unregisterPathFocusAction) { - unregisterPathFocusAction(); - console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered path edit focus action on unmount.`); - } - unregisterPathFocusAction = null; - // // 调用注入的 SFTP 管理器提供的清理函数 (移除,由 store 处理) - // cleanupSftpHandlers(); - // 调用 store 的清理方法 - sessionStore.removeSftpManager(props.sessionId, props.instanceId); + // 注销路径编辑框动作 + if (unregisterPathFocusAction) { + unregisterPathFocusAction(); + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered path edit focus action on unmount.`); + } + unregisterPathFocusAction = null; + document.removeEventListener('click', handleClickOutsidePathInput); + // // 调用注入的 SFTP 管理器提供的清理函数 (移除,由 store 处理) + // cleanupSftpHandlers(); + // 调用 store 的清理方法 + sessionStore.removeSftpManager(props.sessionId, props.instanceId); }); // +++ 监听蒙版可见性,动态调整高度 +++ @@ -1214,38 +1227,180 @@ const stopResize = () => { } }; -// --- 路径编辑逻辑 --- +// --- 路径编辑逻辑 (包含路径历史) --- + +const openPathHistory = () => { + showPathHistoryDropdown.value = true; // 总是尝试显示下拉框 + // 如果列表为空,则尝试获取历史记录。 + // pathHistoryStore.fetchHistory() 应该能够处理未连接时 apiClient 的失败。 + if (pathHistoryStore.historyList.length === 0) { + pathHistoryStore.fetchHistory(); + } + // 总是设置搜索词,以便即使历史记录是旧的或空的,也能基于当前输入进行过滤或显示。 + pathHistoryStore.setSearchTerm(editablePath.value); +}; + +const closePathHistory = () => { + showPathHistoryDropdown.value = false; + pathHistoryStore.resetSelection(); +}; + +const handlePathInputFocus = () => { + isEditingPath.value = true; // Keep existing behavior + if (!currentSftpManager.value || currentSftpManager.value.isLoading.value || !props.wsDeps.isConnected.value) return; + editablePath.value = currentSftpManager.value.currentPath.value; // Set editable path on focus + openPathHistory(); + nextTick(() => { + pathInputRef.value?.select(); + }); +}; + +const handlePathInputChange = () => { + if (showPathHistoryDropdown.value) { + pathHistoryStore.setSearchTerm(editablePath.value); + } +}; + +const navigateToPath = async (path: string) => { + if (!currentSftpManager.value || !path || path.trim().length === 0) return; + const trimmedPath = path.trim(); + isEditingPath.value = false; + closePathHistory(); + + if (trimmedPath === currentSftpManager.value.currentPath.value) { + return; + } + + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] 尝试导航到新路径: ${trimmedPath}`); + try { + await currentSftpManager.value.loadDirectory(trimmedPath); + // 如果 loadDirectory 没有抛出错误,我们认为它成功了 + pathHistoryStore.addPath(trimmedPath); // 导航成功后添加到历史 + editablePath.value = trimmedPath; // 更新输入框内容 + } catch (error) { + console.error(`[FileManager ${props.sessionId}-${props.instanceId}] 导航到路径 ${trimmedPath} 失败:`, error); + // 导航失败,不添加到历史记录,也不更新输入框内容 (除非有特定需求) + } +}; + +const handlePathInputKeydown = (event: KeyboardEvent) => { + if (!showPathHistoryDropdown.value) { + if (event.key === 'Enter') { + navigateToPath(editablePath.value); + } else if (event.key === 'Escape') { + cancelPathEdit(); + } + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + pathHistoryStore.selectNextPath(); + // Dropdown component handles scrolling + break; + case 'ArrowUp': + event.preventDefault(); + pathHistoryStore.selectPreviousPath(); + // Dropdown component handles scrolling + break; + case 'Enter': + event.preventDefault(); + if (pathSelectedIndex.value >= 0 && filteredPathHistory.value[pathSelectedIndex.value]) { + navigateToPath(filteredPathHistory.value[pathSelectedIndex.value].path); + } else { + navigateToPath(editablePath.value); + } + closePathHistory(); + break; + case 'Escape': + event.preventDefault(); + closePathHistory(); + // Keep isEditingPath true to allow user to continue editing or blur + break; + } +}; + +const handlePathSelectedFromDropdown = (path: string) => { + editablePath.value = path; // Update input field + navigateToPath(path); // Navigate and add to history + closePathHistory(); +}; + const startPathEdit = () => { - // 修改:检查 currentSftpManager 是否存在并使用其状态 if (!currentSftpManager.value || currentSftpManager.value.isLoading.value || !props.wsDeps.isConnected.value) return; - // 修改:使用 currentSftpManager.value.currentPath 初始化编辑框 editablePath.value = currentSftpManager.value.currentPath.value; isEditingPath.value = true; + openPathHistory(); // 打开历史记录 nextTick(() => { pathInputRef.value?.focus(); pathInputRef.value?.select(); }); }; -const handlePathInput = async (event?: Event) => { +// Modified to handle path history logic +const handlePathInput = async (event?: Event | FocusEvent) => { + // This function is now primarily for blur handling or if Enter is pressed outside keydown. + // Most Enter logic is in handlePathInputKeydown. if (event && event instanceof KeyboardEvent && event.key !== 'Enter') { - return; + // If it's a key event but not Enter, it's handled by keydown or change. + return; } - // 修改:检查 currentSftpManager 是否存在 + + // If it's a blur event, and the dropdown is not the target, close dropdown. + // The timeout ensures that a click on the dropdown item can be processed first. + if (event && event.type === 'blur') { + setTimeout(() => { + const activeEl = document.activeElement; + const dropdownEl = pathHistoryDropdownRef.value?.$el; + if (dropdownEl && dropdownEl.contains(activeEl)) { + // Focus is within the dropdown, do nothing yet + return; + } + if (pathInputRef.value !== activeEl) { // Focus moved away from input and not into dropdown + isEditingPath.value = false; // Only set to false if focus truly left + closePathHistory(); + } + }, 150); // Slightly longer delay to allow dropdown item click + return; // Don't navigate on blur, only close dropdown + } + + // If it's an Enter key press not handled by keydown (e.g. from a button click if any) + // or if the function is called directly without an event. if (!currentSftpManager.value) return; + const newPath = editablePath.value.trim(); - isEditingPath.value = false; - // 修改:使用 currentSftpManager.value.currentPath 比较 - if (newPath === currentSftpManager.value.currentPath.value || !newPath) { - return; + // Check if dropdown has a selection, if so, it should have been handled by Enter in keydown + if (pathSelectedIndex.value >= 0 && filteredPathHistory.value[pathSelectedIndex.value]) { + // This case should ideally not be hit if keydown is working correctly + navigateToPath(filteredPathHistory.value[pathSelectedIndex.value].path); + } else { + navigateToPath(newPath); } - console.log(`[FileManager ${props.sessionId}-${props.instanceId}] 尝试导航到新路径: ${newPath}`); - // 修改:使用 currentSftpManager.value.loadDirectory - await currentSftpManager.value.loadDirectory(newPath); + isEditingPath.value = false; // Ensure editing mode is exited + closePathHistory(); // Ensure dropdown is closed }; + const cancelPathEdit = () => { isEditingPath.value = false; + closePathHistory(); + // Optionally, revert editablePath to currentSftpManager.currentPath.value + if (currentSftpManager.value) { + editablePath.value = currentSftpManager.value.currentPath.value; + } +}; + +const handleClickOutsidePathInput = (event: MouseEvent) => { + if (pathInputWrapperRef.value && !pathInputWrapperRef.value.contains(event.target as Node)) { + if (isEditingPath.value || showPathHistoryDropdown.value) { + // editablePath.value might be different from current manager path + // if user typed something and then clicked outside. + // Decide if we should commit or revert. For now, just close. + isEditingPath.value = false; + closePathHistory(); + } + } }; // 清除错误消息的函数 - 不再需要,错误由 UI 通知处理 @@ -1460,14 +1615,13 @@ const handleOpenEditorClick = () => { - -
- + +
+ {{ t('fileManager.currentPath') }}: +
diff --git a/packages/frontend/src/components/PathHistoryDropdown.vue b/packages/frontend/src/components/PathHistoryDropdown.vue new file mode 100644 index 0000000..c24898f --- /dev/null +++ b/packages/frontend/src/components/PathHistoryDropdown.vue @@ -0,0 +1,143 @@ + + + + + \ No newline at end of file diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index a5066e6..0822fec 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -1,5 +1,4 @@ { - "appName": "Nexus Terminal", "projectName": "Nexus Terminal", "slogan":"Stir the stars, command the terminal.", @@ -1504,5 +1503,13 @@ "transferInitiated": "Transfer task created", "transferInitiatedGeneric": "Transfer task created successfully.", "transferFailedError": "Failed to initiate transfer. Please try again." + }, + "pathHistory": { + "loading": "Loading...", + "empty": "No path history", + "copy": "Copy path", + "delete": "Delete this history entry", + "copiedSuccess": "Path copied to clipboard", + "copiedError": "Failed to copy path" } } diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 4eebc57..0771a7c 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -1465,5 +1465,13 @@ "transferInitiated": "転送タスクが作成されました", "transferInitiatedGeneric": "転送タスクが正常に作成されました。", "transferFailedError": "転送の開始に失敗しました。もう一度お試しください。" + }, + "pathHistory": { + "loading": "読み込み中...", + "empty": "パス履歴がありません", + "copy": "パスをコピー", + "delete": "この履歴を削除", + "copiedSuccess": "パスがクリップボードにコピーされました", + "copiedError": "パスのコピーに失敗しました" } } \ No newline at end of file diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 6a2afc3..d45d31f 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -1508,5 +1508,13 @@ "logExportSuccess": "已挂起会话日志 {name} 已开始下载。", "logExportError": "导出已挂起会话日志失败: {error}" } + }, + "pathHistory": { + "loading": "加载中...", + "empty": "没有路径历史记录", + "copy": "复制路径", + "delete": "删除此条历史", + "copiedSuccess": "路径已复制到剪贴板", + "copiedError": "复制路径失败" } } diff --git a/packages/frontend/src/stores/pathHistory.store.ts b/packages/frontend/src/stores/pathHistory.store.ts new file mode 100644 index 0000000..74b8348 --- /dev/null +++ b/packages/frontend/src/stores/pathHistory.store.ts @@ -0,0 +1,161 @@ +import { defineStore } from 'pinia'; +import apiClient from '../utils/apiClient'; +import { ref, computed } from 'vue'; +import { useUiNotificationsStore } from './uiNotifications.store'; + +// 后端返回的原始路径历史记录条目接口 +interface PathHistoryEntryBE { + id: number; + path: string; + timestamp: number; // Unix 时间戳 (秒) +} + +// 前端使用的路径历史记录条目接口 +export interface PathHistoryEntryFE extends PathHistoryEntryBE { + // 可以根据需要添加前端特定的字段 +} + +export const usePathHistoryStore = defineStore('pathHistory', () => { + const historyList = ref([]); + const searchTerm = ref(''); + const isLoading = ref(false); + const error = ref(null); + const uiNotificationsStore = useUiNotificationsStore(); + const selectedIndex = ref(-1); // 过滤列表中选中路径的索引 + + // --- Getters --- + + // 计算属性:根据搜索词过滤历史记录 + const filteredHistory = computed(() => { + const term = searchTerm.value.toLowerCase().trim(); + if (!term) { + return historyList.value; // 没有搜索词则返回全部 + } + return historyList.value.filter(entry => + entry.path.toLowerCase().includes(term) + ); + }); + + // --- Actions --- + + // Action: 选中过滤列表中的下一个路径 + const selectNextPath = () => { + const history = filteredHistory.value; + if (history.length === 0) { + selectedIndex.value = -1; + return; + } + selectedIndex.value = (selectedIndex.value + 1) % history.length; + }; + + // Action: 选中过滤列表中的上一个路径 + const selectPreviousPath = () => { + const history = filteredHistory.value; + if (history.length === 0) { + selectedIndex.value = -1; + return; + } + selectedIndex.value = (selectedIndex.value - 1 + history.length) % history.length; + }; + + // Action: 重置选中状态 + const resetSelection = () => { + selectedIndex.value = -1; + }; + + // 从后端获取历史记录 + const fetchHistory = async () => { + // 注意:路径历史可能不需要像命令历史那样频繁地使用 localStorage 缓存, + // 因为它通常在用户与 UI 交互时(如聚焦输入框)才加载。 + // 如果需要缓存,可以参考 commandHistory.store.ts 中的实现。 + error.value = null; + isLoading.value = true; + try { + const response = await apiClient.get('/path-history'); + // 后端返回的可能是升序,前端通常希望降序显示(最新的在前面) + historyList.value = response.data.sort((a, b) => b.timestamp - a.timestamp); + error.value = null; + } catch (err: any) { + console.error('[PathHistoryStore] 获取路径历史记录失败:', err); + error.value = err.response?.data?.message || '获取路径历史记录时发生错误'; + uiNotificationsStore.showError(error.value ?? '未知错误'); + } finally { + isLoading.value = false; + } + }; + + // 添加路径到历史记录 + const addPath = async (path: string) => { + if (!path || path.trim().length === 0) { + return; // 不添加空路径 + } + try { + await apiClient.post('/path-history', { path: path.trim() }); + // 添加成功后,重新获取列表以保证最新状态和正确排序 + await fetchHistory(); + // 也可以考虑在前端直接将新条目添加到列表顶部,以优化体验, + // 但需要确保 ID 和 timestamp 的处理与后端一致或在 fetchHistory 时得到刷新。 + // 例如 (如果后端返回新条目): + // const newEntry = response.data; + // historyList.value.unshift(newEntry); + // historyList.value.sort((a, b) => b.timestamp - a.timestamp); // 确保排序 + } catch (err: any) { + console.error('[PathHistoryStore] 添加路径历史记录失败:', err); + const message = err.response?.data?.message || '添加路径历史记录时发生错误'; + uiNotificationsStore.showError(message); + } + }; + + // 删除单条历史记录 + const deletePath = async (id: number) => { + try { + await apiClient.delete(`/path-history/${id}`); + // 删除成功后,更新本地列表 + const index = historyList.value.findIndex(entry => entry.id === id); + if (index !== -1) { + historyList.value.splice(index, 1); + } + uiNotificationsStore.showSuccess('路径历史记录已删除'); + } catch (err: any) { + console.error('[PathHistoryStore] 删除路径历史记录失败:', err); + const message = err.response?.data?.message || '删除路径历史记录时发生错误'; + uiNotificationsStore.showError(message); + } + }; + + // 清空所有历史记录 + const clearAllHistory = async () => { + try { + await apiClient.delete('/path-history'); + historyList.value = []; // 清空本地列表 + uiNotificationsStore.showSuccess('所有路径历史记录已清空'); + } catch (err: any) { + console.error('[PathHistoryStore] 清空路径历史记录失败:', err); + const message = err.response?.data?.message || '清空路径历史记录时发生错误'; + uiNotificationsStore.showError(message); + } + }; + + // 设置搜索词 + const setSearchTerm = (term: string) => { + searchTerm.value = term; + resetSelection(); // 搜索词变化时重置选中项 + }; + + return { + historyList, + searchTerm, + isLoading, + error, + filteredHistory, + selectedIndex, + fetchHistory, + addPath, + deletePath, + clearAllHistory, + setSearchTerm, + selectNextPath, + selectPreviousPath, + resetSelection, + }; +}); \ No newline at end of file