refactor: 重构前端

This commit is contained in:
Baobhan Sith
2025-04-15 11:11:01 +08:00
parent d1f874d38b
commit 2072bff331
16 changed files with 2361 additions and 768 deletions
+223 -1
View File
@@ -1,4 +1,4 @@
import { Client, SFTPWrapper, Stats } from 'ssh2';
import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream
import { WebSocket } from 'ws';
import { ClientState } from '../websocket'; // 导入统一的 ClientState
@@ -37,11 +37,22 @@ interface NetworkStats {
const DEFAULT_POLLING_INTERVAL = 1000;
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
// Interface for tracking active uploads
interface ActiveUpload {
remotePath: string;
totalSize: number;
bytesWritten: number;
stream: WriteStream;
sessionId: string; // Link back to the session for cleanup
}
export class SftpService {
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
private activeUploads: Map<string, ActiveUpload>; // Map<uploadId, ActiveUpload>
constructor(clientStates: Map<string, ClientState>) {
this.clientStates = clientStates;
this.activeUploads = new Map(); // Initialize the map
}
/**
@@ -98,6 +109,13 @@ export class SftpService {
state.sftp.end();
state.sftp = undefined;
}
// Also clean up any active uploads associated with this session
this.activeUploads.forEach((upload, uploadId) => {
if (upload.sessionId === sessionId) {
console.warn(`[SFTP] Cleaning up active upload ${uploadId} for session ${sessionId} due to SFTP session cleanup.`);
this.cancelUploadInternal(uploadId, 'SFTP session ended'); // Internal cancel without sending message
}
});
}
// --- SFTP 操作方法 ---
@@ -368,4 +386,208 @@ export class SftpService {
// async uploadFile(...)
// async downloadFile(...)
/** 获取路径的绝对表示 */
async realpath(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 realpath (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SFTP ${sessionId}] Received realpath request for ${path} (ID: ${requestId})`);
try {
state.sftp.realpath(path, (err, absPath) => {
if (err) {
console.error(`[SFTP ${sessionId}] realpath ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] realpath ${path} -> ${absPath} success (ID: ${requestId})`);
// 在 payload 中同时发送请求的路径和绝对路径
state.ws.send(JSON.stringify({ type: 'sftp:realpath:success', path: path, payload: { requestedPath: path, absolutePath: absPath }, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] realpath ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
// --- File Upload Methods ---
/** Start a new file upload */
startUpload(sessionId: string, uploadId: string, remotePath: string, totalSize: number): void {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP Upload ${uploadId}] SFTP not ready for session ${sessionId}.`);
state?.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'SFTP 会话未就绪' } }));
return;
}
if (this.activeUploads.has(uploadId)) {
console.warn(`[SFTP Upload ${uploadId}] Upload already in progress for session ${sessionId}.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'Upload already started' } }));
return;
}
console.log(`[SFTP Upload ${uploadId}] Starting upload for ${remotePath} (${totalSize} bytes) in session ${sessionId}`);
try {
const stream = state.sftp.createWriteStream(remotePath);
const uploadState: ActiveUpload = {
remotePath,
totalSize,
bytesWritten: 0,
stream,
sessionId,
};
this.activeUploads.set(uploadId, uploadState);
stream.on('error', (err: Error) => {
console.error(`[SFTP Upload ${uploadId}] Write stream error for ${remotePath}:`, err);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入流错误: ${err.message}` } }));
this.activeUploads.delete(uploadId); // Clean up state on error
});
stream.on('close', () => {
// This 'close' event now primarily handles cleanup after the stream is fully closed.
// The success message is sent earlier in handleUploadChunk.
const finalState = this.activeUploads.get(uploadId);
if (finalState) {
// Check if bytes written match total size upon close, log warning if not (could indicate cancellation after success msg sent)
if (finalState.bytesWritten !== finalState.totalSize) {
console.warn(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but written bytes (${finalState.bytesWritten}) != total size (${finalState.totalSize}). This might happen if cancelled after success message was sent.`);
// Optionally send an error if this state is unexpected, but success might have already been sent.
// state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '文件大小不匹配或上传未完成' } }));
} else {
console.log(`[SFTP Upload ${uploadId}] Write stream closed successfully for ${remotePath}. State cleaned up.`);
}
this.activeUploads.delete(uploadId); // Clean up state when stream is closed
} else {
console.log(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but upload state was already removed.`);
}
});
stream.on('finish', () => {
// The 'finish' event fires when stream.end() is called and all data has been flushed to the underlying system.
// This might be a slightly earlier point than 'close'. Let's log it.
console.log(`[SFTP Upload ${uploadId}] Write stream finished for ${remotePath}. Waiting for close.`);
});
// Notify client that we are ready for chunks
state.ws.send(JSON.stringify({ type: 'sftp:upload:ready', payload: { uploadId } }));
} catch (error: any) {
console.error(`[SFTP Upload ${uploadId}] Error starting upload for ${remotePath}:`, error);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `开始上传时出错: ${error.message}` } }));
this.activeUploads.delete(uploadId); // Clean up if start failed
}
}
/** Handle an incoming file chunk */
handleUploadChunk(sessionId: string, uploadId: string, chunkIndex: number, dataBase64: string): void {
const state = this.clientStates.get(sessionId);
const uploadState = this.activeUploads.get(uploadId);
if (!state || !state.sftp) {
// Session or SFTP gone, can't process chunk. Upload might be cleaned up elsewhere.
console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but session ${sessionId} or SFTP is invalid.`);
this.cancelUploadInternal(uploadId, 'Session or SFTP invalid');
return;
}
if (!uploadState) {
console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but no active upload found.`);
// Send error back to client? Might flood if many chunks arrive after cancellation.
// state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } }));
return;
}
try {
const chunkBuffer = Buffer.from(dataBase64, 'base64');
// console.debug(`[SFTP Upload ${uploadId}] Writing chunk ${chunkIndex} (${chunkBuffer.length} bytes) to ${uploadState.remotePath}`);
// Write the chunk. The 'drain' event is handled automatically by Node.js streams
// if the write buffer is full. We just write.
const writeSuccess = uploadState.stream.write(chunkBuffer, (err) => {
if (err) {
// This callback handles errors specifically related to *this* write operation.
console.error(`[SFTP Upload ${uploadId}] Error writing chunk ${chunkIndex} to ${uploadState.remotePath}:`, err);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入块 ${chunkIndex} 失败: ${err.message}` } }));
// Consider cancelling the upload on write error
this.cancelUploadInternal(uploadId, `Write error on chunk ${chunkIndex}`);
}
// else { console.debug(`[SFTP Upload ${uploadId}] Chunk ${chunkIndex} write callback success.`); }
});
if (!writeSuccess) {
// This indicates the buffer is full and we should wait for 'drain'.
// However, for simplicity in this WebSocket context, we might rely on TCP backpressure
// or simply continue writing, letting the stream buffer handle it.
// Adding explicit 'drain' handling can add complexity.
console.warn(`[SFTP Upload ${uploadId}] Write stream buffer full after chunk ${chunkIndex}. Waiting for drain is recommended for large files/slow connections.`);
}
uploadState.bytesWritten += chunkBuffer.length;
// Send progress (optional, consider throttling)
// const progress = Math.round((uploadState.bytesWritten / uploadState.totalSize) * 100);
// state.ws.send(JSON.stringify({ type: 'sftp:upload:progress', payload: { uploadId, progress } }));
// Check if upload is complete
if (uploadState.bytesWritten > uploadState.totalSize) {
console.error(`[SFTP Upload ${uploadId}] Bytes written (${uploadState.bytesWritten}) exceeded total size (${uploadState.totalSize}) for ${uploadState.remotePath}.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '写入字节数超过文件总大小' } }));
this.cancelUploadInternal(uploadId, 'Bytes written exceeded total size');
} else if (uploadState.bytesWritten === uploadState.totalSize) {
console.log(`[SFTP Upload ${uploadId}] All bytes (${uploadState.bytesWritten}) received for ${uploadState.remotePath}. Sending success and ending stream.`);
// Send success message IMMEDIATELY upon receiving the last expected byte
state.ws.send(JSON.stringify({ type: 'sftp:upload:success', payload: { uploadId, remotePath: uploadState.remotePath } }));
// Now end the stream. The 'close' event will handle cleanup.
uploadState.stream.end();
}
} catch (error: any) {
console.error(`[SFTP Upload ${uploadId}] Error handling chunk ${chunkIndex} for ${uploadState?.remotePath}:`, error);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `处理块 ${chunkIndex} 时出错: ${error.message}` } }));
this.cancelUploadInternal(uploadId, `Error handling chunk ${chunkIndex}`);
}
}
/** Cancel an ongoing upload */
cancelUpload(sessionId: string, uploadId: string): void {
const state = this.clientStates.get(sessionId);
const uploadState = this.activeUploads.get(uploadId);
if (!state) {
console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but session ${sessionId} not found.`);
// Can't send message back if session is gone
this.cancelUploadInternal(uploadId, 'Session not found'); // Clean up if state exists
return;
}
if (!uploadState) {
console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but no active upload found.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } }));
return;
}
console.log(`[SFTP Upload ${uploadId}] Cancelling upload for ${uploadState.remotePath}`);
this.cancelUploadInternal(uploadId, 'User cancelled');
state.ws.send(JSON.stringify({ type: 'sftp:upload:cancelled', payload: { uploadId } }));
}
/** Internal helper to clean up an upload */
private cancelUploadInternal(uploadId: string, reason: string): void {
const uploadState = this.activeUploads.get(uploadId);
if (uploadState) {
console.log(`[SFTP Upload ${uploadId}] Internal cancel (${reason}): Closing stream for ${uploadState.remotePath}`);
// End the stream. The 'close' handler should ideally detect the size mismatch or see the state is gone.
// Using destroy might be more immediate but could lead to unclosed file descriptors on the server in some cases.
uploadState.stream.end(); // Gracefully try to end
// uploadState.stream.destroy(); // More forceful, might be needed
this.activeUploads.delete(uploadId);
} else {
// console.log(`[SFTP Upload ${uploadId}] Internal cancel called, but upload state already removed.`);
}
}
}
+50 -12
View File
@@ -298,7 +298,8 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
case 'sftp:rmdir':
case 'sftp:unlink':
case 'sftp:rename':
case 'sftp:chmod': {
case 'sftp:chmod':
case 'sftp:realpath': { // Add realpath case
if (!sessionId || !state) {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`);
// 尝试包含 requestId 发送错误,如果 requestId 存在的话
@@ -370,6 +371,11 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
sftpService.chmod(sessionId, payload.path, payload.mode, requestId);
} else { throw new Error("Missing 'path' or invalid 'mode' in payload for chmod"); }
break;
case 'sftp:realpath': // Add realpath handler
if (payload?.path) {
sftpService.realpath(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for realpath"); }
break;
default:
// Should not happen if already checked type, but as a safeguard
throw new Error(`Unhandled SFTP type: ${type}`);
@@ -380,17 +386,49 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
}
break;
}
// --- SFTP 文件上传 (保持部分逻辑,因为涉及分块) ---
// TODO: 考虑将上传逻辑也移入 SftpService
case 'sftp:upload:start':
case 'sftp:upload:chunk':
case 'sftp:upload:cancel': {
console.warn(`WebSocket: SFTP 上传功能 (${type}) 尚未完全迁移到 SftpService。`);
// 可以在这里调用 SftpService 的对应方法,或者暂时保留旧逻辑
ws.send(JSON.stringify({ type: 'error', payload: `SFTP 上传功能正在重构中。` }));
break;
}
// --- SFTP 文件上传 (委托给 SftpService) ---
case 'sftp:upload:start': {
if (!sessionId || !state) {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`);
ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '无效的会话' } }));
return;
}
if (!payload?.uploadId || !payload?.remotePath || typeof payload?.size !== 'number') {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, remotePath 或 size。`);
ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId, remotePath 或 size' } }));
return;
}
sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size);
break;
}
case 'sftp:upload:chunk': {
if (!sessionId || !state) {
// Don't warn repeatedly for chunks if session is gone
return;
}
if (!payload?.uploadId || typeof payload?.chunkIndex !== 'number' || !payload?.data) {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, chunkIndex 或 data。`);
// Avoid flooding with errors for every chunk if something is wrong
// Consider sending a single error and potentially cancelling on the service side
return;
}
// Assuming data is base64 encoded string from frontend
sftpService.handleUploadChunk(sessionId, payload.uploadId, payload.chunkIndex, payload.data);
break;
}
case 'sftp:upload:cancel': {
if (!sessionId || !state) {
// Don't warn if session is already gone
return;
}
if (!payload?.uploadId) {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId。`);
ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId' } }));
return;
}
sftpService.cancelUpload(sessionId, payload.uploadId);
break;
}
default:
console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`);
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import MonacoEditor from './MonacoEditor.vue'; // 导入 Monaco Editor 组件
import type { SaveStatus } from '../types/sftp.types'; // 导入保存状态类型
const props = defineProps<{
isVisible: boolean; // 控制可见性
filePath: string | null; // 当前编辑文件路径
language: string; // 编辑器语言
isLoading: boolean; // 是否正在加载文件内容
loadingError: string | null; // 加载错误信息
isSaving: boolean; // 是否正在保存
saveStatus: SaveStatus; // 保存状态
saveError: string | null; // 保存错误信息
modelValue: string; // 文件内容 (用于 v-model)
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void; // v-model 更新事件
(e: 'request-save'): void; // 请求保存事件
(e: 'close'): void; // 关闭编辑器事件
}>();
const { t } = useI18n();
// 计算属性,用于 v-model 绑定到 MonacoEditor
const editorContent = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const handleSaveRequest = () => {
emit('request-save');
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<div v-if="isVisible" class="editor-overlay">
<div class="editor-header">
<span>{{ t('fileManager.editingFile') }}: {{ filePath }}</span>
<div class="editor-actions">
<!-- 保存状态显示 -->
<span v-if="saveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span>
<span v-if="saveStatus === 'success'" class="save-status success"> {{ t('fileManager.saveSuccess') }}</span>
<span v-if="saveStatus === 'error'" class="save-status error"> {{ t('fileManager.saveError') }}: {{ saveError }}</span>
<!-- 保存按钮 -->
<button @click="handleSaveRequest" :disabled="isSaving || isLoading || !!loadingError" class="save-btn">
{{ isSaving ? t('fileManager.saving') : t('fileManager.actions.save') }}
</button>
<!-- 关闭按钮 -->
<button @click="handleClose" class="close-editor-btn"></button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="editor-loading">{{ t('fileManager.loadingFile') }}</div>
<!-- 加载错误 -->
<div v-else-if="loadingError" class="editor-error">{{ loadingError }}</div>
<!-- Monaco 编辑器实例 -->
<MonacoEditor
v-else
v-model="editorContent"
:language="language"
theme="vs-dark"
class="editor-instance"
@request-save="handleSaveRequest"
/>
</div>
</template>
<style scoped>
/* 样式从 FileManager.vue 迁移并保持一致 */
.editor-overlay {
position: absolute; /* 相对于父容器定位 */
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(40, 40, 40, 0.95); /* 深色半透明背景 */
z-index: 1000; /* 确保在文件列表之上,但在上传弹窗之下 */
display: flex;
flex-direction: column;
color: #f0f0f0;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background-color: #333;
border-bottom: 1px solid #555;
font-size: 0.9em;
flex-shrink: 0; /* 防止头部被压缩 */
}
.close-editor-btn {
background: none;
border: none;
color: #ccc;
font-size: 1.2em;
cursor: pointer;
padding: 0.2rem 0.5rem;
}
.close-editor-btn:hover {
color: white;
}
.editor-loading, .editor-error {
padding: 2rem;
text-align: center;
font-size: 1.1em;
flex-grow: 1; /* 占据剩余空间 */
display: flex;
align-items: center;
justify-content: center;
}
.editor-error {
color: #ff8a8a;
}
.editor-actions {
display: flex;
align-items: center;
gap: 1rem; /* 添加按钮间距 */
}
.save-btn {
background-color: #4CAF50;
color: white;
border: none;
padding: 0.4rem 0.8rem;
/* margin-left: 1rem; */ /* 使用 gap 代替 */
cursor: pointer;
border-radius: 3px;
font-size: 0.9em;
}
.save-btn:disabled {
background-color: #aaa;
cursor: not-allowed;
}
.save-btn:hover:not(:disabled) {
background-color: #45a049;
}
.save-status {
/* margin-left: 1rem; */ /* 使用 gap 代替 */
font-size: 0.9em;
padding: 0.2rem 0.5rem;
border-radius: 3px;
white-space: nowrap; /* 防止状态文本换行 */
}
.save-status.saving {
color: #888;
}
.save-status.success {
color: #4CAF50;
background-color: #e8f5e9;
}
.save-status.error {
color: #f44336;
background-color: #ffebee;
}
.editor-instance {
flex-grow: 1; /* 让编辑器占据剩余空间 */
min-height: 0; /* 对 flex 布局中的子元素很重要 */
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { UploadItem } from '../types/upload.types'; //
const props = defineProps<{
uploads: Record<string, UploadItem>; //
}>();
const emit = defineEmits<{
(e: 'cancel-upload', uploadId: string): void; //
}>();
const { t } = useI18n();
//
const hasVisibleUploads = computed(() => {
return Object.values(props.uploads).some(
upload => upload.status !== 'success' && upload.status !== 'cancelled'
);
});
// /
//
const uploadList = computed(() => Object.values(props.uploads));
const handleCancel = (uploadId: string) => {
emit('cancel-upload', uploadId);
};
</script>
<template>
<!-- 仅当有上传任务时显示 -->
<div v-if="uploadList.length > 0" class="upload-popup">
<h4>{{ t('fileManager.uploadTasks') }}:</h4>
<ul>
<li v-for="upload in uploadList" :key="upload.id">
<span>{{ upload.filename }} ({{ t(`fileManager.uploadStatus.${upload.status}`) }})</span>
<progress v-if="upload.status === 'uploading' || upload.status === 'pending'" :value="upload.progress" max="100"></progress>
<span v-if="upload.status === 'uploading'"> {{ upload.progress }}%</span>
<span v-if="upload.status === 'error'" class="error"> {{ t('fileManager.errors.generic') }}: {{ upload.error }}</span>
<span v-if="upload.status === 'success'"> </span>
<span v-if="upload.status === 'cancelled'"> {{ t('fileManager.uploadStatus.cancelled') }}</span>
<!-- 只有在可取消状态时显示取消按钮 -->
<button v-if="['pending', 'uploading', 'paused'].includes(upload.status)" @click="handleCancel(upload.id)" class="cancel-btn">{{ t('fileManager.actions.cancel') }}</button>
</li>
</ul>
</div>
</template>
<style scoped>
/* 样式从 FileManager.vue 迁移并保持一致 */
.upload-popup {
position: fixed;
bottom: 1rem;
right: 1rem;
background-color: white;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
padding: 0.8rem;
max-width: 300px;
max-height: 200px;
overflow-y: auto;
z-index: 1001; /* 确保在文件列表之上 */
font-size: 0.9rem; /* 保持字体大小一致 */
}
.upload-popup h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
}
.upload-popup ul {
list-style: none;
padding: 0;
margin: 0;
}
.upload-popup li {
margin-bottom: 0.4rem;
font-size: 0.85em;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem; /* 添加一些间隙 */
}
.upload-popup progress {
/* margin: 0 0.5rem; */ /* 使用 gap 代替 */
width: 80px;
height: 0.8em;
flex-shrink: 0; /* 防止进度条被压缩 */
}
.upload-popup .error {
color: red;
/* margin-left: 0.5rem; */ /* 使用 gap 代替 */
flex-basis: 100%; /* 错误信息换行 */
font-size: 0.8em;
}
.upload-popup .cancel-btn {
margin-left: auto; /* 将按钮推到右侧 */
padding: 0.1rem 0.4rem;
font-size: 0.8em;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
cursor: pointer;
border-radius: 3px;
}
.upload-popup .cancel-btn:hover {
background-color: #f5c6cb;
}
</style>
@@ -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,
};
}
@@ -0,0 +1,15 @@
// 类型定义:用于服务器状态监控数据 (从 useStatusMonitor 迁移)
export interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
memUsed?: number; // MB
memTotal?: number; // MB
diskPercent?: number;
diskUsed?: number; // KB
diskTotal?: number; // KB
cpuModel?: string;
// 可以根据后端实际发送的数据添加更多字段
// 例如:swapPercent?, swapUsed?, swapTotal?, netRxRate?, netTxRate?, netInterface?, osName?, loadAvg?, timestamp?
}
// 可以根据需要添加其他与服务器或连接状态相关的类型
+28
View File
@@ -0,0 +1,28 @@
// 类型定义:用于 SFTP 文件和目录属性
export interface FileAttributes {
size: number;
uid: number;
gid: number;
mode: number; // 文件模式 (例如 0o755)
atime: number; // 最后访问时间 (毫秒时间戳)
mtime: number; // 最后修改时间 (毫秒时间戳)
isDirectory: boolean;
isFile: boolean;
isSymbolicLink: boolean;
}
// 类型定义:用于文件列表中的单个条目
export interface FileListItem {
filename: string; // 文件或目录名
longname: string; // ls -l 风格的长名称字符串
attrs: FileAttributes; // 文件属性
}
// 类型定义:用于编辑器文件内容和编码 (从 useFileEditor 迁移)
export interface EditorFileContent {
content: string;
encoding: 'utf8' | 'base64';
}
// 类型定义:编辑器保存状态 (从 useFileEditor 迁移)
export type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
@@ -0,0 +1,11 @@
// 类型定义:用于文件上传任务
export interface UploadItem {
id: string; // 上传任务的唯一标识符
file: File; // 要上传的文件对象
filename: string; // 文件名
progress: number; // 上传进度 (0-100)
error?: string; // 错误信息
status: 'pending' | 'uploading' | 'paused' | 'success' | 'error' | 'cancelled'; // 上传状态
}
// 可以根据需要添加其他与上传相关的类型
@@ -0,0 +1,17 @@
// WebSocket 连接状态类型
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
// 通用消息负载类型定义
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type MessagePayload = any;
// WebSocket 消息结构接口
export interface WebSocketMessage {
type: string; // 消息类型
payload?: MessagePayload; // 消息负载
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // 允许其他属性,如 requestId, encoding 等
}
// 消息处理器函数类型
export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void;
+93 -207
View File
@@ -1,232 +1,115 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'; // Added watch
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; // useI18n
import TerminalComponent from '../components/Terminal.vue'; //
import FileManagerComponent from '../components/FileManager.vue'; //
import StatusMonitorComponent from '../components/StatusMonitor.vue'; //
import type { Terminal } from 'xterm'; // Terminal
import { useWebSocketConnection } from '../composables/useWebSocketConnection'; // hook
import { useSshTerminal } from '../composables/useSshTerminal'; // SSH
import { useStatusMonitor } from '../composables/useStatusMonitor'; //
import type { ServerStatus } from '../types/server.types'; // ServerStatus
// Removed duplicate/unused import: import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
// --- Interfaces ---
// Updated interface to match StatusMonitor and backend
interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
memUsed?: number; // MB
memTotal?: number; // MB
diskPercent?: number;
diskUsed?: number; // KB
diskTotal?: number; // KB
cpuModel?: string;
}
// --- ---
// ServerStatus types/server.types.ts
const { t } = useI18n(); // t
const route = useRoute();
const connectionId = computed(() => route.params.connectionId as string); // connectionId
const terminalInstance = ref<Terminal | null>(null); //
const ws = ref<WebSocket | null>(null); // WebSocket
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
const statusMessage = ref<string>(t('workspace.status.initializing')); // 使 i18n
const terminalOutputBuffer = ref<string[]>([]); // WebSocket
const serverStatus = ref<ServerStatus | null>(null); //
const statusError = ref<string | null>(null); //
// --- WebSocket ---
const {
isConnected,
connectionStatus, // Get reactive status from composable
statusMessage, // Get reactive status message from composable
connect,
disconnect,
sendMessage,
onMessage,
} = useWebSocketConnection();
// i18n
const getStatusText = (statusKey: string, params?: Record<string, any>): string => {
return t(`workspace.status.${statusKey}`, params || {});
};
// --- SSH ---
const {
// terminalInstance, // 访
handleTerminalReady,
handleTerminalData,
handleTerminalResize,
registerSshHandlers,
unregisterAllSshHandlers,
} = useSshTerminal();
//
const getTerminalText = (key: string, params?: Record<string, any>): string => {
return t(`workspace.terminal.${key}`, params || {});
};
// --- ---
const {
serverStatus, // composable
statusError, // composable
registerStatusHandlers, // SSH
unregisterAllStatusHandlers, // SSH
} = useStatusMonitor();
//
const onTerminalReady = (term: Terminal) => {
terminalInstance.value = term;
//
terminalOutputBuffer.value.forEach(data => term.write(data));
terminalOutputBuffer.value = []; //
console.log('终端准备就绪');
};
//
const onTerminalData = (data: string) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify({ type: 'ssh:input', payload: { data } }));
}
};
//
const onTerminalResize = (dimensions: { cols: number; rows: number }) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
console.log('发送终端大小调整:', dimensions);
ws.value.send(JSON.stringify({ type: 'ssh:resize', payload: dimensions }));
}
};
// WebSocket
const initializeWebSocketConnection = () => {
// 使 (3001) /
// WebSocket
//
const wsUrl = `ws://${window.location.hostname}:3001`; // WebSocket URL
console.log(`尝试连接 WebSocket: ${wsUrl}`);
statusMessage.value = getStatusText('connectingWs', { url: wsUrl });
connectionStatus.value = 'connecting';
ws.value = new WebSocket(wsUrl);
ws.value.onopen = () => {
console.log('WebSocket 连接已打开');
statusMessage.value = getStatusText('wsConnected');
// ssh:connect
if (ws.value) {
ws.value.send(JSON.stringify({ type: 'ssh:connect', payload: { connectionId: connectionId.value } }));
}
};
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// console.log(' WebSocket :', message); // Debug log
switch (message.type) {
case 'ssh:output':
let outputData = message.payload;
// Base64
if (message.encoding === 'base64' && typeof outputData === 'string') {
try {
// Base64 UTF-8
// atob Node.js Buffer.from(..., 'base64').toString()
outputData = atob(outputData);
} catch (e) {
console.error('Base64 解码失败:', e, '原始数据:', message.payload);
outputData = `\r\n[解码错误: ${e}]\r\n`; //
}
}
//
if (terminalInstance.value) {
terminalInstance.value.write(outputData);
} else {
// ()
terminalOutputBuffer.value.push(message.payload);
}
break;
case 'ssh:connected':
console.log('SSH 会话已连接');
connectionStatus.value = 'connected';
statusMessage.value = getStatusText('connected');
terminalInstance.value?.focus(); //
break;
case 'ssh:disconnected':
const reasonDisconnect = message.payload || '未知原因';
console.log('SSH 会话已断开:', reasonDisconnect);
connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('disconnected', { reason: reasonDisconnect });
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason: reasonDisconnect })}\x1b[0m`);
break;
case 'ssh:error':
const errorMsg = message.payload || '未知 SSH 错误';
console.error('SSH 错误:', errorMsg);
connectionStatus.value = 'error';
// key
let errorKey = 'sshError';
if (errorMsg.includes('解密')) errorKey = 'decryptError';
else if (errorMsg.includes('未找到 ID')) errorKey = 'noConnInfo';
else if (errorMsg.includes('缺少密码')) errorKey = 'noPassword';
else if (errorMsg.includes('打开 Shell 失败')) errorKey = 'shellError';
else if (errorMsg.includes('已存在活动的 SSH 连接')) errorKey = 'alreadyConnected';
statusMessage.value = getStatusText(errorKey, { message: errorMsg, id: connectionId.value });
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
break;
case 'ssh:status':
const statusKey = message.payload?.key || 'unknown'; // key
const statusParams = message.payload?.params || {};
console.log('SSH 状态:', statusKey, statusParams);
statusMessage.value = getStatusText(statusKey, statusParams); //
break;
case 'info': //
console.log('后端信息:', message.payload);
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${message.payload}\x1b[0m`);
break;
case 'error': //
console.error('后端错误:', message.payload);
connectionStatus.value = 'error';
statusMessage.value = getStatusText('error', { message: message.payload });
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${message.payload}\x1b[0m`);
break;
// --- Handle Status Updates ---
case 'status_update': // Corrected message type
// console.log(':', message.payload.status); // Debug log
// Ensure payload and status exist before assigning
if (message.payload && message.payload.status) {
serverStatus.value = message.payload.status; // Assign the nested status object
statusError.value = null; // Clear previous error on successful update
} else {
console.warn('WorkspaceView: Received status_update message with missing payload.status');
}
break;
// Optional: Handle status errors if backend sends them
// case 'ssh:status:error':
// console.error(':', message.payload);
// statusError.value = message.payload || '';
// serverStatus.value = null; // Clear status data on error
// break;
// default: // Removed default case to allow other components to handle messages
// console.warn('WorkspaceView: WebSocket :', message.type);
}
} catch (e) {
console.error('处理 WebSocket 消息时出错:', e);
// JSON
if (terminalInstance.value && typeof event.data === 'string') {
terminalInstance.value.write(event.data);
}
}
};
ws.value.onerror = (error) => {
console.error('WebSocket 错误:', error);
connectionStatus.value = 'error';
statusMessage.value = getStatusText('wsError');
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsErrorMsg')}\x1b[0m`);
};
ws.value.onclose = (event) => {
console.log('WebSocket 连接已关闭:', event.code, event.reason);
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('wsClosed', { code: event.code });
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsCloseMsg', { code: event.code })}\x1b[0m`);
}
ws.value = null; //
serverStatus.value = null; // Clear server status on disconnect
statusError.value = null; // Clear status error on disconnect
};
};
// --- ---
onMounted(() => {
if (connectionId.value) {
initializeWebSocketConnection();
const wsUrl = `ws://${window.location.hostname}:3001`; // WebSocket URL
connect(wsUrl, connectionId.value); // 使 WebSocket connect
// isConnected true
// registerSshHandlers();
// registerStatusHandlers();
} else {
statusMessage.value = getStatusText('error', { message: '缺少连接 ID' });
connectionStatus.value = 'error';
console.error('WorkspaceView: 缺少 connectionId 路由参数。');
console.error('[工作区视图] 缺少 connectionId 路由参数。');
}
});
onBeforeUnmount(() => {
if (ws.value) {
console.log('组件卸载,关闭 WebSocket 连接...');
ws.value.close();
}
disconnect(); // 使 WebSocket disconnect
unregisterAllSshHandlers(); // SSH
unregisterAllStatusHandlers(); // 使
});
</script>
// connectionId ()
watch(connectionId, (newId, oldId) => {
if (newId && newId !== oldId) {
console.log(`[工作区视图] 连接 ID 从 ${oldId} 更改为 ${newId}。正在重新连接...`);
// ID
disconnect();
unregisterAllSshHandlers();
unregisterAllStatusHandlers(); // 使
// serverStatus statusError useStatusMonitor
//
const wsUrl = `ws://${window.location.hostname}:3001`;
connect(wsUrl, newId);
// registerSshHandlers(); // isConnected watch
// registerStatusHandlers(); // isConnected watch
} else if (!newId && oldId) {
//
disconnect(); // isConnected false
// unregisterAllSshHandlers(); // isConnected watch
// unregisterAllStatusHandlers(); // isConnected watch
}
});
// WebSocket /
watch(isConnected, (connected) => {
if (connected) {
console.log('[工作区视图] WebSocket 已连接,注册 SSH 和状态处理器。');
registerSshHandlers();
registerStatusHandlers();
} else {
console.log('[工作区视图] WebSocket 已断开,注销 SSH 和状态处理器。');
// isConnected false
unregisterAllSshHandlers();
unregisterAllStatusHandlers();
// disconnect() connectionId
// isConnected false
}
});
// ( useSshTerminal)
</script>
<template>
<div class="workspace-view">
@@ -234,20 +117,23 @@ onBeforeUnmount(() => {
<!-- 使用 t 函数渲染状态栏文本 -->
{{ t('workspace.statusBar', { status: statusMessage, id: connectionId }) }}
<!-- 状态颜色仍然通过 class 绑定 -->
<!-- 使用来自 useWebSocketConnection 的状态 -->
<span :class="`status-${connectionStatus}`"></span>
</div>
<div class="main-content-area">
<div class="left-pane">
<div class="terminal-wrapper">
<!-- 将事件绑定到 useSshTerminal 的处理函数 -->
<TerminalComponent
@ready="onTerminalReady"
@data="onTerminalData"
@resize="onTerminalResize"
@ready="handleTerminalReady"
@data="handleTerminalData"
@resize="handleTerminalResize"
/>
</div>
<!-- 文件管理器窗格 -->
<div class="file-manager-wrapper">
<FileManagerComponent :ws="ws" :is-connected="connectionStatus === 'connected'" />
<!-- Removed :ws prop. Communication will be handled via composables -->
<FileManagerComponent :is-connected="isConnected" />
</div>
</div>
<!-- 状态监控窗格 -->