refactor: 重构前端
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { ref, readonly, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// 移除对 useSftpActions 的直接导入,因为方法是注入的
|
||||
// import { useSftpActions } from './useSftpActions';
|
||||
// 从类型文件导入所需类型
|
||||
import type { EditorFileContent, SaveStatus } from '../types/sftp.types';
|
||||
|
||||
// --- 类型定义 (已移至 sftp.types.ts) ---
|
||||
// export type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||
// export interface EditorFileContent { ... }
|
||||
|
||||
// 辅助函数:根据文件名获取语言 (从 FileManager.vue 迁移)
|
||||
const getLanguageFromFilename = (filename: string): string => {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'js': return 'javascript';
|
||||
case 'ts': return 'typescript';
|
||||
case 'json': return 'json';
|
||||
case 'html': return 'html';
|
||||
case 'css': return 'css';
|
||||
case 'scss': return 'scss';
|
||||
case 'less': return 'less';
|
||||
case 'py': return 'python';
|
||||
case 'java': return 'java';
|
||||
case 'c': return 'c';
|
||||
case 'cpp': return 'cpp';
|
||||
case 'cs': return 'csharp';
|
||||
case 'go': return 'go';
|
||||
case 'php': return 'php';
|
||||
case 'rb': return 'ruby';
|
||||
case 'rs': return 'rust';
|
||||
case 'sql': return 'sql';
|
||||
case 'sh': return 'shell';
|
||||
case 'yaml': case 'yml': return 'yaml';
|
||||
case 'md': return 'markdown';
|
||||
case 'xml': return 'xml';
|
||||
case 'ini': return 'ini';
|
||||
case 'bat': return 'bat';
|
||||
case 'dockerfile': return 'dockerfile';
|
||||
default: return 'plaintext';
|
||||
}
|
||||
};
|
||||
|
||||
export function useFileEditor(
|
||||
// 注入依赖:需要 SFTP 操作模块提供的读写文件方法
|
||||
sftpReadFile: (path: string) => Promise<EditorFileContent>,
|
||||
sftpWriteFile: (path: string, content: string) => Promise<void>
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- 编辑器状态 ---
|
||||
const isEditorVisible = ref(false);
|
||||
const editingFilePath = ref<string | null>(null);
|
||||
const editingFileContent = ref<string>(''); // 用于 v-model 绑定
|
||||
const editingFileLanguage = ref<string>('plaintext');
|
||||
const editingFileEncoding = ref<'utf8' | 'base64'>('utf8'); // 文件内容的原始编码
|
||||
const isEditorLoading = ref<boolean>(false);
|
||||
const editorError = ref<string | null>(null);
|
||||
const isSaving = ref<boolean>(false);
|
||||
const saveStatus = ref<SaveStatus>('idle');
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
// --- 方法 ---
|
||||
|
||||
const openFile = async (filePath: string) => {
|
||||
console.log(`[文件编辑器模块] 尝试打开文件: ${filePath}`);
|
||||
if (!filePath) return;
|
||||
|
||||
// 如果已经是同一个文件,则不重新加载(除非需要强制刷新)
|
||||
// if (editingFilePath.value === filePath && isEditorVisible.value) {
|
||||
// console.log(`[文件编辑器模块] 文件 ${filePath} 已在编辑器中打开。`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
isEditorVisible.value = true; // 显示编辑器区域
|
||||
isEditorLoading.value = true; // 显示加载状态
|
||||
editorError.value = null;
|
||||
saveStatus.value = 'idle'; // 重置保存状态
|
||||
saveError.value = null;
|
||||
editingFilePath.value = filePath;
|
||||
editingFileLanguage.value = getLanguageFromFilename(filePath);
|
||||
editingFileContent.value = ''; // 清空旧内容
|
||||
|
||||
try {
|
||||
const fileData = await sftpReadFile(filePath); // 调用注入的 readFile 方法
|
||||
console.log(`[文件编辑器模块] 文件 ${filePath} 读取成功。编码: ${fileData.encoding}`);
|
||||
|
||||
// 处理可能的 Base64 编码
|
||||
if (fileData.encoding === 'base64') {
|
||||
try {
|
||||
editingFileContent.value = atob(fileData.content); // 解码
|
||||
editingFileEncoding.value = 'base64'; // 记录原始编码
|
||||
} catch (decodeError) {
|
||||
console.error(`[文件编辑器模块] Base64 解码错误 for ${filePath}:`, decodeError);
|
||||
editorError.value = t('fileManager.errors.fileDecodeError');
|
||||
editingFileContent.value = `// ${t('fileManager.errors.fileDecodeError')}\n${fileData.content}`; // 显示原始 Base64 作为后备
|
||||
}
|
||||
} else {
|
||||
editingFileContent.value = fileData.content;
|
||||
editingFileEncoding.value = 'utf8';
|
||||
}
|
||||
isEditorLoading.value = false;
|
||||
} catch (err: any) {
|
||||
console.error(`[文件编辑器模块] 读取文件 ${filePath} 失败:`, err);
|
||||
editorError.value = `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`;
|
||||
editingFileContent.value = `// ${editorError.value}`; // 在编辑器中显示错误
|
||||
isEditorLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!editingFilePath.value || isSaving.value || isEditorLoading.value || editorError.value) {
|
||||
console.warn('[文件编辑器模块] 保存条件不满足,无法保存。', {
|
||||
path: editingFilePath.value,
|
||||
isSaving: isSaving.value,
|
||||
isLoading: isEditorLoading.value,
|
||||
hasError: !!editorError.value
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[文件编辑器模块] 开始保存文件: ${editingFilePath.value}`);
|
||||
isSaving.value = true;
|
||||
saveStatus.value = 'saving';
|
||||
saveError.value = null;
|
||||
|
||||
const contentToSave = editingFileContent.value; // 获取当前编辑器内容
|
||||
|
||||
try {
|
||||
await sftpWriteFile(editingFilePath.value, contentToSave); // 调用注入的 writeFile 方法
|
||||
console.log(`[文件编辑器模块] 文件 ${editingFilePath.value} 保存成功。`);
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'success';
|
||||
saveError.value = null;
|
||||
|
||||
// 成功提示短暂显示后消失
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'success') {
|
||||
saveStatus.value = 'idle';
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[文件编辑器模块] 保存文件 ${editingFilePath.value} 失败:`, err);
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'error';
|
||||
saveError.value = `${t('fileManager.errors.saveFailed')}: ${err.message || err}`;
|
||||
|
||||
// 错误提示显示时间长一些
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'error') {
|
||||
saveStatus.value = 'idle';
|
||||
saveError.value = null;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
console.log('[文件编辑器模块] 关闭编辑器。');
|
||||
isEditorVisible.value = false;
|
||||
editingFilePath.value = null;
|
||||
editingFileContent.value = '';
|
||||
editorError.value = null;
|
||||
isEditorLoading.value = false;
|
||||
saveStatus.value = 'idle';
|
||||
saveError.value = null;
|
||||
isSaving.value = false;
|
||||
};
|
||||
|
||||
// 提供一个方法来更新内容,主要用于 v-model
|
||||
const updateContent = (newContent: string) => {
|
||||
editingFileContent.value = newContent;
|
||||
// 当用户编辑时,可以重置保存状态(如果需要)
|
||||
if (saveStatus.value === 'success' || saveStatus.value === 'error') {
|
||||
saveStatus.value = 'idle';
|
||||
saveError.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 注意:这个 composable 不直接处理 WebSocket 消息,
|
||||
// 它依赖注入的 sftpReadFile 和 sftpWriteFile 函数,
|
||||
// 这些函数(在 useSftpActions 中实现)内部处理了相应的 WebSocket 消息和请求/响应逻辑。
|
||||
|
||||
return {
|
||||
// 状态 (只读的 ref)
|
||||
isEditorVisible: readonly(isEditorVisible),
|
||||
editingFilePath: readonly(editingFilePath),
|
||||
editingFileLanguage: readonly(editingFileLanguage),
|
||||
isEditorLoading: readonly(isEditorLoading),
|
||||
editorError: readonly(editorError),
|
||||
isSaving: readonly(isSaving),
|
||||
saveStatus: readonly(saveStatus),
|
||||
saveError: readonly(saveError),
|
||||
|
||||
// 可写状态 (用于 v-model)
|
||||
editingFileContent, // 直接暴露 ref 用于 v-model
|
||||
|
||||
// 方法
|
||||
openFile,
|
||||
saveFile,
|
||||
closeEditor,
|
||||
updateContent, // 如果需要从外部更新内容
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { ref, reactive, nextTick, onUnmounted, type Ref } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入
|
||||
import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
|
||||
|
||||
// --- 接口定义 (已移至 upload.types.ts) ---
|
||||
|
||||
// 辅助函数 (从 FileManager.vue 复制)
|
||||
const generateUploadId = (): string => {
|
||||
// 如果需要,可以使用稍微不同的格式作为上传 ID
|
||||
return `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
// 辅助函数 (从 FileManager.vue 复制)
|
||||
const joinPath = (base: string, name: string): string => {
|
||||
if (base === '/') return `/${name}`;
|
||||
if (base.endsWith('/')) return `${base}${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
|
||||
export function useFileUploader(
|
||||
currentPathRef: Ref<string>,
|
||||
fileListRef: Ref<Readonly<FileListItem[]>>, // 传入 fileList 用于检查覆盖
|
||||
refreshDirectory: () => void // 上传成功后刷新目录的回调函数
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
|
||||
|
||||
// 对 uploads 字典使用 reactive 以获得更好的深度响应性
|
||||
const uploads = reactive<Record<string, UploadItem>>({});
|
||||
|
||||
// --- 上传逻辑 ---
|
||||
|
||||
const sendFileChunks = (uploadId: string, file: File, startByte = 0) => {
|
||||
const upload = uploads[uploadId];
|
||||
// 在继续之前检查连接和上传状态
|
||||
if (!isConnected.value || !upload || upload.status !== 'uploading') {
|
||||
console.warn(`[文件上传模块] 无法为 ${uploadId} 发送块。连接状态: ${isConnected.value}, 上传状态: ${upload?.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = 1024 * 64; // 64KB 块大小
|
||||
const reader = new FileReader();
|
||||
let offset = startByte;
|
||||
let chunkIndex = 0; // Initialize chunk index counter
|
||||
|
||||
reader.onload = (e) => {
|
||||
const currentUpload = uploads[uploadId];
|
||||
// *发送前* 再次检查连接和状态
|
||||
if (!isConnected.value || !currentUpload || currentUpload.status !== 'uploading') {
|
||||
console.warn(`[文件上传模块] 上传 ${uploadId} 在发送偏移量 ${offset} 的块之前状态已更改或连接已断开。`);
|
||||
return; // 如果状态改变或断开连接,则停止发送
|
||||
}
|
||||
|
||||
const chunkResult = e.target?.result as string;
|
||||
// 确保结果是字符串并且包含 base64 前缀
|
||||
if (typeof chunkResult === 'string' && chunkResult.startsWith('data:')) {
|
||||
const chunkBase64 = chunkResult.split(',')[1];
|
||||
const isLast = offset + chunkSize >= file.size;
|
||||
|
||||
sendMessage({
|
||||
type: 'sftp:upload:chunk',
|
||||
payload: { uploadId, chunkIndex: chunkIndex++, data: chunkBase64, isLast } // Add and increment chunkIndex
|
||||
});
|
||||
|
||||
// 注意:直接使用 base64 长度估算字节大小并不完全准确,但对于进度条来说足够了
|
||||
offset += chunkBase64.length * 3 / 4;
|
||||
currentUpload.progress = Math.min(100, Math.round((offset / file.size) * 100));
|
||||
|
||||
if (!isLast) {
|
||||
// 使用 requestAnimationFrame 或 nextTick 在块之间添加轻微延迟
|
||||
// 以潜在地改善 UI 响应性并减少负载。
|
||||
nextTick(readNextChunk);
|
||||
} else {
|
||||
console.log(`[文件上传模块] 已发送 ${uploadId} 的最后一个块`);
|
||||
// 后端将在收到最后一个块后发送 sftp:upload:success
|
||||
}
|
||||
} else {
|
||||
console.error(`[文件上传模块] FileReader 为 ${uploadId} 返回了意外结果:`, chunkResult);
|
||||
// 处理错误:更新上传状态,也许重试?
|
||||
currentUpload.status = 'error';
|
||||
currentUpload.error = t('fileManager.errors.readFileError');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error(`[文件上传模块] FileReader 错误,上传 ID: ${uploadId}`);
|
||||
const failedUpload = uploads[uploadId];
|
||||
if (failedUpload) {
|
||||
failedUpload.status = 'error';
|
||||
failedUpload.error = t('fileManager.errors.readFileError');
|
||||
}
|
||||
};
|
||||
|
||||
const readNextChunk = () => {
|
||||
// 读取下一个块之前再次检查状态
|
||||
if (offset < file.size && uploads[uploadId]?.status === 'uploading') {
|
||||
const slice = file.slice(offset, offset + chunkSize);
|
||||
reader.readAsDataURL(slice);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始读取第一个块(或恢复时的下一个块)
|
||||
if (file.size > 0) {
|
||||
readNextChunk();
|
||||
} else {
|
||||
// 立即处理零字节文件
|
||||
console.log(`[文件上传模块] 处理零字节文件 ${uploadId}`);
|
||||
// Send chunkIndex 0 for zero-byte file
|
||||
sendMessage({ type: 'sftp:upload:chunk', payload: { uploadId, chunkIndex: 0, data: '', isLast: true } });
|
||||
upload.progress = 100;
|
||||
// Backend should send success message shortly after this
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const startFileUpload = (file: File) => {
|
||||
if (!isConnected.value) {
|
||||
console.warn('[文件上传模块] 无法开始上传:WebSocket 未连接。');
|
||||
// 可以选择向用户显示错误消息
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadId = generateUploadId();
|
||||
const remotePath = joinPath(currentPathRef.value, file.name);
|
||||
|
||||
// 使用传入的 fileListRef 检查是否覆盖
|
||||
// 为 item 添加显式类型 FileListItem
|
||||
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) {
|
||||
if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) {
|
||||
console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`);
|
||||
return; // 用户取消覆盖
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到响应式 uploads 字典
|
||||
uploads[uploadId] = {
|
||||
id: uploadId,
|
||||
file,
|
||||
filename: file.name,
|
||||
progress: 0,
|
||||
status: 'pending' // 初始状态
|
||||
};
|
||||
|
||||
console.log(`[文件上传模块] 开始上传 ${uploadId} 到 ${remotePath}`);
|
||||
sendMessage({
|
||||
type: 'sftp:upload:start',
|
||||
payload: { uploadId, remotePath, size: file.size }
|
||||
});
|
||||
// 后端应该响应 sftp:upload:ready
|
||||
};
|
||||
|
||||
const cancelUpload = (uploadId: string, notifyBackend = true) => {
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && ['pending', 'uploading', 'paused'].includes(upload.status)) {
|
||||
console.log(`[文件上传模块] 取消上传 ${uploadId}`);
|
||||
upload.status = 'cancelled'; // 立即更新状态
|
||||
|
||||
if (notifyBackend && isConnected.value) {
|
||||
sendMessage({ type: 'sftp:upload:cancel', payload: { uploadId } });
|
||||
}
|
||||
|
||||
// 短暂延迟后从列表中移除,以显示取消状态
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'cancelled') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 消息处理器 ---
|
||||
|
||||
const onUploadReady = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
if (!uploadId) return;
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'pending') {
|
||||
console.log(`[文件上传模块] 上传 ${uploadId} 已就绪,开始发送块。`);
|
||||
upload.status = 'uploading';
|
||||
sendFileChunks(uploadId, upload.file); // 开始发送块
|
||||
} else {
|
||||
console.warn(`[文件上传模块] 收到未知或非待处理状态的上传 ID 的 upload:ready 消息: ${uploadId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
if (!uploadId) return;
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
console.log(`[文件上传模块] 上传 ${uploadId} 成功`);
|
||||
upload.status = 'success';
|
||||
upload.progress = 100;
|
||||
|
||||
// 使用回调刷新目录
|
||||
refreshDirectory();
|
||||
|
||||
// 延迟后从列表中移除
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'success') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 2000); // 成功状态显示时间短一些
|
||||
} else {
|
||||
console.warn(`[文件上传模块] 收到未知上传 ID 的 upload:success 消息: ${uploadId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 从 message 中获取 uploadId,因为 payload 此时是错误字符串
|
||||
const uploadId = message.uploadId;
|
||||
if (!uploadId) {
|
||||
console.warn(`[文件上传模块] 收到缺少 uploadId 的 upload:error 消息:`, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
const errorMessage = typeof payload === 'string' ? payload : t('fileManager.errors.uploadFailed');
|
||||
console.error(`[文件上传模块] 上传 ${uploadId} 出错:`, errorMessage);
|
||||
upload.status = 'error';
|
||||
upload.error = errorMessage; // 使用 payload 作为错误消息
|
||||
|
||||
// 让错误消息可见时间长一些
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'error') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 5000);
|
||||
} else {
|
||||
console.warn(`[文件上传模块] 收到未知上传 ID 的 upload:error 消息: ${uploadId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadPause = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'uploading') {
|
||||
console.log(`[文件上传模块] 上传 ${uploadId} 已暂停`);
|
||||
upload.status = 'paused';
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadResume = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'paused') {
|
||||
console.log(`[文件上传模块] 恢复上传 ${uploadId}`);
|
||||
upload.status = 'uploading';
|
||||
// 恢复发送块(后端应该告知从哪里恢复,
|
||||
// 但现在假设我们重新开始或后端处理了它)
|
||||
// 更健壮的实现需要后端发送最后接收到的字节偏移量。
|
||||
sendFileChunks(uploadId, upload.file); // 为简单起见,现在重新开始发送块
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadCancelled = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
console.log(`[文件上传模块] 后端确认上传 ${uploadId} 已取消。`);
|
||||
// 状态可能已经由用户操作设置为 'cancelled'
|
||||
if (upload.status !== 'cancelled') {
|
||||
upload.status = 'cancelled';
|
||||
}
|
||||
// 确保它会被移除(如果尚未计划移除)
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'cancelled') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 注册处理器 ---
|
||||
const unregisterUploadReady = onMessage('sftp:upload:ready', onUploadReady);
|
||||
const unregisterUploadSuccess = onMessage('sftp:upload:success', onUploadSuccess);
|
||||
const unregisterUploadError = onMessage('sftp:upload:error', onUploadError);
|
||||
const unregisterUploadPause = onMessage('sftp:upload:pause', onUploadPause);
|
||||
const unregisterUploadResume = onMessage('sftp:upload:resume', onUploadResume);
|
||||
const unregisterUploadCancelled = onMessage('sftp:upload:cancelled', onUploadCancelled);
|
||||
|
||||
// --- 清理 ---
|
||||
onUnmounted(() => {
|
||||
console.log('[文件上传模块] 卸载并注销处理器。');
|
||||
unregisterUploadReady?.();
|
||||
unregisterUploadSuccess?.();
|
||||
unregisterUploadError?.();
|
||||
unregisterUploadPause?.();
|
||||
unregisterUploadResume?.();
|
||||
unregisterUploadCancelled?.();
|
||||
|
||||
// 当使用此 composable 的组件卸载时,取消任何正在进行的上传
|
||||
Object.keys(uploads).forEach(uploadId => {
|
||||
cancelUpload(uploadId, true); // 卸载时通知后端
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
uploads, // 暴露响应式字典
|
||||
startFileUpload,
|
||||
cancelUpload,
|
||||
// 如果拖放/选择处理程序要在这里管理,则暴露它们,
|
||||
// 或者将它们保留在组件中并调用 startFileUpload。
|
||||
// 为简单起见,假设组件处理 UI 事件
|
||||
// 并为每个文件调用 startFileUpload(file)。
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { ref, readonly, type Ref, onUnmounted } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// 确保从类型文件导入所有需要的类型
|
||||
import type { FileListItem, FileAttributes, EditorFileContent } from '../types/sftp.types';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
|
||||
|
||||
// --- 接口定义 (已移至 sftp.types.ts) ---
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
const generateRequestId = (): string => {
|
||||
return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
const joinPath = (base: string, name: string): string => {
|
||||
if (base === '/') return `/${name}`;
|
||||
// Handle cases where base might end with '/' already
|
||||
if (base.endsWith('/')) return `${base}${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
const sortFiles = (a: FileListItem, b: FileListItem): number => {
|
||||
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
|
||||
if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
};
|
||||
|
||||
|
||||
export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
const { t } = useI18n();
|
||||
// Import isSftpReady along with other needed functions/state
|
||||
const { sendMessage, onMessage, isConnected, isSftpReady } = useWebSocketConnection();
|
||||
|
||||
const fileList = ref<FileListItem[]>([]);
|
||||
const isLoading = ref<boolean>(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// --- Action Methods ---
|
||||
|
||||
const loadDirectory = (path: string) => {
|
||||
// Check if SFTP is ready first
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady'); // Use a specific error message
|
||||
isLoading.value = false;
|
||||
fileList.value = []; // Clear list if not ready
|
||||
console.warn(`[useSftpActions] Attempted to load directory ${path} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
// Original isConnected check might still be relevant as a fallback, but isSftpReady implies isConnected
|
||||
// if (!isConnected.value) { ... } // Can likely be removed if isSftpReady logic is robust
|
||||
|
||||
console.log(`[useSftpActions] Loading directory: ${path}`);
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
currentPathRef.value = path; // Update the external ref passed in
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
|
||||
// Response handled by onSftpReaddirSuccess/Error
|
||||
};
|
||||
|
||||
const createDirectory = (newDirName: string) => {
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
console.warn(`[useSftpActions] Attempted to create directory ${newDirName} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
const newFolderPath = joinPath(currentPathRef.value, newDirName);
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({ type: 'sftp:mkdir', requestId: requestId, payload: { path: newFolderPath } });
|
||||
// Response handled by onSftpMkdirSuccess/Error
|
||||
};
|
||||
|
||||
const createFile = (newFileName: string) => {
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
console.warn(`[useSftpActions] Attempted to create file ${newFileName} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
const newFilePath = joinPath(currentPathRef.value, newFileName);
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({
|
||||
type: 'sftp:writefile',
|
||||
requestId: requestId,
|
||||
payload: { path: newFilePath, content: '', encoding: 'utf8' } // Create by writing empty content
|
||||
});
|
||||
// Response handled by onSftpWriteFileSuccess/Error (will trigger refresh)
|
||||
};
|
||||
|
||||
const deleteItems = (items: FileListItem[]) => {
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
console.warn(`[useSftpActions] Attempted to delete items but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return;
|
||||
items.forEach(item => {
|
||||
const targetPath = joinPath(currentPathRef.value, item.filename);
|
||||
const actionType = item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink';
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({ type: actionType, requestId: requestId, payload: { path: targetPath } });
|
||||
});
|
||||
// Responses handled by onSftpRmdirSuccess/Error, onSftpUnlinkSuccess/Error (will trigger refresh)
|
||||
};
|
||||
|
||||
const renameItem = (item: FileListItem, newName: string) => {
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
console.warn(`[useSftpActions] Attempted to rename item ${item.filename} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
if (!newName || item.filename === newName) return;
|
||||
const oldPath = joinPath(currentPathRef.value, item.filename);
|
||||
const newPath = joinPath(currentPathRef.value, newName);
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({ type: 'sftp:rename', requestId: requestId, payload: { oldPath, newPath } });
|
||||
// Response handled by onSftpRenameSuccess/Error (will trigger refresh)
|
||||
};
|
||||
|
||||
const changePermissions = (item: FileListItem, mode: number) => {
|
||||
if (!isSftpReady.value) {
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
console.warn(`[useSftpActions] Attempted to change permissions for ${item.filename} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
const targetPath = joinPath(currentPathRef.value, item.filename);
|
||||
const requestId = generateRequestId();
|
||||
sendMessage({ type: 'sftp:chmod', requestId: requestId, payload: { path: targetPath, mode: mode } });
|
||||
// Response handled by onSftpChmodSuccess/Error (will trigger refresh)
|
||||
};
|
||||
|
||||
// 注意: readFile 和 writeFile 的核心逻辑将由 useFileEditor 管理,
|
||||
// 但 useSftpActions 可以提供基础的发送/接收机制(如果其他地方需要),
|
||||
// 或者 useFileEditor 可以直接调用 sendMessage。暂时保留这些方法在这里。
|
||||
|
||||
const readFile = (path: string): Promise<EditorFileContent> => { // 使用导入的 EditorFileContent 类型
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSftpReady.value) {
|
||||
console.warn(`[useSftpActions] Attempted to read file ${path} but SFTP is not ready.`);
|
||||
return reject(new Error(t('fileManager.errors.sftpNotReady')));
|
||||
}
|
||||
const requestId = generateRequestId();
|
||||
|
||||
const unregisterSuccess = onMessage('sftp:readfile:success', (payload, message) => {
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
resolve({ content: payload.content, encoding: payload.encoding });
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterError = onMessage('sftp:readfile:error', (payload, message) => {
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(payload || 'Failed to read file'));
|
||||
}
|
||||
});
|
||||
|
||||
sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } });
|
||||
|
||||
// Timeout for the request
|
||||
setTimeout(() => {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(t('fileManager.errors.readFileTimeout')));
|
||||
}, 20000); // 20 second timeout
|
||||
});
|
||||
};
|
||||
|
||||
const writeFile = (path: string, content: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSftpReady.value) {
|
||||
console.warn(`[useSftpActions] Attempted to write file ${path} but SFTP is not ready.`);
|
||||
return reject(new Error(t('fileManager.errors.sftpNotReady')));
|
||||
}
|
||||
const requestId = generateRequestId();
|
||||
const encoding: 'utf8' | 'base64' = 'utf8'; // Assuming always sending utf8
|
||||
|
||||
const unregisterSuccess = onMessage('sftp:writefile:success', (payload, message) => {
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterError = onMessage('sftp:writefile:error', (payload, message) => {
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(payload || 'Failed to write file'));
|
||||
}
|
||||
});
|
||||
|
||||
sendMessage({
|
||||
type: 'sftp:writefile',
|
||||
requestId: requestId,
|
||||
payload: { path, content, encoding }
|
||||
});
|
||||
|
||||
// Timeout for the request
|
||||
setTimeout(() => {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(t('fileManager.errors.saveTimeout')));
|
||||
}, 20000); // 20 second timeout
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// --- Message Handlers ---
|
||||
|
||||
const onSftpReaddirSuccess = (payload: FileListItem[], message: WebSocketMessage) => {
|
||||
// Only update if the path matches the current path this composable instance is tracking
|
||||
if (message.path === currentPathRef.value) {
|
||||
console.log(`[useSftpActions] Received file list for ${message.path}`);
|
||||
fileList.value = payload.sort(sortFiles);
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
} else {
|
||||
console.log(`[useSftpActions] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
|
||||
}
|
||||
};
|
||||
|
||||
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => {
|
||||
if (message.path === currentPathRef.value) {
|
||||
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload);
|
||||
error.value = payload;
|
||||
isLoading.value = false;
|
||||
fileList.value = []; // Clear list on error
|
||||
}
|
||||
};
|
||||
|
||||
// Generic handler for actions that should trigger a refresh on success
|
||||
const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// Simplify: Always refresh the current directory on any relevant success action.
|
||||
// This avoids potential issues with path comparison logic.
|
||||
console.log(`[useSftpActions] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`);
|
||||
loadDirectory(currentPathRef.value); // Refresh the current directory
|
||||
error.value = null; // Clear previous errors on success
|
||||
};
|
||||
|
||||
// Generic handler for action errors
|
||||
const onActionError = (payload: string, message: WebSocketMessage) => {
|
||||
console.error(`[useSftpActions] Action ${message.type} failed:`, payload);
|
||||
// Display a generic error or use specific messages based on type
|
||||
const actionTypeMap: Record<string, string> = {
|
||||
'sftp:mkdir:error': t('fileManager.errors.createFolderFailed'),
|
||||
'sftp:rmdir:error': t('fileManager.errors.deleteFailed'),
|
||||
'sftp:unlink:error': t('fileManager.errors.deleteFailed'),
|
||||
'sftp:rename:error': t('fileManager.errors.renameFailed'),
|
||||
'sftp:chmod:error': t('fileManager.errors.chmodFailed'),
|
||||
'sftp:writefile:error': t('fileManager.errors.saveFailed'), // Added writefile error
|
||||
};
|
||||
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
|
||||
error.value = `${prefix}: ${payload}`;
|
||||
// Optionally stop loading indicator if one was active for this action
|
||||
};
|
||||
|
||||
// --- Register Handlers ---
|
||||
const unregisterReaddirSuccess = onMessage('sftp:readdir:success', onSftpReaddirSuccess);
|
||||
const unregisterReaddirError = onMessage('sftp:readdir:error', onSftpReaddirError);
|
||||
|
||||
// Register generic handlers for actions that trigger refresh on success
|
||||
const unregisterMkdirSuccess = onMessage('sftp:mkdir:success', onActionSuccessRefresh);
|
||||
const unregisterRmdirSuccess = onMessage('sftp:rmdir:success', onActionSuccessRefresh);
|
||||
const unregisterUnlinkSuccess = onMessage('sftp:unlink:success', onActionSuccessRefresh);
|
||||
const unregisterRenameSuccess = onMessage('sftp:rename:success', onActionSuccessRefresh);
|
||||
const unregisterChmodSuccess = onMessage('sftp:chmod:success', onActionSuccessRefresh);
|
||||
const unregisterWritefileSuccess = onMessage('sftp:writefile:success', onActionSuccessRefresh); // Refresh on successful write too
|
||||
|
||||
// Register generic error handlers
|
||||
const unregisterMkdirError = onMessage('sftp:mkdir:error', onActionError);
|
||||
const unregisterRmdirError = onMessage('sftp:rmdir:error', onActionError);
|
||||
const unregisterUnlinkError = onMessage('sftp:unlink:error', onActionError);
|
||||
const unregisterRenameError = onMessage('sftp:rename:error', onActionError);
|
||||
const unregisterChmodError = onMessage('sftp:chmod:error', onActionError);
|
||||
const unregisterWritefileError = onMessage('sftp:writefile:error', onActionError); // Handle writefile error display
|
||||
|
||||
// Unregister handlers when the composable's scope is destroyed
|
||||
onUnmounted(() => {
|
||||
console.log('[useSftpActions] Unmounting and unregistering handlers.');
|
||||
unregisterReaddirSuccess?.();
|
||||
unregisterReaddirError?.();
|
||||
unregisterMkdirSuccess?.();
|
||||
unregisterRmdirSuccess?.();
|
||||
unregisterUnlinkSuccess?.();
|
||||
unregisterRenameSuccess?.();
|
||||
unregisterChmodSuccess?.();
|
||||
unregisterWritefileSuccess?.();
|
||||
unregisterMkdirError?.();
|
||||
unregisterRmdirError?.();
|
||||
unregisterUnlinkError?.();
|
||||
unregisterRenameError?.();
|
||||
unregisterChmodError?.();
|
||||
unregisterWritefileError?.();
|
||||
// Note: readFile/writeFile promise handlers are unregistered within the promise logic
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
fileList: readonly(fileList),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
// currentPath: readonly(currentPath), // Path is managed via the passed ref
|
||||
|
||||
// Methods
|
||||
loadDirectory,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteItems,
|
||||
renameItem,
|
||||
changePermissions,
|
||||
readFile, // Expose if needed by editor composable
|
||||
writeFile, // Expose if needed by editor composable
|
||||
joinPath, // Expose helper if needed externally
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ref, onUnmounted, type Ref } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook 本身
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { Terminal } from 'xterm';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
|
||||
|
||||
export function useSshTerminal() {
|
||||
const { t } = useI18n();
|
||||
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null);
|
||||
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
|
||||
// 辅助函数:获取终端消息文本
|
||||
const getTerminalText = (key: string, params?: Record<string, any>): string => {
|
||||
// 确保 i18n key 存在,否则返回原始 key
|
||||
const translationKey = `workspace.terminal.${key}`;
|
||||
const translated = t(translationKey, params || {});
|
||||
return translated === translationKey ? key : translated;
|
||||
};
|
||||
|
||||
// --- 终端事件处理 ---
|
||||
|
||||
const handleTerminalReady = (term: Terminal) => {
|
||||
console.log('[SSH终端模块] 终端实例已就绪。');
|
||||
terminalInstance.value = term;
|
||||
// 将缓冲区的输出写入终端
|
||||
terminalOutputBuffer.value.forEach(data => term.write(data));
|
||||
terminalOutputBuffer.value = []; // 清空缓冲区
|
||||
// 可以在这里自动聚焦或执行其他初始化操作
|
||||
// term.focus(); // 也许在 ssh:connected 时聚焦更好
|
||||
};
|
||||
|
||||
const handleTerminalData = (data: string) => {
|
||||
// console.debug('[SSH终端模块] 接收到终端输入:', data);
|
||||
sendMessage({ type: 'ssh:input', payload: { data } });
|
||||
};
|
||||
|
||||
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
console.log('[SSH终端模块] 发送终端大小调整:', dimensions);
|
||||
sendMessage({ type: 'ssh:resize', payload: dimensions });
|
||||
};
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
|
||||
const handleSshOutput = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
let outputData = payload;
|
||||
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
|
||||
if (message.encoding === 'base64' && typeof outputData === 'string') {
|
||||
try {
|
||||
outputData = atob(outputData); // 在浏览器环境中使用 atob
|
||||
} catch (e) {
|
||||
console.error('[SSH终端模块] Base64 解码失败:', e, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
|
||||
}
|
||||
}
|
||||
// 如果不是 base64 或解码失败,确保它是字符串
|
||||
else if (typeof outputData !== 'string') {
|
||||
console.warn('[SSH终端模块] 收到非字符串 ssh:output payload:', outputData);
|
||||
try {
|
||||
outputData = JSON.stringify(outputData); // 尝试序列化
|
||||
} catch {
|
||||
outputData = String(outputData); // 最后手段:强制转字符串
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalInstance.value) {
|
||||
terminalInstance.value.write(outputData);
|
||||
} else {
|
||||
// 如果终端还没准备好,先缓冲输出
|
||||
terminalOutputBuffer.value.push(outputData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshConnected = () => {
|
||||
console.log('[SSH终端模块] SSH 会话已连接。');
|
||||
// 连接成功后聚焦终端
|
||||
terminalInstance.value?.focus();
|
||||
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
|
||||
if (terminalOutputBuffer.value.length > 0) {
|
||||
console.warn('[SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...');
|
||||
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
|
||||
terminalOutputBuffer.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshDisconnected = (payload: MessagePayload) => {
|
||||
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
|
||||
console.log('[SSH终端模块] SSH 会话已断开:', reason);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
||||
// 可以在这里添加其他清理逻辑,例如禁用输入
|
||||
};
|
||||
|
||||
const handleSshError = (payload: MessagePayload) => {
|
||||
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
|
||||
console.error('[SSH终端模块] SSH 错误:', errorMsg);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleSshStatus = (payload: MessagePayload) => {
|
||||
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
|
||||
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
|
||||
const statusKey = payload?.key || 'unknown';
|
||||
const statusParams = payload?.params || {};
|
||||
console.log('[SSH终端模块] 收到 SSH 状态更新:', statusKey, statusParams);
|
||||
// 可以在终端打印一些状态信息吗?
|
||||
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleInfoMessage = (payload: MessagePayload) => {
|
||||
console.log('[SSH终端模块] 收到后端信息:', payload);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleErrorMessage = (payload: MessagePayload) => {
|
||||
// 通用错误也可能需要显示在终端
|
||||
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
|
||||
console.error('[SSH终端模块] 收到后端通用错误:', errorMsg);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
|
||||
};
|
||||
|
||||
|
||||
// --- 注册 WebSocket 消息处理器 ---
|
||||
const unregisterHandlers: (() => void)[] = [];
|
||||
|
||||
const registerSshHandlers = () => {
|
||||
unregisterHandlers.push(onMessage('ssh:output', handleSshOutput));
|
||||
unregisterHandlers.push(onMessage('ssh:connected', handleSshConnected));
|
||||
unregisterHandlers.push(onMessage('ssh:disconnected', handleSshDisconnected));
|
||||
unregisterHandlers.push(onMessage('ssh:error', handleSshError));
|
||||
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
|
||||
unregisterHandlers.push(onMessage('info', handleInfoMessage));
|
||||
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
|
||||
console.log('[SSH终端模块] 已注册 SSH 相关消息处理器。');
|
||||
};
|
||||
|
||||
const unregisterAllSshHandlers = () => {
|
||||
console.log('[SSH终端模块] 注销 SSH 相关消息处理器...');
|
||||
unregisterHandlers.forEach(unregister => unregister?.());
|
||||
unregisterHandlers.length = 0; // 清空数组
|
||||
};
|
||||
|
||||
// --- 清理 ---
|
||||
onUnmounted(() => {
|
||||
unregisterAllSshHandlers();
|
||||
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
|
||||
terminalInstance.value = null;
|
||||
console.log('[SSH终端模块] Composable 已卸载。');
|
||||
});
|
||||
|
||||
// --- 暴露给组件的接口 ---
|
||||
return {
|
||||
terminalInstance, // 暴露终端实例 ref,以便组件可以访问(如果需要)
|
||||
handleTerminalReady,
|
||||
handleTerminalData,
|
||||
handleTerminalResize,
|
||||
registerSshHandlers, // 暴露注册函数,由父组件在连接后调用
|
||||
unregisterAllSshHandlers, // 暴露注销函数,在断开或卸载时调用
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ref, readonly, onUnmounted } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
|
||||
import type { ServerStatus } from '../types/server.types'; // 从类型文件导入
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
|
||||
|
||||
// --- 接口定义 (已移至 server.types.ts) ---
|
||||
|
||||
export function useStatusMonitor() {
|
||||
const { onMessage, isConnected } = useWebSocketConnection();
|
||||
|
||||
const serverStatus = ref<ServerStatus | null>(null);
|
||||
const statusError = ref<string | null>(null); // 存储状态获取错误
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
const handleStatusUpdate = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// console.debug('[状态监控模块] 收到 status_update:', payload);
|
||||
if (payload && payload.status) {
|
||||
serverStatus.value = payload.status;
|
||||
statusError.value = null; // 收到有效状态时清除错误
|
||||
} else {
|
||||
console.warn('[状态监控模块] 收到缺少 payload.status 的 status_update 消息');
|
||||
// 可以选择设置一个错误状态,表明数据格式不正确
|
||||
// statusError.value = '收到的状态数据格式无效';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理可能的后端状态错误消息 (如果后端会发送的话)
|
||||
const handleStatusError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
console.error('[状态监控模块] 收到状态错误消息:', payload);
|
||||
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
|
||||
serverStatus.value = null; // 出错时清除状态数据
|
||||
};
|
||||
|
||||
// --- 注册 WebSocket 消息处理器 ---
|
||||
let unregisterUpdate: (() => void) | null = null;
|
||||
let unregisterError: (() => void) | null = null;
|
||||
|
||||
const registerStatusHandlers = () => {
|
||||
// 仅在连接时注册处理器
|
||||
if (isConnected.value) {
|
||||
console.log('[状态监控模块] 注册状态消息处理器。');
|
||||
unregisterUpdate = onMessage('status_update', handleStatusUpdate);
|
||||
// 假设后端可能发送 'status:error' 类型的特定错误
|
||||
unregisterError = onMessage('status:error', handleStatusError);
|
||||
} else {
|
||||
console.warn('[状态监控模块] WebSocket 未连接,无法注册状态处理器。');
|
||||
}
|
||||
};
|
||||
|
||||
const unregisterAllStatusHandlers = () => {
|
||||
console.log('[状态监控模块] 注销状态消息处理器。');
|
||||
unregisterUpdate?.();
|
||||
unregisterError?.();
|
||||
unregisterUpdate = null;
|
||||
unregisterError = null;
|
||||
};
|
||||
|
||||
// --- 清理 ---
|
||||
onUnmounted(() => {
|
||||
unregisterAllStatusHandlers();
|
||||
console.log('[状态监控模块] Composable 已卸载。');
|
||||
});
|
||||
|
||||
// --- 暴露接口 ---
|
||||
return {
|
||||
serverStatus: readonly(serverStatus), // 只读状态
|
||||
statusError: readonly(statusError), // 只读错误状态
|
||||
registerStatusHandlers, // 暴露注册函数
|
||||
unregisterAllStatusHandlers, // 暴露注销函数
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ref, shallowRef, onUnmounted, computed, type Ref, readonly } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// 从类型文件导入 WebSocket 相关类型
|
||||
import type { ConnectionStatus, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types';
|
||||
|
||||
// --- 类型定义 (已移至 websocket.types.ts) ---
|
||||
// export type ConnectionStatus = ...;
|
||||
// export type MessagePayload = ...;
|
||||
// export interface WebSocketMessage { ... }
|
||||
// export type MessageHandler = ...;
|
||||
|
||||
// --- Singleton State within the module scope ---
|
||||
// This ensures only one WebSocket connection and state is managed across the app.
|
||||
const ws = shallowRef<WebSocket | null>(null); // Use shallowRef for the WebSocket object itself
|
||||
const connectionStatus = ref<ConnectionStatus>('disconnected');
|
||||
const statusMessage = ref<string>('');
|
||||
const connectionIdForSession = ref<string | null>(null); // Store the connectionId used for the current session
|
||||
const isSftpReady = ref<boolean>(false); // Track SFTP readiness
|
||||
|
||||
// Registry for message handlers
|
||||
const messageHandlers = new Map<string, Set<MessageHandler>>();
|
||||
// --- End Singleton State ---
|
||||
|
||||
|
||||
export function useWebSocketConnection() {
|
||||
const { t } = useI18n(); // Get t function for status messages
|
||||
|
||||
// Helper to get status text safely
|
||||
const getStatusText = (statusKey: string, params?: Record<string, unknown>): string => {
|
||||
try {
|
||||
// Use a fallback key or message if translation is missing
|
||||
const translated = t(`workspace.status.${statusKey}`, params || {});
|
||||
// Check if the key itself was returned (indicating missing translation)
|
||||
return translated === `workspace.status.${statusKey}` ? statusKey : translated;
|
||||
} catch (e) {
|
||||
console.warn(`[i18n] Error getting translation for workspace.status.${statusKey}:`, e);
|
||||
return statusKey; // Fallback to the key itself
|
||||
}
|
||||
};
|
||||
|
||||
// Function to dispatch a message to all registered handlers for its type
|
||||
const dispatchMessage = (type: string, payload: MessagePayload, fullMessage: WebSocketMessage) => {
|
||||
if (messageHandlers.has(type)) {
|
||||
messageHandlers.get(type)?.forEach(handler => {
|
||||
try {
|
||||
handler(payload, fullMessage);
|
||||
} catch (e) {
|
||||
console.error(`[WebSocket] Error in message handler for type "${type}":`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const connect = (url: string, connId: string) => {
|
||||
// Prevent multiple connections or connection attempts
|
||||
if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) {
|
||||
// If it's the same connection ID and already open/connecting, do nothing
|
||||
if (connectionIdForSession.value === connId) {
|
||||
console.warn(`[WebSocket] Connection for ${connId} already open or connecting.`);
|
||||
return;
|
||||
}
|
||||
// If different connection ID, close the old one first
|
||||
console.log(`[WebSocket] Closing existing connection for ${connectionIdForSession.value} before connecting to ${connId}`);
|
||||
disconnect(); // Ensure cleanup before new connection
|
||||
}
|
||||
|
||||
console.log(`[WebSocket] Attempting to connect to: ${url} for connection ${connId}`);
|
||||
connectionIdForSession.value = connId;
|
||||
statusMessage.value = getStatusText('connectingWs', { url });
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
try {
|
||||
ws.value = new WebSocket(url);
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('[WebSocket] Connection opened.');
|
||||
statusMessage.value = getStatusText('wsConnected');
|
||||
// Status remains 'connecting' until ssh:connected is received
|
||||
// Send the initial connection message required by the backend
|
||||
sendMessage({ type: 'ssh:connect', payload: { connectionId: connId } });
|
||||
// Dispatch an internal event if needed
|
||||
// dispatchMessage('internal:opened', {}, { type: 'internal:opened' });
|
||||
};
|
||||
|
||||
ws.value.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
// console.debug('[WebSocket] Received:', message.type); // Less verbose logging
|
||||
|
||||
// --- Update Global Connection Status based on specific messages ---
|
||||
if (message.type === 'ssh:connected') {
|
||||
if (connectionStatus.value !== 'connected') {
|
||||
console.log('[WebSocket] SSH session connected.');
|
||||
connectionStatus.value = 'connected';
|
||||
statusMessage.value = getStatusText('connected');
|
||||
}
|
||||
} else if (message.type === 'ssh:disconnected') {
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
console.log('[WebSocket] SSH session disconnected.');
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('disconnected', { reason: message.payload || 'Unknown reason' });
|
||||
}
|
||||
} else if (message.type === 'ssh:error' || message.type === 'error') { // Handle generic backend errors too
|
||||
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
|
||||
console.error('[WebSocket] Received error message:', message.payload);
|
||||
connectionStatus.value = 'error';
|
||||
let errorMsg = message.payload || 'Unknown error';
|
||||
if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message;
|
||||
statusMessage.value = getStatusText('error', { message: errorMsg });
|
||||
isSftpReady.value = false; // Reset SFTP status on error
|
||||
}
|
||||
} else if (message.type === 'sftp_ready') {
|
||||
console.log('[WebSocket] SFTP session ready.');
|
||||
isSftpReady.value = true;
|
||||
}
|
||||
// --- End Status Update ---
|
||||
|
||||
// Dispatch message to specific handlers
|
||||
dispatchMessage(message.type, message.payload, message);
|
||||
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Error processing message:', e, 'Raw data:', event.data);
|
||||
// Optionally dispatch raw data if needed by some handler
|
||||
// dispatchMessage('internal:raw', event.data, { type: 'internal:raw' });
|
||||
}
|
||||
};
|
||||
|
||||
ws.value.onerror = (event) => {
|
||||
console.error('[WebSocket] Connection error:', event);
|
||||
if (connectionStatus.value !== 'disconnected') { // Avoid overwriting disconnect status
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('wsError');
|
||||
}
|
||||
dispatchMessage('internal:error', event, { type: 'internal:error' });
|
||||
isSftpReady.value = false; // Reset SFTP status on WS error
|
||||
ws.value = null; // Clean up on error
|
||||
connectionIdForSession.value = null;
|
||||
};
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log(`[WebSocket] Connection closed: Code=${event.code}, Reason=${event.reason}`);
|
||||
// Update status only if not already handled by ssh:disconnected or error
|
||||
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('wsClosed', { code: event.code });
|
||||
}
|
||||
dispatchMessage('internal:closed', { code: event.code, reason: event.reason }, { type: 'internal:closed' });
|
||||
isSftpReady.value = false; // Reset SFTP status on close
|
||||
ws.value = null; // Clean up reference
|
||||
connectionIdForSession.value = null;
|
||||
// Optionally clear handlers on close? Depends on desired behavior.
|
||||
// messageHandlers.clear();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to create WebSocket instance:', err);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('wsError'); // Or a more specific creation error
|
||||
isSftpReady.value = false; // Reset SFTP status on creation error
|
||||
ws.value = null;
|
||||
connectionIdForSession.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (ws.value) {
|
||||
console.log('[WebSocket] Closing connection manually...');
|
||||
// Set status immediately to prevent race conditions with onclose
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('disconnected', { reason: 'Manual disconnect' });
|
||||
}
|
||||
ws.value.close(1000, 'Client initiated disconnect'); // Use standard code and reason
|
||||
ws.value = null;
|
||||
connectionIdForSession.value = null;
|
||||
isSftpReady.value = false; // Reset SFTP status on manual disconnect
|
||||
// messageHandlers.clear(); // Clear handlers on manual disconnect
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = (message: WebSocketMessage) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const messageString = JSON.stringify(message);
|
||||
// console.debug('[WebSocket] Sending:', message.type); // Less verbose
|
||||
ws.value.send(messageString);
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Failed to stringify or send message:', e, message);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[WebSocket] Cannot send message, connection not open. State: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Register a handler for a specific message type
|
||||
const onMessage = (type: string, handler: MessageHandler) => {
|
||||
if (!messageHandlers.has(type)) {
|
||||
messageHandlers.set(type, new Set());
|
||||
}
|
||||
const handlersSet = messageHandlers.get(type);
|
||||
if (handlersSet) {
|
||||
handlersSet.add(handler);
|
||||
console.debug(`[WebSocket] Handler registered for type: ${type}`);
|
||||
}
|
||||
|
||||
|
||||
// Return an unregister function
|
||||
return () => {
|
||||
const currentSet = messageHandlers.get(type);
|
||||
if (currentSet) {
|
||||
currentSet.delete(handler);
|
||||
console.debug(`[WebSocket] Handler unregistered for type: ${type}`);
|
||||
if (currentSet.size === 0) {
|
||||
messageHandlers.delete(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Cleanup logic: The singleton nature means disconnect should be called explicitly
|
||||
// when the connection is no longer needed (e.g., when WorkspaceView unmounts).
|
||||
// onUnmounted is generally tied to the component instance using the composable.
|
||||
// If useWebSocketConnection is called in WorkspaceView's setup, its onUnmounted
|
||||
// will trigger disconnect, which is the desired behavior.
|
||||
|
||||
return {
|
||||
// State (Exported as readonly refs where appropriate)
|
||||
isConnected: computed(() => connectionStatus.value === 'connected'),
|
||||
isSftpReady: readonly(isSftpReady), // Expose SFTP readiness state
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
statusMessage: readonly(statusMessage),
|
||||
|
||||
// Methods
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
onMessage,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user