feat: 添加历史路径功能
This commit is contained in:
@@ -227,13 +227,12 @@ const definedMigrations: Migration[] = [
|
||||
ANALYZE; -- 重新分析数据库模式
|
||||
`
|
||||
},
|
||||
// --- 未来可以添加更多迁移 ---
|
||||
{
|
||||
id: 6,
|
||||
name: 'Create passkeys table for WebAuthn credentials',
|
||||
check: async (db: Database): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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'))
|
||||
);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -259,6 +260,7 @@ const startServer = () => {
|
||||
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/path-history', pathHistoryRoutes);
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 || '无法清空路径历史记录' });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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<number> => {
|
||||
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<PathHistoryEntry[]> => {
|
||||
const sql = `SELECT id, path, timestamp FROM path_history ORDER BY timestamp ASC`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const rows = await allDb<DbPathHistoryRow>(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<boolean> => {
|
||||
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<number> => {
|
||||
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('无法清空路径历史记录');
|
||||
}
|
||||
};
|
||||
@@ -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<number> => {
|
||||
// 可以在这里添加额外的业务逻辑,例如校验路径格式、长度限制等
|
||||
if (!path || path.trim().length === 0) {
|
||||
throw new Error('路径不能为空');
|
||||
}
|
||||
|
||||
// 调用 upsertPath 来处理插入或更新时间戳
|
||||
return PathHistoryRepository.upsertPath(path.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有路径历史记录
|
||||
* @returns 返回所有历史记录条目数组,按时间戳升序
|
||||
*/
|
||||
export const getAllPathHistory = async (): Promise<PathHistoryEntry[]> => {
|
||||
return PathHistoryRepository.getAllPaths();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 删除一条路径历史记录
|
||||
* @param id - 要删除的记录 ID
|
||||
* @returns 返回是否成功删除 (删除行数 > 0)
|
||||
*/
|
||||
export const deletePathHistoryById = async (id: number): Promise<boolean> => {
|
||||
const success = await PathHistoryRepository.deletePathById(id);
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有路径历史记录
|
||||
* @returns 返回删除的记录条数
|
||||
*/
|
||||
export const clearAllPathHistory = async (): Promise<number> => {
|
||||
return PathHistoryRepository.clearAllPaths();
|
||||
};
|
||||
@@ -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<typeof createSftpActionsManager>;
|
||||
@@ -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<HTMLDivElement | null>(null); // 文件列表
|
||||
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
|
||||
// const scrollIntervalId = ref<number | null>(null); // 已移至 useFileManagerDragAndDrop
|
||||
|
||||
// +++ Path History Refs +++
|
||||
const showPathHistoryDropdown = ref(false);
|
||||
const pathInputWrapperRef = ref<HTMLDivElement | null>(null); // Wrapper for path input and dropdown
|
||||
const pathHistoryDropdownRef = ref<InstanceType<typeof PathHistoryDropdown> | 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<boolean | undefined> => {
|
||||
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,6 +1133,7 @@ onMounted(() => {
|
||||
}
|
||||
};
|
||||
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
|
||||
document.addEventListener('click', handleClickOutsidePathInput);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1138,6 +1150,7 @@ onBeforeUnmount(() => {
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered path edit focus action on unmount.`);
|
||||
}
|
||||
unregisterPathFocusAction = null;
|
||||
document.removeEventListener('click', handleClickOutsidePathInput);
|
||||
// // 调用注入的 SFTP 管理器提供的清理函数 (移除,由 store 处理)
|
||||
// cleanupSftpHandlers();
|
||||
// 调用 store 的清理方法
|
||||
@@ -1214,38 +1227,180 @@ const stopResize = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- 路径编辑逻辑 ---
|
||||
const startPathEdit = () => {
|
||||
// 修改:检查 currentSftpManager 是否存在并使用其状态
|
||||
// --- 路径编辑逻辑 (包含路径历史) ---
|
||||
|
||||
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 = () => {
|
||||
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') {
|
||||
// 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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End Path Actions -->
|
||||
<!-- Path Bar -->
|
||||
<div class="flex items-center bg-background border border-border rounded px-1.5 py-0.5 overflow-hidden min-w-[100px] flex-shrink">
|
||||
<span v-show="!isEditingPath" class="text-text-secondary whitespace-nowrap overflow-x-auto pr-2">
|
||||
<!-- Path Bar with History Dropdown -->
|
||||
<div ref="pathInputWrapperRef" class="relative flex items-center bg-background border border-border rounded px-1.5 py-0.5 min-w-[100px] flex-shrink">
|
||||
<span v-show="!isEditingPath && !showPathHistoryDropdown" @click="startPathEdit" class="text-text-secondary whitespace-nowrap overflow-x-auto pr-2 cursor-text">
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.currentPath') }}:</span>
|
||||
<strong
|
||||
@click="startPathEdit"
|
||||
:title="t('fileManager.editPathTooltip')"
|
||||
class="font-medium text-link ml-1 px-1 rounded cursor-text transition-colors duration-200"
|
||||
class="font-medium text-link ml-1 px-1 rounded transition-colors duration-200"
|
||||
:class="{
|
||||
'hover:bg-black/5': currentSftpManager && props.wsDeps.isConnected.value,
|
||||
'opacity-60 cursor-not-allowed': !currentSftpManager || !props.wsDeps.isConnected.value
|
||||
@@ -1477,15 +1631,23 @@ const handleOpenEditorClick = () => {
|
||||
</strong>
|
||||
</span>
|
||||
<input
|
||||
v-show="isEditingPath"
|
||||
v-show="isEditingPath || showPathHistoryDropdown"
|
||||
ref="pathInputRef"
|
||||
type="text"
|
||||
v-model="editablePath"
|
||||
class="flex-grow bg-transparent text-foreground p-0.5 outline-none min-w-[100px]"
|
||||
data-focus-id="fileManagerPathInput"
|
||||
@keyup.enter="handlePathInput"
|
||||
@focus="handlePathInputFocus"
|
||||
@input="handlePathInputChange"
|
||||
@keydown="handlePathInputKeydown"
|
||||
@blur="handlePathInput"
|
||||
@keyup.esc="cancelPathEdit"
|
||||
/>
|
||||
<PathHistoryDropdown
|
||||
v-if="showPathHistoryDropdown"
|
||||
ref="pathHistoryDropdownRef"
|
||||
@pathSelected="handlePathSelectedFromDropdown"
|
||||
@closeDropdown="closePathHistory"
|
||||
class="left-0 right-0 top-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div> <!-- End Wrapper -->
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="path-history-dropdown absolute z-40 w-full rounded-md bg-background shadow-lg border border-border/50 max-h-60 overflow-y-auto text-sm">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading && filteredHistory.length === 0" class="p-3 text-center text-text-secondary">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
{{ $t('pathHistory.loading', '加载中...') }}
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredHistory.length === 0" class="p-3 text-center text-text-secondary">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
{{ $t('pathHistory.empty', '没有路径历史记录') }}
|
||||
</div>
|
||||
<!-- History List -->
|
||||
<ul v-else ref="historyListRef" class="list-none p-1 m-0">
|
||||
<li
|
||||
v-for="(entry, index) in filteredHistory"
|
||||
:key="entry.id"
|
||||
:ref="el => { if (el) itemRefs[index] = el as HTMLLIElement }"
|
||||
class="group flex justify-between items-center px-3 py-0.1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
|
||||
:class="{ 'bg-primary/20 font-medium text-primary-foreground': index === storeSelectedIndex }"
|
||||
@click="handleItemClick(entry.path)"
|
||||
@mouseenter="hoveredItemId = entry.id"
|
||||
@mouseleave="hoveredItemId = null"
|
||||
:title="entry.path"
|
||||
>
|
||||
<!-- Path Text -->
|
||||
<span class="truncate mr-2 flex-grow font-mono text-sm text-foreground">{{ entry.path }}</span>
|
||||
<!-- Actions (Show on Hover) -->
|
||||
<div
|
||||
class="flex items-center flex-shrink-0 transition-opacity duration-150"
|
||||
:class="{ 'opacity-100': hoveredItemId === entry.id || isTouchDevice, 'opacity-0 group-hover:opacity-100 focus-within:opacity-100': !isTouchDevice }"
|
||||
>
|
||||
<!-- Copy Button -->
|
||||
<button
|
||||
@click.stop="copyPathToClipboard(entry.path)"
|
||||
class="p-1.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors duration-150 text-text-secondary hover:text-primary"
|
||||
:title="$t('pathHistory.copy', '复制路径')"
|
||||
>
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
@click.stop="deleteHistoryEntry(entry.id)"
|
||||
class="ml-1 p-1.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors duration-150 text-text-secondary hover:text-error"
|
||||
:title="$t('pathHistory.delete', '删除此条历史')"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { usePathHistoryStore, PathHistoryEntryFE } from '../stores/pathHistory.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const pathHistoryStore = usePathHistoryStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits(['pathSelected', 'closeDropdown']);
|
||||
|
||||
// --- Store State and Getters ---
|
||||
const {
|
||||
filteredHistory,
|
||||
isLoading,
|
||||
selectedIndex: storeSelectedIndex,
|
||||
} = storeToRefs(pathHistoryStore);
|
||||
|
||||
const historyListRef = ref<HTMLUListElement | null>(null);
|
||||
const itemRefs = ref<HTMLLIElement[]>([]);
|
||||
const hoveredItemId = ref<number | null>(null);
|
||||
const isTouchDevice = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isTouchDevice.value = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
// Reset itemRefs before each update to avoid stale references
|
||||
watch(filteredHistory, () => {
|
||||
itemRefs.value = [];
|
||||
}, { flush: 'pre' });
|
||||
});
|
||||
|
||||
|
||||
// --- Actions ---
|
||||
const handleItemClick = (path: string) => {
|
||||
emit('pathSelected', path);
|
||||
// No need to call addPath here, parent component will handle it after navigation
|
||||
};
|
||||
|
||||
const copyPathToClipboard = async (path: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
uiNotificationsStore.showSuccess(t('pathHistory.copiedSuccess', '路径已复制到剪贴板'));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy path:', err);
|
||||
uiNotificationsStore.showError(t('pathHistory.copiedError', '复制路径失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHistoryEntry = (id: number) => {
|
||||
pathHistoryStore.deletePath(id);
|
||||
};
|
||||
|
||||
// --- Scroll to Selected Item ---
|
||||
const scrollToSelected = async () => {
|
||||
await nextTick();
|
||||
if (storeSelectedIndex.value < 0 || !historyListRef.value || !itemRefs.value[storeSelectedIndex.value]) return;
|
||||
|
||||
const selectedItem = itemRefs.value[storeSelectedIndex.value];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes in the store's selectedIndex and scroll
|
||||
watch(storeSelectedIndex, () => {
|
||||
scrollToSelected();
|
||||
});
|
||||
|
||||
// Expose for parent component to call if needed, e.g., when dropdown opens
|
||||
defineExpose({
|
||||
scrollToSelected,
|
||||
focusList: () => {
|
||||
historyListRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.path-history-dropdown {
|
||||
/* Ensures dropdown appears above other elements */
|
||||
/* Further styling can be added if needed */
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1465,5 +1465,13 @@
|
||||
"transferInitiated": "転送タスクが作成されました",
|
||||
"transferInitiatedGeneric": "転送タスクが正常に作成されました。",
|
||||
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"pathHistory": {
|
||||
"loading": "読み込み中...",
|
||||
"empty": "パス履歴がありません",
|
||||
"copy": "パスをコピー",
|
||||
"delete": "この履歴を削除",
|
||||
"copiedSuccess": "パスがクリップボードにコピーされました",
|
||||
"copiedError": "パスのコピーに失敗しました"
|
||||
}
|
||||
}
|
||||
@@ -1508,5 +1508,13 @@
|
||||
"logExportSuccess": "已挂起会话日志 {name} 已开始下载。",
|
||||
"logExportError": "导出已挂起会话日志失败: {error}"
|
||||
}
|
||||
},
|
||||
"pathHistory": {
|
||||
"loading": "加载中...",
|
||||
"empty": "没有路径历史记录",
|
||||
"copy": "复制路径",
|
||||
"delete": "删除此条历史",
|
||||
"copiedSuccess": "路径已复制到剪贴板",
|
||||
"copiedError": "复制路径失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathHistoryEntryFE[]>([]);
|
||||
const searchTerm = ref('');
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const selectedIndex = ref<number>(-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<PathHistoryEntryBE[]>('/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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user