update
This commit is contained in:
@@ -50,14 +50,14 @@ export class StatusMonitorService {
|
||||
startStatusPolling(sessionId: string, interval: number = DEFAULT_POLLING_INTERVAL): void {
|
||||
const state = this.clientStates.get(sessionId);
|
||||
if (!state || !state.sshClient) {
|
||||
console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
|
||||
//console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
|
||||
return;
|
||||
}
|
||||
if (state.statusIntervalId) {
|
||||
console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
|
||||
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
|
||||
return;
|
||||
}
|
||||
console.log(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
|
||||
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
|
||||
this.fetchAndSendServerStatus(sessionId); // 立即执行一次
|
||||
state.statusIntervalId = setInterval(() => {
|
||||
this.fetchAndSendServerStatus(sessionId);
|
||||
@@ -71,7 +71,7 @@ export class StatusMonitorService {
|
||||
stopStatusPolling(sessionId: string): void {
|
||||
const state = this.clientStates.get(sessionId);
|
||||
if (state?.statusIntervalId) {
|
||||
console.log(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`);
|
||||
//console.warn(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`);
|
||||
clearInterval(state.statusIntervalId);
|
||||
state.statusIntervalId = undefined;
|
||||
previousNetStats.delete(sessionId); // 清理网络统计缓存
|
||||
@@ -85,7 +85,7 @@ export class StatusMonitorService {
|
||||
private async fetchAndSendServerStatus(sessionId: string): Promise<void> {
|
||||
const state = this.clientStates.get(sessionId);
|
||||
if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`);
|
||||
//console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`);
|
||||
this.stopStatusPolling(sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export class StatusMonitorService {
|
||||
const status = await this.fetchServerStatus(state.sshClient, sessionId);
|
||||
state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } }));
|
||||
} catch (error: any) {
|
||||
console.error(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error);
|
||||
//console.warn(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error);
|
||||
state.ws.send(JSON.stringify({ type: 'status_error', payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` } }));
|
||||
}
|
||||
}
|
||||
@@ -308,13 +308,13 @@ export class StatusMonitorService {
|
||||
stream.on('close', (code: number, signal?: string) => {
|
||||
// Don't reject on non-zero exit code, as some commands might return non-zero normally
|
||||
// if (code !== 0) {
|
||||
// console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`);
|
||||
// //console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`);
|
||||
// }
|
||||
resolve(output.trim());
|
||||
}).on('data', (data: Buffer) => {
|
||||
output += data.toString('utf8');
|
||||
}).stderr.on('data', (data: Buffer) => {
|
||||
console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
|
||||
//console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="status-monitor">
|
||||
<h4>服务器状态</h4>
|
||||
<div v-if="!statusData" class="loading-status">
|
||||
<div v-if="!serverStatus" class="loading-status">
|
||||
等待数据...
|
||||
</div>
|
||||
<div v-else class="status-grid">
|
||||
@@ -19,14 +19,14 @@
|
||||
<div class="status-item">
|
||||
<label>CPU:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.cpuPercent ?? 0}%` }"></div>
|
||||
<div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span>{{ statusData.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
||||
<span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>内存:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.memPercent ?? 0}%` }"></div>
|
||||
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ memDisplay }}</span>
|
||||
</div>
|
||||
@@ -34,25 +34,25 @@
|
||||
<div class="status-item">
|
||||
<label>Swap:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar swap-bar" :style="{ width: `${statusData.swapPercent ?? 0}%` }"></div>
|
||||
<div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>磁盘 (/):</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.diskPercent ?? 0}%` }"></div>
|
||||
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item network-rate">
|
||||
<label>网络 ({{ statusData.netInterface || '...' }}):</label>
|
||||
<span class="rate down">⬇ {{ formatBytesPerSecond(statusData.netRxRate) }}</span>
|
||||
<span class="rate up">⬆ {{ formatBytesPerSecond(statusData.netTxRate) }}</span>
|
||||
<label>网络 ({{ serverStatus.netInterface || '...' }}):</label>
|
||||
<span class="rate down">⬇ {{ formatBytesPerSecond(serverStatus.netRxRate) }}</span>
|
||||
<span class="rate up">⬆ {{ formatBytesPerSecond(serverStatus.netTxRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="status-error">
|
||||
错误: {{ error }}
|
||||
<div v-if="statusError" class="status-error">
|
||||
错误: {{ statusError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,18 +79,19 @@ interface ServerStatus {
|
||||
osName?: string; // 操作系统名称
|
||||
}
|
||||
|
||||
// Props 用于接收父组件传递的状态数据和错误信息
|
||||
// 更新 Props 定义
|
||||
const props = defineProps<{
|
||||
statusData: ServerStatus | null;
|
||||
error?: string | null;
|
||||
sessionId: string; // 添加会话 ID
|
||||
serverStatus: ServerStatus | null; // 更改名称从 statusData 到 serverStatus
|
||||
statusError?: string | null; // 更改名称从 error 到 statusError
|
||||
}>();
|
||||
|
||||
// --- 缓存状态 ---
|
||||
const cachedCpuModel = ref<string | null>(null);
|
||||
const cachedOsName = ref<string | null>(null);
|
||||
|
||||
// 监听传入的 statusData 变化以更新缓存
|
||||
watch(() => props.statusData, (newData) => {
|
||||
// 监听传入的 serverStatus 变化以更新缓存 (更新引用)
|
||||
watch(() => props.serverStatus, (newData) => {
|
||||
if (newData) {
|
||||
// 仅当新数据有效时更新缓存
|
||||
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
|
||||
@@ -103,15 +104,15 @@ watch(() => props.statusData, (newData) => {
|
||||
// 如果 newData 为 null (例如断开连接),不清除缓存
|
||||
}, { immediate: true }); // 立即执行一次以初始化缓存
|
||||
|
||||
// --- 显示计算属性 (包含缓存逻辑) ---
|
||||
// --- 显示计算属性 (包含缓存逻辑) - 更新引用 ---
|
||||
const displayCpuModel = computed(() => {
|
||||
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
|
||||
return (props.statusData?.cpuModel ?? cachedCpuModel.value) || 'N/A';
|
||||
return (props.serverStatus?.cpuModel ?? cachedCpuModel.value) || 'N/A';
|
||||
});
|
||||
|
||||
const displayOsName = computed(() => {
|
||||
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
|
||||
return (props.statusData?.osName ?? cachedOsName.value) || 'N/A';
|
||||
return (props.serverStatus?.osName ?? cachedOsName.value) || 'N/A';
|
||||
});
|
||||
|
||||
|
||||
@@ -133,9 +134,9 @@ const formatKbToGb = (kb?: number): string => {
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
// 计算属性用于显示内存信息
|
||||
// 计算属性用于显示内存信息 (更新引用)
|
||||
const memDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
const data = props.serverStatus;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A'; // 检查数据有效性
|
||||
const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : '';
|
||||
// 确保 MB 值在是整数时不显示小数
|
||||
@@ -144,9 +145,9 @@ const memDisplay = computed(() => {
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
// 计算属性用于显示磁盘信息
|
||||
// 计算属性用于显示磁盘信息 (更新引用)
|
||||
const diskDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
const data = props.serverStatus;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A'; // 检查数据有效性
|
||||
// 百分比代表已用空间
|
||||
const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : '';
|
||||
@@ -154,9 +155,9 @@ const diskDisplay = computed(() => {
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`;
|
||||
});
|
||||
|
||||
// 计算属性用于显示 Swap 信息
|
||||
// 计算属性用于显示 Swap 信息 (更新引用)
|
||||
const swapDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
const data = props.serverStatus;
|
||||
// 处理 swap 可能为 undefined 或 0 的情况
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'xterm/css/xterm.css'; // 引入 xterm 样式
|
||||
|
||||
// 定义 props 和 emits
|
||||
const props = defineProps<{
|
||||
sessionId: string; // 会话 ID
|
||||
stream?: ReadableStream<string>; // 用于接收来自 WebSocket 的数据流 (可选)
|
||||
options?: object; // xterm 的配置选项
|
||||
}>();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
|
||||
// 定义会话状态的简化接口 (仅包含标签栏需要的信息)
|
||||
interface SessionTabInfo {
|
||||
sessionId: string;
|
||||
connectionName: string; // 显示在标签上的名称
|
||||
}
|
||||
|
||||
// 定义 Props
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array as PropType<SessionTabInfo[]>,
|
||||
required: true,
|
||||
},
|
||||
activeSessionId: {
|
||||
type: String as PropType<string | null>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['activate-session', 'close-session']);
|
||||
|
||||
const activateSession = (sessionId: string) => {
|
||||
if (sessionId !== props.activeSessionId) {
|
||||
emit('activate-session', sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const closeSession = (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation(); // 阻止事件冒泡到标签点击事件
|
||||
emit('close-session', sessionId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-tab-bar">
|
||||
<ul class="tab-list">
|
||||
<li
|
||||
v-for="session in sessions"
|
||||
:key="session.sessionId"
|
||||
:class="['tab-item', { active: session.sessionId === activeSessionId }]"
|
||||
@click="activateSession(session.sessionId)"
|
||||
:title="session.connectionName"
|
||||
>
|
||||
<span class="tab-name">{{ session.connectionName }}</span>
|
||||
<button class="close-tab-button" @click="closeSession($event, session.sessionId)" title="关闭标签页">
|
||||
× <!-- 使用 HTML 实体 '×' -->
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 可以添加一个 "+" 按钮来打开新标签/连接 -->
|
||||
<!-- <button class="add-tab-button">+</button> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-tab-bar {
|
||||
display: flex;
|
||||
background-color: #e0e0e0; /* 标签栏背景色 */
|
||||
border-bottom: 1px solid #bdbdbd;
|
||||
overflow-x: auto; /* 如果标签过多则允许水平滚动 */
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5rem; /* 左右留出一点空间 */
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid #bdbdbd;
|
||||
background-color: #f0f0f0; /* 未激活标签背景 */
|
||||
color: #616161; /* 未激活标签文字颜色 */
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
max-width: 200px; /* 限制标签最大宽度 */
|
||||
position: relative; /* 为了关闭按钮定位 */
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background-color: #e0e0e0; /* 悬停时背景 */
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background-color: #ffffff; /* 激活标签背景 */
|
||||
color: #333333; /* 激活标签文字颜色 */
|
||||
border-bottom: 1px solid #ffffff; /* 覆盖底部边框,使其看起来与下方内容区域连接 */
|
||||
position: relative;
|
||||
z-index: 1; /* 确保激活标签在上方 */
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.8rem; /* 与关闭按钮的间距 */
|
||||
}
|
||||
|
||||
.close-tab-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9e9e9e;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 0 0.3rem;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
margin-left: auto; /* 将按钮推到右侧 */
|
||||
}
|
||||
|
||||
.close-tab-button:hover {
|
||||
background-color: #bdbdbd;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-item.active .close-tab-button {
|
||||
color: #757575; /* 激活标签上的关闭按钮颜色 */
|
||||
}
|
||||
.tab-item.active .close-tab-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 可选:添加新标签按钮样式 */
|
||||
/*
|
||||
.add-tab-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
color: #616161;
|
||||
}
|
||||
.add-tab-button:hover {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
*/
|
||||
</style>
|
||||
@@ -7,7 +7,12 @@ import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store
|
||||
import { useTagsStore, TagInfo } from '../stores/tags.store';
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['connect-request', 'request-add-connection', 'request-edit-connection']); // 添加新事件
|
||||
const emit = defineEmits([
|
||||
'connect-request', // 左键单击 - 请求激活或替换当前标签
|
||||
'open-new-session', // 中键单击 - 请求在新标签中打开
|
||||
'request-add-connection', // 右键菜单 - 添加
|
||||
'request-edit-connection' // 右键菜单 - 编辑
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
// const router = useRouter(); // 不再需要
|
||||
@@ -127,6 +132,12 @@ onMounted(() => {
|
||||
connectionsStore.fetchConnections();
|
||||
tagsStore.fetchTags();
|
||||
});
|
||||
|
||||
// 处理中键点击(在新标签页打开)
|
||||
const handleOpenInNewTab = (connectionId: number) => {
|
||||
emit('open-new-session', connectionId);
|
||||
closeContextMenu(); // 如果右键菜单是打开的,也关闭它
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -156,7 +167,9 @@ onMounted(() => {
|
||||
v-for="conn in group.connections"
|
||||
:key="conn.id"
|
||||
class="connection-item"
|
||||
@click="handleConnect(conn.id)"
|
||||
@click.left="handleConnect(conn.id)"
|
||||
@click.middle.prevent="handleOpenInNewTab(conn.id)"
|
||||
@auxclick.prevent="handleOpenInNewTab(conn.id)"
|
||||
@contextmenu.prevent="showContextMenu($event, conn)"
|
||||
>
|
||||
<i class="fas fa-server connection-icon"></i>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, reactive, nextTick, onUnmounted, type Ref } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
|
||||
import { ref, reactive, nextTick, onUnmounted, readonly, type Ref } from 'vue'; // 再次修正导入,移除大写 Readonly
|
||||
import { createWebSocketConnectionManager } from './useWebSocketConnection'; // 导入工厂函数
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入
|
||||
import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入
|
||||
@@ -20,14 +20,16 @@ const joinPath = (base: string, name: string): string => {
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
|
||||
export function useFileUploader(
|
||||
currentPathRef: Ref<string>,
|
||||
fileListRef: Ref<Readonly<FileListItem[]>>, // 传入 fileList 用于检查覆盖
|
||||
refreshDirectory: () => void // 上传成功后刷新目录的回调函数
|
||||
fileListRef: Readonly<Ref<readonly FileListItem[]>>, // 使用 Readonly 类型
|
||||
refreshDirectory: () => void, // 上传成功后刷新目录的回调函数
|
||||
sessionId: string,
|
||||
dbConnectionId: string
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
|
||||
// 使用工厂函数创建WebSocket连接管理器,并传入t函数
|
||||
const { sendMessage, onMessage, isConnected } = createWebSocketConnectionManager(sessionId, dbConnectionId, t);
|
||||
|
||||
// 对 uploads 字典使用 reactive 以获得更好的深度响应性
|
||||
const uploads = reactive<Record<string, UploadItem>>({});
|
||||
@@ -128,8 +130,8 @@ export function useFileUploader(
|
||||
const remotePath = joinPath(currentPathRef.value, file.name);
|
||||
|
||||
// 使用传入的 fileListRef 检查是否覆盖
|
||||
// 为 item 添加显式类型 FileListItem
|
||||
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) {
|
||||
// fileListRef.value 现在是 readonly FileListItem[]
|
||||
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) { // 添加 item 类型注解
|
||||
if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) {
|
||||
console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`);
|
||||
return; // 用户取消覆盖
|
||||
|
||||
@@ -1,43 +1,68 @@
|
||||
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'; // 从类型文件导入
|
||||
import { ref, readonly, type Ref, type ComputedRef } from 'vue'; // Removed onUnmounted, added ComputedRef
|
||||
import type { FileListItem, EditorFileContent } from '../types/sftp.types';
|
||||
import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 从类型文件导入
|
||||
|
||||
// --- 接口定义 (已移至 sftp.types.ts) ---
|
||||
/**
|
||||
* @interface WebSocketDependencies
|
||||
* @description Defines the necessary functions and state required from a WebSocket manager instance.
|
||||
*/
|
||||
export interface WebSocketDependencies {
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
onMessage: (type: string, handler: MessageHandler) => () => void;
|
||||
isConnected: ComputedRef<boolean>;
|
||||
isSftpReady: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
const generateRequestId = (): string => {
|
||||
return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
// Helper function
|
||||
const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
// Helper function
|
||||
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}`;
|
||||
return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`;
|
||||
};
|
||||
|
||||
// Helper function (Copied from FileManager.vue)
|
||||
// Helper function
|
||||
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();
|
||||
/**
|
||||
* 创建并管理单个 SFTP 会话的操作。
|
||||
* 每个实例对应一个会话 (Session) 并依赖于一个 WebSocket 管理器实例。
|
||||
*
|
||||
* @param {string} sessionId - 此 SFTP 管理器关联的会话 ID (用于日志记录)。
|
||||
* @param {Ref<string>} currentPathRef - 一个外部 ref,用于跟踪和更新当前目录路径。
|
||||
* @param {WebSocketDependencies} wsDeps - 从对应的 WebSocket 管理器实例注入的依赖项。
|
||||
* @param {Function} t - i18n 翻译函数,从父组件传入
|
||||
* @returns 一个包含状态、方法和清理函数的 SFTP 操作管理器对象。
|
||||
*/
|
||||
export function createSftpActionsManager(
|
||||
sessionId: string,
|
||||
currentPathRef: Ref<string>,
|
||||
wsDeps: WebSocketDependencies,
|
||||
t: Function
|
||||
) {
|
||||
const { sendMessage, onMessage, isConnected, isSftpReady } = wsDeps; // 使用注入的依赖
|
||||
|
||||
const fileList = ref<FileListItem[]>([]);
|
||||
const isLoading = ref<boolean>(false);
|
||||
const error = ref<string | null>(null);
|
||||
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
|
||||
|
||||
// Function to clear the error state
|
||||
// 用于存储注销函数的数组
|
||||
const unregisterCallbacks: (() => void)[] = [];
|
||||
|
||||
// 清理函数,用于注销所有消息处理器
|
||||
const cleanup = () => {
|
||||
console.log(`[SFTP ${instanceSessionId}] Cleaning up message handlers.`);
|
||||
unregisterCallbacks.forEach(cb => cb());
|
||||
unregisterCallbacks.length = 0; // 清空数组
|
||||
};
|
||||
|
||||
// 清除错误状态的函数
|
||||
const clearSftpError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
@@ -45,42 +70,37 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
// --- 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
|
||||
error.value = t('fileManager.errors.sftpNotReady');
|
||||
isLoading.value = false;
|
||||
fileList.value = []; // Clear list if not ready
|
||||
console.warn(`[useSftpActions] Attempted to load directory ${path} but SFTP is not ready.`);
|
||||
fileList.value = [];
|
||||
console.warn(`[SFTP ${instanceSessionId}] 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}`);
|
||||
console.log(`[SFTP ${instanceSessionId}] Loading directory: ${path}`);
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
currentPathRef.value = path; // Update the external ref passed in
|
||||
currentPathRef.value = path; // 更新外部 ref
|
||||
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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] Attempted to create file ${newFileName} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
const newFilePath = joinPath(currentPathRef.value, newFileName);
|
||||
@@ -88,15 +108,14 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
sendMessage({
|
||||
type: 'sftp:writefile',
|
||||
requestId: requestId,
|
||||
payload: { path: newFilePath, content: '', encoding: 'utf8' } // Create by writing empty content
|
||||
payload: { path: newFilePath, content: '', encoding: 'utf8' }
|
||||
});
|
||||
// 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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] Attempted to delete items but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return;
|
||||
@@ -106,13 +125,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] Attempted to rename item ${item.filename} but SFTP is not ready.`);
|
||||
return;
|
||||
}
|
||||
if (!newName || item.filename === newName) return;
|
||||
@@ -120,82 +138,96 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 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 类型
|
||||
// readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销
|
||||
const readFile = (path: string): Promise<EditorFileContent> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSftpReady.value) {
|
||||
console.warn(`[useSftpActions] Attempted to read file ${path} but SFTP is not ready.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] Attempted to read file ${path} but SFTP is not ready.`);
|
||||
return reject(new Error(t('fileManager.errors.sftpNotReady')));
|
||||
}
|
||||
const requestId = generateRequestId();
|
||||
let unregisterSuccess: (() => void) | null = null;
|
||||
let unregisterError: (() => void) | null = null;
|
||||
|
||||
const unregisterSuccess = onMessage('sftp:readfile:success', (payload, message) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(t('fileManager.errors.readFileTimeout')));
|
||||
}, 20000); // 20 秒超时
|
||||
|
||||
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 确保 payload 是期望的类型
|
||||
const successPayload = payload as { content: string; encoding: 'utf8' | 'base64' };
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
resolve({ content: payload.content, encoding: payload.encoding });
|
||||
resolve({ content: successPayload.content, encoding: successPayload.encoding });
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterError = onMessage('sftp:readfile:error', (payload, message) => {
|
||||
unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 确保 payload 是期望的类型 (string)
|
||||
const errorPayload = payload as string;
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(payload || 'Failed to read file'));
|
||||
reject(new Error(errorPayload || '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.`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 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 encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
|
||||
let unregisterSuccess: (() => void) | null = null;
|
||||
let unregisterError: (() => void) | null = null;
|
||||
|
||||
const unregisterSuccess = onMessage('sftp:writefile:success', (payload, message) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(t('fileManager.errors.saveTimeout')));
|
||||
}, 20000); // 20 秒超时
|
||||
|
||||
unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterError = onMessage('sftp:writefile:error', (payload, message) => {
|
||||
unregisterError = onMessage('sftp:writefile:error', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 确保 payload 是期望的类型 (string)
|
||||
const errorPayload = payload as string;
|
||||
if (message.requestId === requestId && message.path === path) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(payload || 'Failed to write file'));
|
||||
reject(new Error(errorPayload || 'Failed to write file'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,112 +236,80 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
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
|
||||
const onSftpReaddirSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 类型断言,因为我们知道 readdir:success 的 payload 是 FileListItem[]
|
||||
const fileListPayload = payload as FileListItem[];
|
||||
if (message.path === currentPathRef.value) {
|
||||
console.log(`[useSftpActions] Received file list for ${message.path}`);
|
||||
fileList.value = payload.sort(sortFiles);
|
||||
console.log(`[SFTP ${instanceSessionId}] Received file list for ${message.path}`);
|
||||
fileList.value = fileListPayload.sort(sortFiles);
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
} else {
|
||||
console.log(`[useSftpActions] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
|
||||
console.log(`[SFTP ${instanceSessionId}] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
|
||||
}
|
||||
};
|
||||
|
||||
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => {
|
||||
const onSftpReaddirError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 类型断言,因为我们知道 readdir:error 的 payload 是 string
|
||||
const errorPayload = payload as string;
|
||||
if (message.path === currentPathRef.value) {
|
||||
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload);
|
||||
error.value = payload; // Set the error message
|
||||
console.error(`[SFTP ${instanceSessionId}] Error loading directory ${message.path}:`, errorPayload);
|
||||
error.value = errorPayload;
|
||||
isLoading.value = false;
|
||||
// Do NOT clear fileList.value here, keep the previous list visible
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
console.log(`[SFTP ${instanceSessionId}] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`);
|
||||
loadDirectory(currentPathRef.value);
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
// 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 onActionError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// 类型断言,因为我们知道这些错误的 payload 是 string
|
||||
const errorPayload = payload as string;
|
||||
console.error(`[SFTP ${instanceSessionId}] Action ${message.type} failed:`, errorPayload);
|
||||
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
|
||||
'sftp:writefile:error': t('fileManager.errors.saveFailed'),
|
||||
};
|
||||
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
|
||||
error.value = `${prefix}: ${payload}`;
|
||||
// Optionally stop loading indicator if one was active for this action
|
||||
error.value = `${prefix}: ${errorPayload}`;
|
||||
};
|
||||
|
||||
// --- Register Handlers ---
|
||||
const unregisterReaddirSuccess = onMessage('sftp:readdir:success', onSftpReaddirSuccess);
|
||||
const unregisterReaddirError = onMessage('sftp:readdir:error', onSftpReaddirError);
|
||||
// --- Register Handlers & Store Unregister Callbacks ---
|
||||
unregisterCallbacks.push(onMessage('sftp:readdir:success', onSftpReaddirSuccess));
|
||||
unregisterCallbacks.push(onMessage('sftp:readdir:error', onSftpReaddirError));
|
||||
unregisterCallbacks.push(onMessage('sftp:mkdir:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:rmdir:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:unlink:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:rename:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:chmod:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:writefile:success', onActionSuccessRefresh));
|
||||
unregisterCallbacks.push(onMessage('sftp:mkdir:error', onActionError));
|
||||
unregisterCallbacks.push(onMessage('sftp:rmdir:error', onActionError));
|
||||
unregisterCallbacks.push(onMessage('sftp:unlink:error', onActionError));
|
||||
unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError));
|
||||
unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError));
|
||||
unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError));
|
||||
|
||||
// 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
|
||||
});
|
||||
// 移除 onUnmounted 块
|
||||
|
||||
return {
|
||||
// State
|
||||
fileList: readonly(fileList),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
// currentPath: readonly(currentPath), // Path is managed via the passed ref
|
||||
|
||||
// Methods
|
||||
loadDirectory,
|
||||
@@ -318,9 +318,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
deleteItems,
|
||||
renameItem,
|
||||
changePermissions,
|
||||
readFile, // Expose if needed by editor composable
|
||||
writeFile, // Expose if needed by editor composable
|
||||
joinPath, // Expose helper if needed externally
|
||||
clearSftpError, // Expose the clear error function
|
||||
readFile,
|
||||
writeFile,
|
||||
joinPath, // 暴露辅助函数
|
||||
clearSftpError,
|
||||
|
||||
// Cleanup function
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { ref, onUnmounted, type Ref } from 'vue';
|
||||
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook 本身
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, readonly, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly
|
||||
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
|
||||
import type { Terminal } from 'xterm';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
|
||||
export function useSshTerminal() {
|
||||
const { t } = useI18n();
|
||||
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
|
||||
// 定义与 WebSocket 相关的依赖接口
|
||||
export interface SshTerminalDependencies {
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
|
||||
isConnected: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 SSH 终端管理器实例
|
||||
* @param sessionId 会话唯一标识符
|
||||
* @param wsDeps WebSocket 依赖对象
|
||||
* @param t i18n 翻译函数,从父组件传入
|
||||
* @returns SSH 终端管理器实例
|
||||
*/
|
||||
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: Function) {
|
||||
// 使用依赖注入的 WebSocket 函数
|
||||
const { sendMessage, onMessage, isConnected } = wsDeps;
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null);
|
||||
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
@@ -22,7 +35,7 @@ export function useSshTerminal() {
|
||||
// --- 终端事件处理 ---
|
||||
|
||||
const handleTerminalReady = (term: Terminal) => {
|
||||
console.log('[SSH终端模块] 终端实例已就绪。');
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`);
|
||||
terminalInstance.value = term;
|
||||
// 将缓冲区的输出写入终端
|
||||
terminalOutputBuffer.value.forEach(data => term.write(data));
|
||||
@@ -32,31 +45,36 @@ export function useSshTerminal() {
|
||||
};
|
||||
|
||||
const handleTerminalData = (data: string) => {
|
||||
// console.debug('[SSH终端模块] 接收到终端输入:', data);
|
||||
sendMessage({ type: 'ssh:input', payload: { data } });
|
||||
// console.debug(`[会话 ${sessionId}][SSH终端模块] 接收到终端输入:`, data);
|
||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||
};
|
||||
|
||||
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
console.log('[SSH终端模块] 发送终端大小调整:', dimensions);
|
||||
sendMessage({ type: 'ssh:resize', payload: dimensions });
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 发送终端大小调整:`, dimensions);
|
||||
sendMessage({ type: 'ssh:resize', sessionId, payload: dimensions });
|
||||
};
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
|
||||
const handleSshOutput = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const handleSshOutput = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
let outputData = payload;
|
||||
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
|
||||
if (message.encoding === 'base64' && typeof outputData === 'string') {
|
||||
if (message?.encoding === 'base64' && typeof outputData === 'string') {
|
||||
try {
|
||||
outputData = atob(outputData); // 在浏览器环境中使用 atob
|
||||
} catch (e) {
|
||||
console.error('[SSH终端模块] Base64 解码失败:', e, '原始数据:', message.payload);
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, e, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
|
||||
}
|
||||
}
|
||||
// 如果不是 base64 或解码失败,确保它是字符串
|
||||
else if (typeof outputData !== 'string') {
|
||||
console.warn('[SSH终端模块] 收到非字符串 ssh:output payload:', outputData);
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
|
||||
try {
|
||||
outputData = JSON.stringify(outputData); // 尝试序列化
|
||||
} catch {
|
||||
@@ -64,6 +82,19 @@ export function useSshTerminal() {
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试过滤掉非标准的 OSC 184 序列
|
||||
// 注意:这个正则表达式可能需要根据实际序列进行调整
|
||||
// 它尝试匹配 \x1b]184;... 直到 \x1b\\ 或 \x07
|
||||
const osc184Regex = /\x1b]184;[^\x1b\x07]*(\x1b\\|\x07)/g;
|
||||
if (typeof outputData === 'string') {
|
||||
const originalLength = outputData.length;
|
||||
outputData = outputData.replace(osc184Regex, '');
|
||||
if (outputData.length < originalLength) {
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] 过滤掉 OSC 184 序列。`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (terminalInstance.value) {
|
||||
terminalInstance.value.write(outputData);
|
||||
} else {
|
||||
@@ -72,50 +103,80 @@ export function useSshTerminal() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshConnected = () => {
|
||||
console.log('[SSH终端模块] SSH 会话已连接。');
|
||||
const handleSshConnected = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。`);
|
||||
// 连接成功后聚焦终端
|
||||
terminalInstance.value?.focus();
|
||||
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
|
||||
if (terminalOutputBuffer.value.length > 0) {
|
||||
console.warn('[SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...');
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
|
||||
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
|
||||
terminalOutputBuffer.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshDisconnected = (payload: MessagePayload) => {
|
||||
const handleSshDisconnected = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
|
||||
console.log('[SSH终端模块] SSH 会话已断开:', reason);
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
||||
// 可以在这里添加其他清理逻辑,例如禁用输入
|
||||
};
|
||||
|
||||
const handleSshError = (payload: MessagePayload) => {
|
||||
const handleSshError = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
|
||||
console.error('[SSH终端模块] SSH 错误:', errorMsg);
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleSshStatus = (payload: MessagePayload) => {
|
||||
const handleSshStatus = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
|
||||
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
|
||||
const statusKey = payload?.key || 'unknown';
|
||||
const statusParams = payload?.params || {};
|
||||
console.log('[SSH终端模块] 收到 SSH 状态更新:', statusKey, statusParams);
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 收到 SSH 状态更新:`, statusKey, statusParams);
|
||||
// 可以在终端打印一些状态信息吗?
|
||||
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleInfoMessage = (payload: MessagePayload) => {
|
||||
console.log('[SSH终端模块] 收到后端信息:', payload);
|
||||
const handleInfoMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 收到后端信息:`, payload);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleErrorMessage = (payload: MessagePayload) => {
|
||||
const handleErrorMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
// 通用错误也可能需要显示在终端
|
||||
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
|
||||
console.error('[SSH终端模块] 收到后端通用错误:', errorMsg);
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] 收到后端通用错误:`, errorMsg);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
|
||||
};
|
||||
|
||||
@@ -131,30 +192,62 @@ export function useSshTerminal() {
|
||||
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
|
||||
unregisterHandlers.push(onMessage('info', handleInfoMessage));
|
||||
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
|
||||
console.log('[SSH终端模块] 已注册 SSH 相关消息处理器。');
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已注册 SSH 相关消息处理器。`);
|
||||
};
|
||||
|
||||
const unregisterAllSshHandlers = () => {
|
||||
console.log('[SSH终端模块] 注销 SSH 相关消息处理器...');
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 注销 SSH 相关消息处理器...`);
|
||||
unregisterHandlers.forEach(unregister => unregister?.());
|
||||
unregisterHandlers.length = 0; // 清空数组
|
||||
};
|
||||
|
||||
// --- 清理 ---
|
||||
onUnmounted(() => {
|
||||
// 初始化时自动注册处理程序
|
||||
registerSshHandlers();
|
||||
|
||||
// --- 清理函数 ---
|
||||
const cleanup = () => {
|
||||
unregisterAllSshHandlers();
|
||||
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
|
||||
terminalInstance.value = null;
|
||||
console.log('[SSH终端模块] Composable 已卸载。');
|
||||
});
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
|
||||
};
|
||||
|
||||
// --- 暴露给组件的接口 ---
|
||||
// 返回工厂实例
|
||||
return {
|
||||
terminalInstance, // 暴露终端实例 ref,以便组件可以访问(如果需要)
|
||||
// 公共接口
|
||||
handleTerminalReady,
|
||||
handleTerminalData,
|
||||
handleTerminalResize,
|
||||
registerSshHandlers, // 暴露注册函数,由父组件在连接后调用
|
||||
unregisterAllSshHandlers, // 暴露注销函数,在断开或卸载时调用
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
// 保留兼容旧代码的函数(将在完全迁移后移除)
|
||||
export function useSshTerminal(t: (key: string) => string) {
|
||||
console.warn('⚠️ 使用已弃用的 useSshTerminal() 全局单例。请迁移到 createSshTerminalManager() 工厂函数。');
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null);
|
||||
|
||||
const handleTerminalReady = (term: Terminal) => {
|
||||
console.log('[SSH终端模块][旧] 终端实例已就绪,但使用了已弃用的单例模式。');
|
||||
terminalInstance.value = term;
|
||||
};
|
||||
|
||||
const handleTerminalData = (data: string) => {
|
||||
console.warn('[SSH终端模块][旧] 收到终端数据,但使用了已弃用的单例模式,无法发送。');
|
||||
};
|
||||
|
||||
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
console.warn('[SSH终端模块][旧] 收到终端大小调整,但使用了已弃用的单例模式,无法发送。');
|
||||
};
|
||||
|
||||
// 返回与旧接口兼容的空函数,以避免错误
|
||||
return {
|
||||
terminalInstance,
|
||||
handleTerminalReady,
|
||||
handleTerminalData,
|
||||
handleTerminalResize,
|
||||
registerSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 registerSshHandlers'),
|
||||
unregisterAllSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 unregisterAllSshHandlers'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
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'; // 从类型文件导入
|
||||
import { ref, readonly, watch, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly, 添加 watch
|
||||
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
|
||||
import type { ServerStatus } from '../types/server.types';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
|
||||
// --- 接口定义 (已移至 server.types.ts) ---
|
||||
// 定义与 WebSocket 相关的依赖接口
|
||||
export interface StatusMonitorDependencies {
|
||||
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
|
||||
isConnected: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
export function useStatusMonitor() {
|
||||
const { onMessage, isConnected } = useWebSocketConnection();
|
||||
/**
|
||||
* 创建一个状态监控管理器实例
|
||||
* @param sessionId 会话唯一标识符
|
||||
* @param wsDeps WebSocket 依赖对象
|
||||
* @returns 状态监控管理器实例
|
||||
*/
|
||||
export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMonitorDependencies) {
|
||||
const { onMessage, isConnected } = wsDeps;
|
||||
|
||||
const serverStatus = ref<ServerStatus | null>(null);
|
||||
const statusError = ref<string | null>(null); // 存储状态获取错误
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
const handleStatusUpdate = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
// console.debug('[状态监控模块] 收到 status_update:', payload);
|
||||
const handleStatusUpdate = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
// console.debug(`[会话 ${sessionId}][状态监控模块] 收到 status_update:`, JSON.stringify(payload)); // 添加日志
|
||||
if (payload && payload.status) {
|
||||
serverStatus.value = payload.status;
|
||||
statusError.value = null; // 收到有效状态时清除错误
|
||||
} else {
|
||||
console.warn('[状态监控模块] 收到缺少 payload.status 的 status_update 消息');
|
||||
console.warn(`[会话 ${sessionId}][状态监控模块] 收到缺少 payload.status 的 status_update 消息`);
|
||||
// 可以选择设置一个错误状态,表明数据格式不正确
|
||||
// statusError.value = '收到的状态数据格式无效';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理可能的后端状态错误消息 (如果后端会发送的话)
|
||||
const handleStatusError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
console.error('[状态监控模块] 收到状态错误消息:', payload);
|
||||
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
|
||||
serverStatus.value = null; // 出错时清除状态数据
|
||||
const handleStatusError = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
}
|
||||
|
||||
console.error(`[会话 ${sessionId}][状态监控模块] 收到状态错误消息:`, payload);
|
||||
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
|
||||
serverStatus.value = null; // 出错时清除状态数据
|
||||
};
|
||||
|
||||
// --- 注册 WebSocket 消息处理器 ---
|
||||
@@ -36,36 +56,81 @@ export function useStatusMonitor() {
|
||||
let unregisterError: (() => void) | null = null;
|
||||
|
||||
const registerStatusHandlers = () => {
|
||||
// 仅在连接时注册处理器
|
||||
// 防止重复注册
|
||||
if (unregisterUpdate || unregisterError) {
|
||||
console.log(`[会话 ${sessionId}][状态监控模块] 处理器已注册,跳过。`);
|
||||
return;
|
||||
}
|
||||
if (isConnected.value) {
|
||||
console.log('[状态监控模块] 注册状态消息处理器。');
|
||||
console.log(`[会话 ${sessionId}][状态监控模块] 注册状态消息处理器。`);
|
||||
unregisterUpdate = onMessage('status_update', handleStatusUpdate);
|
||||
// 假设后端可能发送 'status:error' 类型的特定错误
|
||||
unregisterError = onMessage('status:error', handleStatusError);
|
||||
} else {
|
||||
console.warn('[状态监控模块] WebSocket 未连接,无法注册状态处理器。');
|
||||
console.warn(`[会话 ${sessionId}][状态监控模块] WebSocket 未连接,无法注册状态处理器。`);
|
||||
}
|
||||
};
|
||||
|
||||
const unregisterAllStatusHandlers = () => {
|
||||
console.log('[状态监控模块] 注销状态消息处理器。');
|
||||
unregisterUpdate?.();
|
||||
unregisterError?.();
|
||||
unregisterUpdate = null;
|
||||
unregisterError = null;
|
||||
if (unregisterUpdate || unregisterError) {
|
||||
console.log(`[会话 ${sessionId}][状态监控模块] 注销状态消息处理器。`);
|
||||
unregisterUpdate?.();
|
||||
unregisterError?.();
|
||||
unregisterUpdate = null;
|
||||
unregisterError = null;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 清理 ---
|
||||
onUnmounted(() => {
|
||||
// 监听连接状态变化以自动注册/注销处理器
|
||||
watch(isConnected, (newValue, oldValue) => {
|
||||
console.log(`[会话 ${sessionId}][状态监控模块] 连接状态变化: ${oldValue} -> ${newValue}`);
|
||||
if (newValue) {
|
||||
registerStatusHandlers();
|
||||
// 连接成功后,可以考虑请求一次初始状态(如果后端支持)
|
||||
// sendMessage({ type: 'status:get', sessionId });
|
||||
} else {
|
||||
unregisterAllStatusHandlers();
|
||||
// 连接断开时清除状态
|
||||
serverStatus.value = null;
|
||||
statusError.value = '连接已断开'; // 或者使用 i18n
|
||||
}
|
||||
}, { immediate: true }); // immediate: true 确保初始状态下也会执行一次
|
||||
|
||||
// --- 清理函数 ---
|
||||
const cleanup = () => {
|
||||
unregisterAllStatusHandlers();
|
||||
console.log('[状态监控模块] Composable 已卸载。');
|
||||
});
|
||||
console.log(`[会话 ${sessionId}][状态监控模块] 已清理。`);
|
||||
};
|
||||
|
||||
// --- 暴露接口 ---
|
||||
return {
|
||||
serverStatus: readonly(serverStatus), // 只读状态
|
||||
statusError: readonly(statusError), // 只读错误状态
|
||||
registerStatusHandlers, // 暴露注册函数
|
||||
unregisterAllStatusHandlers, // 暴露注销函数
|
||||
registerStatusHandlers, // 暴露注册函数,以便在需要时可以重新注册
|
||||
unregisterAllStatusHandlers, // 暴露注销函数,以便在需要时可以手动注销
|
||||
cleanup, // 暴露清理函数,在会话关闭时调用
|
||||
};
|
||||
}
|
||||
|
||||
// 保留兼容旧代码的函数(将在完全迁移后移除)
|
||||
export function useStatusMonitor() {
|
||||
console.warn('⚠️ 使用已弃用的 useStatusMonitor() 全局单例。请迁移到 createStatusMonitorManager() 工厂函数。');
|
||||
|
||||
const serverStatus = ref<ServerStatus | null>(null);
|
||||
const statusError = ref<string | null>(null);
|
||||
|
||||
const registerStatusHandlers = () => {
|
||||
console.warn('[状态监控模块][旧] 调用了已弃用的 registerStatusHandlers');
|
||||
};
|
||||
|
||||
const unregisterAllStatusHandlers = () => {
|
||||
console.warn('[状态监控模块][旧] 调用了已弃用的 unregisterAllStatusHandlers');
|
||||
};
|
||||
|
||||
// 返回与旧接口兼容的空对象,以避免错误
|
||||
return {
|
||||
serverStatus: readonly(serverStatus),
|
||||
statusError: readonly(statusError),
|
||||
registerStatusHandlers,
|
||||
unregisterAllStatusHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,215 +1,223 @@
|
||||
import { ref, shallowRef, onUnmounted, computed, type Ref, readonly } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// 从类型文件导入 WebSocket 相关类型
|
||||
import { ref, shallowRef, computed, readonly } from 'vue';
|
||||
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 = ...;
|
||||
/**
|
||||
* 创建并管理单个 WebSocket 连接实例。
|
||||
* 每个实例对应一个会话 (Session)。
|
||||
*
|
||||
* @param {string} sessionId - 此 WebSocket 连接关联的会话 ID (用于日志记录)。
|
||||
* @param {string} dbConnectionId - 此 WebSocket 连接关联的数据库连接 ID (用于后端识别)。
|
||||
* @param {Function} t - i18n 翻译函数,从父组件传入
|
||||
* @returns 一个包含状态和方法的 WebSocket 连接管理器对象。
|
||||
*/
|
||||
export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: Function) {
|
||||
// --- Instance State ---
|
||||
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
|
||||
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
|
||||
const connectionStatus = ref<ConnectionStatus>('disconnected'); // 连接状态
|
||||
const statusMessage = ref<string>(''); // 状态描述文本
|
||||
const isSftpReady = ref<boolean>(false); // SFTP 是否就绪
|
||||
const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表
|
||||
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
|
||||
const instanceDbConnectionId = dbConnectionId; // 保存数据库连接 ID
|
||||
// --- End Instance State ---
|
||||
|
||||
// --- 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
|
||||
/**
|
||||
* 安全地获取状态文本的辅助函数
|
||||
* @param {string} statusKey - i18n 键名 (例如 'connectingWs')
|
||||
* @param {Record<string, unknown>} [params] - i18n 插值参数
|
||||
* @returns {string} 翻译后的文本或键名本身 (如果翻译失败)
|
||||
*/
|
||||
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
|
||||
console.warn(`[WebSocket ${instanceSessionId}] i18n 错误 (键: workspace.status.${statusKey}):`, e);
|
||||
return statusKey;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to dispatch a message to all registered handlers for its type
|
||||
/**
|
||||
* 将收到的消息分发给已注册的处理器
|
||||
* @param {string} type - 消息类型
|
||||
* @param {MessagePayload} payload - 消息负载
|
||||
* @param {WebSocketMessage} fullMessage - 完整的消息对象
|
||||
*/
|
||||
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);
|
||||
console.error(`[WebSocket ${instanceSessionId}] 消息处理器错误 (类型: "${type}"):`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const connect = (url: string, connId: string) => {
|
||||
// Prevent multiple connections or connection attempts
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
* @param {string} url - WebSocket 服务器 URL
|
||||
*/
|
||||
const connect = (url: string) => {
|
||||
// 防止重复连接同一实例
|
||||
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.warn(`[WebSocket ${instanceSessionId}] 连接已打开或正在连接中。`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WebSocket] Attempting to connect to: ${url} for connection ${connId}`);
|
||||
connectionIdForSession.value = connId;
|
||||
console.log(`[WebSocket ${instanceSessionId}] 尝试连接到: ${url} (DB Conn ID: ${instanceDbConnectionId})`);
|
||||
statusMessage.value = getStatusText('connectingWs', { url });
|
||||
connectionStatus.value = 'connecting';
|
||||
isSftpReady.value = false; // 重置 SFTP 状态
|
||||
|
||||
try {
|
||||
ws.value = new WebSocket(url);
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('[WebSocket] Connection opened.');
|
||||
console.log(`[WebSocket ${instanceSessionId}] 连接已打开。`);
|
||||
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' });
|
||||
// 状态保持 'connecting' 直到收到 ssh:connected
|
||||
// 发送后端所需的初始连接消息,包含数据库连接 ID
|
||||
sendMessage({ type: 'ssh:connect', payload: { connectionId: instanceDbConnectionId } });
|
||||
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
|
||||
// console.debug(`[WebSocket ${instanceSessionId}] 收到:`, message.type);
|
||||
|
||||
// --- Update Global Connection Status based on specific messages ---
|
||||
// --- 更新此实例的连接状态 ---
|
||||
if (message.type === 'ssh:connected') {
|
||||
if (connectionStatus.value !== 'connected') {
|
||||
console.log('[WebSocket] SSH session connected.');
|
||||
console.log(`[WebSocket ${instanceSessionId}] SSH 会话已连接。`);
|
||||
connectionStatus.value = 'connected';
|
||||
statusMessage.value = getStatusText('connected');
|
||||
}
|
||||
} else if (message.type === 'ssh:disconnected') {
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
console.log('[WebSocket] SSH session disconnected.');
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
console.log(`[WebSocket ${instanceSessionId}] SSH 会话已断开。`);
|
||||
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
|
||||
statusMessage.value = getStatusText('disconnected', { reason: message.payload || '未知原因' });
|
||||
isSftpReady.value = false; // SSH 断开,SFTP 也应不可用
|
||||
}
|
||||
} else if (message.type === 'ssh:error' || message.type === 'error') {
|
||||
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
|
||||
console.error('[WebSocket] Received error message:', message.payload);
|
||||
console.error(`[WebSocket ${instanceSessionId}] 收到错误消息:`, message.payload);
|
||||
connectionStatus.value = 'error';
|
||||
let errorMsg = message.payload || 'Unknown error';
|
||||
let errorMsg = message.payload || '未知错误';
|
||||
if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message;
|
||||
statusMessage.value = getStatusText('error', { message: errorMsg });
|
||||
isSftpReady.value = false; // Reset SFTP status on error
|
||||
isSftpReady.value = false;
|
||||
}
|
||||
} else if (message.type === 'sftp_ready') {
|
||||
console.log('[WebSocket] SFTP session ready.');
|
||||
console.log(`[WebSocket ${instanceSessionId}] SFTP 会话已就绪。`);
|
||||
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' });
|
||||
console.error(`[WebSocket ${instanceSessionId}] 处理消息时出错:`, e, '原始数据:', event.data);
|
||||
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
|
||||
console.error(`[WebSocket ${instanceSessionId}] 连接错误:`, event);
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
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;
|
||||
isSftpReady.value = false;
|
||||
ws.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
|
||||
console.log(`[WebSocket ${instanceSessionId}] 连接已关闭: Code=${event.code}, Reason=${event.reason}`);
|
||||
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();
|
||||
isSftpReady.value = false;
|
||||
ws.value = null; // 清理实例引用
|
||||
// 不自动清除处理器,以便在重连时可能复用
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to create WebSocket instance:', err);
|
||||
console.error(`[WebSocket ${instanceSessionId}] 创建 WebSocket 实例失败:`, err);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('wsError'); // Or a more specific creation error
|
||||
isSftpReady.value = false; // Reset SFTP status on creation error
|
||||
statusMessage.value = getStatusText('wsError');
|
||||
isSftpReady.value = false;
|
||||
ws.value = null;
|
||||
connectionIdForSession.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动断开此 WebSocket 连接
|
||||
*/
|
||||
const disconnect = () => {
|
||||
if (ws.value) {
|
||||
console.log('[WebSocket] Closing connection manually...');
|
||||
// Set status immediately to prevent race conditions with onclose
|
||||
console.log(`[WebSocket ${instanceSessionId}] 手动关闭连接...`);
|
||||
if (connectionStatus.value !== 'disconnected') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('disconnected', { reason: 'Manual disconnect' });
|
||||
statusMessage.value = getStatusText('disconnected', { reason: '手动断开' });
|
||||
}
|
||||
ws.value.close(1000, 'Client initiated disconnect'); // Use standard code and reason
|
||||
ws.value.close(1000, '客户端主动断开'); // 使用标准代码和原因
|
||||
ws.value = null;
|
||||
connectionIdForSession.value = null;
|
||||
isSftpReady.value = false; // Reset SFTP status on manual disconnect
|
||||
// messageHandlers.clear(); // Clear handlers on manual disconnect
|
||||
isSftpReady.value = false;
|
||||
// 手动断开时可以考虑清除处理器,取决于是否需要重连逻辑
|
||||
// messageHandlers.clear();
|
||||
} else {
|
||||
console.log(`[WebSocket ${instanceSessionId}] 连接已关闭或不存在,无需断开。`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送 WebSocket 消息
|
||||
* @param {WebSocketMessage} message - 要发送的消息对象
|
||||
*/
|
||||
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
|
||||
// console.debug(`[WebSocket ${instanceSessionId}] 发送:`, message.type);
|
||||
ws.value.send(messageString);
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Failed to stringify or send message:', e, message);
|
||||
console.error(`[WebSocket ${instanceSessionId}] 序列化或发送消息失败:`, e, message);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[WebSocket] Cannot send message, connection not open. State: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
|
||||
console.warn(`[WebSocket ${instanceSessionId}] 无法发送消息,连接未打开。状态: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Register a handler for a specific message type
|
||||
const onMessage = (type: string, handler: MessageHandler) => {
|
||||
/**
|
||||
* 注册一个消息处理器
|
||||
* @param {string} type - 要监听的消息类型
|
||||
* @param {MessageHandler} handler - 处理函数
|
||||
* @returns {Function} 一个用于注销此处理器的函数
|
||||
*/
|
||||
const onMessage = (type: string, handler: MessageHandler): (() => void) => {
|
||||
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}`);
|
||||
// console.debug(`[WebSocket ${instanceSessionId}] 已注册处理器: ${type}`);
|
||||
}
|
||||
|
||||
|
||||
// Return an unregister function
|
||||
// 返回注销函数
|
||||
return () => {
|
||||
const currentSet = messageHandlers.get(type);
|
||||
if (currentSet) {
|
||||
currentSet.delete(handler);
|
||||
console.debug(`[WebSocket] Handler unregistered for type: ${type}`);
|
||||
// console.debug(`[WebSocket ${instanceSessionId}] 已注销处理器: ${type}`);
|
||||
if (currentSet.size === 0) {
|
||||
messageHandlers.delete(type);
|
||||
}
|
||||
@@ -217,20 +225,18 @@ export function useWebSocketConnection() {
|
||||
};
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 注意:没有在此处使用 onUnmounted。
|
||||
// disconnect 方法需要由外部调用者 (例如 WorkspaceView) 在会话关闭时显式调用。
|
||||
|
||||
// 返回此实例的状态和方法
|
||||
return {
|
||||
// State (Exported as readonly refs where appropriate)
|
||||
// 状态 (只读引用)
|
||||
isConnected: computed(() => connectionStatus.value === 'connected'),
|
||||
isSftpReady: readonly(isSftpReady), // Expose SFTP readiness state
|
||||
isSftpReady: readonly(isSftpReady),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
statusMessage: readonly(statusMessage),
|
||||
|
||||
// Methods
|
||||
// 方法
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"connections": "Connections",
|
||||
"terminal": "Terminal",
|
||||
"proxies": "Proxies",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
@@ -149,7 +150,10 @@
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"statusBar": "Status: {status} (Connection ID: {id})",
|
||||
"noActiveSession": "No Active Session",
|
||||
"selectConnectionPrompt": "Please select a connection",
|
||||
"selectConnectionHint": "Select a connection from the left list, or click the 'Add New Connection' button to create a new one.",
|
||||
"statusBar": "Status: {status} | Connection: {id}",
|
||||
"status": {
|
||||
"initializing": "Initializing...",
|
||||
"connectingWs": "Connecting to {url}...",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"addConnection": "添加新连接",
|
||||
"loading": "正在加载连接...",
|
||||
"error": "加载连接失败: {error}",
|
||||
"noConnections": "还没有任何连接。点击“添加新连接”来创建一个吧!",
|
||||
"noConnections": "还没有任何连接。点击'添加新连接'来创建一个吧!",
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"host": "主机",
|
||||
@@ -108,7 +108,7 @@
|
||||
"addProxy": "添加新代理",
|
||||
"loading": "正在加载代理...",
|
||||
"error": "加载代理列表失败: {error}",
|
||||
"noProxies": "还没有任何代理配置。点击“添加新代理”来创建一个吧!",
|
||||
"noProxies": "还没有任何代理配置。点击'添加新代理'来创建一个吧!",
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"type": "类型",
|
||||
@@ -151,7 +151,8 @@
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"statusBar": "状态: {status} (连接 ID: {id})",
|
||||
"noActiveSession": "无活动会话",
|
||||
"statusBar": "状态: {status} | 连接: {id}",
|
||||
"status": {
|
||||
"initializing": "正在初始化...",
|
||||
"connectingWs": "正在连接到 {url}...",
|
||||
@@ -172,7 +173,7 @@
|
||||
"unknown": "未知状态"
|
||||
},
|
||||
"selectConnectionPrompt": "请选择一个连接",
|
||||
"selectConnectionHint": "从左侧列表中选择一个连接以开始。",
|
||||
"selectConnectionHint": "从左侧列表中选择一个连接,或点击'添加新连接'按钮创建一个新连接。",
|
||||
"terminal": {
|
||||
"infoPrefix": "[信息]",
|
||||
"errorPrefix": "[错误]",
|
||||
@@ -257,7 +258,7 @@
|
||||
"addTag": "添加新标签",
|
||||
"loading": "正在加载标签...",
|
||||
"error": "加载标签列表失败: {error}",
|
||||
"noTags": "还没有任何标签。点击“添加新标签”来创建一个吧!",
|
||||
"noTags": "还没有任何标签。点击'添加新标签'来创建一个吧!",
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"updatedAt": "更新时间",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ref, computed, shallowRef, type Ref } from 'vue'; // 导入 shallowRef
|
||||
import { defineStore } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, type ConnectionInfo } from './connections.store';
|
||||
|
||||
// 导入管理器工厂函数 (用于创建实例)
|
||||
import { createWebSocketConnectionManager } from '../composables/useWebSocketConnection';
|
||||
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
|
||||
import { createSshTerminalManager, type SshTerminalDependencies } from '../composables/useSshTerminal';
|
||||
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../composables/useStatusMonitor';
|
||||
|
||||
// --- 辅助函数 ---
|
||||
function generateSessionId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
|
||||
// --- 类型定义 (导出以便其他模块使用) ---
|
||||
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
||||
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||
export type SshTerminalInstance = ReturnType<typeof createSshTerminalManager>;
|
||||
export type StatusMonitorInstance = ReturnType<typeof createStatusMonitorManager>;
|
||||
|
||||
export interface SessionState {
|
||||
sessionId: string;
|
||||
connectionId: string; // 数据库中的连接 ID
|
||||
connectionName: string; // 用于显示
|
||||
wsManager: WsManagerInstance;
|
||||
sftpManager: SftpManagerInstance;
|
||||
terminalManager: SshTerminalInstance;
|
||||
statusMonitorManager: StatusMonitorInstance;
|
||||
currentSftpPath: Ref<string>; // SFTP 当前路径 (可能需要保留在此处或移至 SftpManager 内部)
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
// --- 依赖 ---
|
||||
const { t } = useI18n();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
|
||||
// --- State ---
|
||||
// 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性
|
||||
const sessions = shallowRef<Map<string, SessionState>>(new Map());
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
|
||||
// --- Getters ---
|
||||
const sessionTabs = computed(() => {
|
||||
return Array.from(sessions.value.values()).map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionName: session.connectionName,
|
||||
}));
|
||||
});
|
||||
|
||||
const activeSession = computed((): SessionState | null => {
|
||||
if (!activeSessionId.value) return null;
|
||||
return sessions.value.get(activeSessionId.value) || null;
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/**
|
||||
* 根据连接 ID 查找连接信息
|
||||
*/
|
||||
const findConnectionInfo = (connectionId: number | string): ConnectionInfo | undefined => {
|
||||
return connectionsStore.connections.find(c => c.id === Number(connectionId));
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开一个新的会话标签页
|
||||
*/
|
||||
const openNewSession = (connectionId: number | string) => {
|
||||
console.log(`[SessionStore] 请求打开新会话: ${connectionId}`);
|
||||
const connInfo = findConnectionInfo(connectionId);
|
||||
if (!connInfo) {
|
||||
console.error(`[SessionStore] 无法打开新会话:找不到 ID 为 ${connectionId} 的连接信息。`);
|
||||
// TODO: 向用户显示错误
|
||||
return;
|
||||
}
|
||||
|
||||
const newSessionId = generateSessionId();
|
||||
const dbConnId = String(connInfo.id);
|
||||
|
||||
// 1. 创建管理器实例 (从 WorkspaceView 迁移)
|
||||
const wsManager = createWebSocketConnectionManager(newSessionId, dbConnId, t);
|
||||
const currentSftpPath = ref<string>('.'); // SFTP 路径状态
|
||||
const wsDeps: WebSocketDependencies = {
|
||||
sendMessage: wsManager.sendMessage,
|
||||
onMessage: wsManager.onMessage,
|
||||
isConnected: wsManager.isConnected,
|
||||
isSftpReady: wsManager.isSftpReady,
|
||||
};
|
||||
const sftpManager = createSftpActionsManager(newSessionId, currentSftpPath, wsDeps, t);
|
||||
const sshTerminalDeps: SshTerminalDependencies = {
|
||||
sendMessage: wsManager.sendMessage,
|
||||
onMessage: wsManager.onMessage,
|
||||
isConnected: wsManager.isConnected,
|
||||
};
|
||||
const terminalManager = createSshTerminalManager(newSessionId, sshTerminalDeps, t);
|
||||
const statusMonitorDeps: StatusMonitorDependencies = {
|
||||
onMessage: wsManager.onMessage,
|
||||
isConnected: wsManager.isConnected,
|
||||
};
|
||||
const statusMonitorManager = createStatusMonitorManager(newSessionId, statusMonitorDeps);
|
||||
|
||||
// 2. 创建 SessionState 对象
|
||||
const newSession: SessionState = {
|
||||
sessionId: newSessionId,
|
||||
connectionId: dbConnId,
|
||||
connectionName: connInfo.name || connInfo.host,
|
||||
wsManager: wsManager,
|
||||
sftpManager: sftpManager,
|
||||
terminalManager: terminalManager,
|
||||
statusMonitorManager: statusMonitorManager,
|
||||
currentSftpPath: currentSftpPath,
|
||||
};
|
||||
|
||||
// 3. 添加到 Map 并激活 (需要创建 Map 的新实例以触发 shallowRef 更新)
|
||||
const newSessionsMap = new Map(sessions.value);
|
||||
newSessionsMap.set(newSessionId, newSession);
|
||||
sessions.value = newSessionsMap; // 触发 shallowRef 更新
|
||||
activeSessionId.value = newSessionId;
|
||||
console.log(`[SessionStore] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`);
|
||||
|
||||
// 4. 启动 WebSocket 连接
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`; // TODO: 从配置获取 URL
|
||||
wsManager.connect(wsUrl);
|
||||
console.log(`[SessionStore] 已为会话 ${newSessionId} 启动 WebSocket 连接。`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 激活指定 ID 的会话标签页
|
||||
*/
|
||||
const activateSession = (sessionId: string) => {
|
||||
if (sessions.value.has(sessionId)) {
|
||||
if (activeSessionId.value !== sessionId) {
|
||||
activeSessionId.value = sessionId;
|
||||
console.log(`[SessionStore] 已激活会话: ${sessionId}`);
|
||||
// TODO: 可能需要 nextTick 来聚焦终端?
|
||||
} else {
|
||||
console.log(`[SessionStore] 会话 ${sessionId} 已经是活动状态。`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[SessionStore] 尝试激活不存在的会话 ID: ${sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭指定 ID 的会话标签页
|
||||
*/
|
||||
const closeSession = (sessionId: string) => {
|
||||
console.log(`[SessionStore] 请求关闭会话 ID: ${sessionId}`);
|
||||
const sessionToClose = sessions.value.get(sessionId);
|
||||
if (!sessionToClose) {
|
||||
console.warn(`[SessionStore] 尝试关闭不存在的会话 ID: ${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 调用实例上的清理和断开方法 (从 WorkspaceView 迁移)
|
||||
sessionToClose.wsManager.disconnect();
|
||||
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 wsManager.disconnect()`);
|
||||
sessionToClose.sftpManager.cleanup();
|
||||
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 sftpManager.cleanup()`);
|
||||
sessionToClose.terminalManager.cleanup();
|
||||
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 terminalManager.cleanup()`);
|
||||
sessionToClose.statusMonitorManager.cleanup();
|
||||
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 statusMonitorManager.cleanup()`);
|
||||
|
||||
// 2. 从 Map 中移除会话 (需要创建 Map 的新实例以触发 shallowRef 更新)
|
||||
const newSessionsMap = new Map(sessions.value);
|
||||
newSessionsMap.delete(sessionId);
|
||||
sessions.value = newSessionsMap; // 触发 shallowRef 更新
|
||||
console.log(`[SessionStore] 已从 Map 中移除会话: ${sessionId}`);
|
||||
|
||||
// 3. 切换活动标签页
|
||||
if (activeSessionId.value === sessionId) {
|
||||
const remainingSessions = Array.from(sessions.value.keys());
|
||||
const nextActiveId = remainingSessions.length > 0 ? remainingSessions[remainingSessions.length - 1] : null;
|
||||
activeSessionId.value = nextActiveId;
|
||||
console.log(`[SessionStore] 关闭活动会话后,切换到: ${nextActiveId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理连接列表的左键点击(连接或激活)
|
||||
*/
|
||||
const handleConnectRequest = (connectionId: number | string) => {
|
||||
const connIdStr = String(connectionId);
|
||||
console.log(`[SessionStore] 处理连接请求: ${connIdStr}`);
|
||||
|
||||
let existingSessionId: string | null = null;
|
||||
for (const [sessionId, session] of sessions.value.entries()) {
|
||||
if (session.connectionId === connIdStr) {
|
||||
existingSessionId = sessionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSessionId) {
|
||||
if (activeSessionId.value !== existingSessionId) {
|
||||
console.log(`[SessionStore] 激活已存在的会话: ${existingSessionId}`);
|
||||
activateSession(existingSessionId);
|
||||
} else {
|
||||
console.log(`[SessionStore] 点击的连接 ${connIdStr} 已在活动会话 ${existingSessionId} 中,无需操作。`);
|
||||
}
|
||||
} else {
|
||||
// 当前行为:替换当前活动会话(如果存在)
|
||||
if (activeSession.value) {
|
||||
console.log(`[SessionStore] 替换当前会话 ${activeSessionId.value} 为新连接 ${connIdStr}`);
|
||||
closeSession(activeSessionId.value!); // 确保 activeSessionId 存在
|
||||
openNewSession(connIdStr);
|
||||
} else {
|
||||
console.log(`[SessionStore] 当前无活动会话,打开新会话: ${connIdStr}`);
|
||||
openNewSession(connIdStr);
|
||||
}
|
||||
// 备选行为:总是打开新标签页?需要调整 openNewSession 逻辑
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理连接列表的中键点击(总是打开新会话)
|
||||
*/
|
||||
const handleOpenNewSession = (connectionId: number | string) => {
|
||||
console.log(`[SessionStore] 处理打开新会话请求: ${connectionId}`);
|
||||
openNewSession(connectionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理所有会话(例如在应用卸载时)
|
||||
*/
|
||||
const cleanupAllSessions = () => {
|
||||
console.log('[SessionStore] 清理所有会话...');
|
||||
sessions.value.forEach((session, sessionId) => {
|
||||
closeSession(sessionId); // 调用单个会话的关闭逻辑
|
||||
});
|
||||
sessions.value.clear();
|
||||
activeSessionId.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
sessions,
|
||||
activeSessionId,
|
||||
// Getters
|
||||
sessionTabs,
|
||||
activeSession,
|
||||
// Actions
|
||||
openNewSession,
|
||||
activateSession,
|
||||
closeSession,
|
||||
handleConnectRequest,
|
||||
handleOpenNewSession,
|
||||
cleanupAllSessions,
|
||||
};
|
||||
});
|
||||
@@ -14,4 +14,4 @@ export interface WebSocketMessage {
|
||||
}
|
||||
|
||||
// 消息处理器函数类型
|
||||
export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void;
|
||||
export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; // 恢复 message 参数为必需
|
||||
|
||||
@@ -1,203 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; // 移除 computed, useRoute, useRouter
|
||||
import { onMounted, onBeforeUnmount, computed, ref } from 'vue'; // 移除不再需要的导入
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import TerminalComponent from '../components/Terminal.vue';
|
||||
import FileManagerComponent from '../components/FileManager.vue';
|
||||
import StatusMonitorComponent from '../components/StatusMonitor.vue';
|
||||
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
|
||||
import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; // 引入表单组件
|
||||
import type { ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
|
||||
import type { Terminal } from 'xterm';
|
||||
import { useWebSocketConnection } from '../composables/useWebSocketConnection';
|
||||
import { useSshTerminal } from '../composables/useSshTerminal'; // 导入 SSH 终端模块
|
||||
import { useStatusMonitor } from '../composables/useStatusMonitor';
|
||||
import type { ServerStatus } from '../types/server.types';
|
||||
// import { useConnectionsStore } from '../stores/connections.store'; // 不再直接在此处使用 store
|
||||
// import { storeToRefs } from 'pinia'; // 不再直接在此处使用 storeToRefs
|
||||
|
||||
// --- 接口定义 ---
|
||||
// ServerStatus 现在从 types/server.types.ts 导入
|
||||
import AddConnectionFormComponent from '../components/AddConnectionForm.vue';
|
||||
import TerminalTabBar from '../components/TerminalTabBar.vue';
|
||||
import { useSessionStore } from '../stores/session.store'; // 导入 Session Store
|
||||
import type { ConnectionInfo } from '../stores/connections.store'; // 保持 ConnectionInfo 类型导入
|
||||
// 导入管理器实例类型,用于 FileManagerComponent 的 prop 类型断言
|
||||
import type { SftpManagerInstance } from '../stores/session.store';
|
||||
|
||||
// --- Setup ---
|
||||
const { t } = useI18n();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
// --- 内部状态 ---
|
||||
const activeConnectionId = ref<string | null>(null);
|
||||
const showAddEditForm = ref(false); // 控制表单模态框显示
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null); // 要编辑的连接
|
||||
|
||||
// --- 连接 Store (不再需要在此处直接引用 connections, loading, error) ---
|
||||
// const connectionsStore = useConnectionsStore();
|
||||
// const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
|
||||
|
||||
// --- WebSocket 连接模块 ---
|
||||
const {
|
||||
isConnected,
|
||||
connectionStatus, // Get reactive status from composable
|
||||
statusMessage, // Get reactive status message from composable
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
onMessage,
|
||||
} = useWebSocketConnection();
|
||||
|
||||
// --- SSH 终端模块 ---
|
||||
const {
|
||||
// terminalInstance, // 不再需要直接从这里访问
|
||||
handleTerminalReady,
|
||||
handleTerminalData,
|
||||
handleTerminalResize,
|
||||
registerSshHandlers,
|
||||
unregisterAllSshHandlers,
|
||||
} = useSshTerminal();
|
||||
|
||||
// --- 状态监控模块 ---
|
||||
const {
|
||||
serverStatus, // 从 composable 获取状态
|
||||
statusError, // 从 composable 获取错误
|
||||
registerStatusHandlers, // 重命名以避免与 SSH 冲突
|
||||
unregisterAllStatusHandlers, // 重命名以避免与 SSH 冲突
|
||||
} = useStatusMonitor();
|
||||
// --- 从 Store 获取响应式状态和 Getters ---
|
||||
// 使用 storeToRefs 保持响应性,或者直接在模板中使用 sessionStore.xxx
|
||||
const { sessionTabs, activeSessionId, activeSession } = storeToRefs(sessionStore);
|
||||
|
||||
// --- UI 状态 (保持本地) ---
|
||||
const showAddEditForm = ref(false);
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(() => {
|
||||
// 组件挂载时不自动连接,等待用户选择
|
||||
// if (activeConnectionId.value) {
|
||||
// const wsUrl = `ws://${window.location.hostname}:3001`;
|
||||
// connect(wsUrl, activeConnectionId.value);
|
||||
// 不在此处立即注册,等待 isConnected 变为 true
|
||||
// registerSshHandlers();
|
||||
// registerStatusHandlers();
|
||||
// } else {
|
||||
// console.log('[工作区视图] 没有活动的连接 ID。'); // 不再是错误
|
||||
// }
|
||||
console.log('[工作区视图] 组件已挂载。');
|
||||
// 可以在这里执行一些初始化操作,如果需要的话
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect(); // 使用 WebSocket 模块的 disconnect
|
||||
unregisterAllSshHandlers(); // 注销 SSH 终端处理器
|
||||
unregisterAllStatusHandlers(); // 使用状态监控模块的注销函数
|
||||
console.log('[工作区视图] 组件即将卸载,清理所有会话...');
|
||||
sessionStore.cleanupAllSessions(); // 调用 store action 清理
|
||||
});
|
||||
|
||||
// 监听 activeConnectionId 变化以处理连接切换
|
||||
watch(activeConnectionId, (newId, oldId) => {
|
||||
console.log(`[工作区视图] 活动连接 ID 从 ${oldId} 更改为 ${newId}`);
|
||||
// 断开旧连接 (如果存在)
|
||||
if (oldId) {
|
||||
disconnect(); // isConnected 会变为 false,触发清理
|
||||
}
|
||||
// 连接新的 WebSocket (如果新 ID 有效)
|
||||
if (newId) {
|
||||
console.log(`[工作区视图] 正在连接到 ID: ${newId}...`);
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`;
|
||||
connect(wsUrl, newId); // connect 会处理 isConnected 状态
|
||||
}
|
||||
// 注意:处理器的注册/注销现在完全由 isConnected 的 watch 驱动
|
||||
});
|
||||
// --- 监听器 (如果需要监听 store 状态变化) ---
|
||||
// watch(activeSessionId, (newSessionId, oldSessionId) => {
|
||||
// console.log(`[工作区视图] 活动会话 ID 从 ${oldSessionId} 更改为 ${newSessionId}`);
|
||||
// if (newSessionId) {
|
||||
// nextTick(() => {
|
||||
// // TODO: 聚焦到活动会话的终端 (此逻辑可能移至 Store 或保留在此处)
|
||||
// console.log(`[工作区视图] TODO: 聚焦会话 ${newSessionId} 的终端`);
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// 监听 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)
|
||||
|
||||
// --- 连接列表点击处理 ---
|
||||
const handleConnectRequest = (id: number | string) => {
|
||||
console.log(`[工作区视图] 请求激活连接 ID: ${id}`);
|
||||
activeConnectionId.value = String(id);
|
||||
};
|
||||
|
||||
// --- 表单模态框处理 ---
|
||||
// --- 本地方法 (仅处理 UI 状态) ---
|
||||
const handleRequestAddConnection = () => {
|
||||
connectionToEdit.value = null; // 确保是添加模式
|
||||
connectionToEdit.value = null;
|
||||
showAddEditForm.value = true;
|
||||
};
|
||||
|
||||
const handleRequestEditConnection = (connection: ConnectionInfo) => {
|
||||
connectionToEdit.value = connection; // 设置要编辑的连接
|
||||
connectionToEdit.value = connection;
|
||||
showAddEditForm.value = true;
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
showAddEditForm.value = false;
|
||||
connectionToEdit.value = null; // 清除编辑状态
|
||||
connectionToEdit.value = null;
|
||||
};
|
||||
|
||||
const handleConnectionAdded = () => {
|
||||
console.log('[工作区视图] 连接已添加');
|
||||
handleFormClose();
|
||||
// WorkspaceConnectionList 会自动从 store 更新
|
||||
};
|
||||
|
||||
const handleConnectionUpdated = () => {
|
||||
console.log('[工作区视图] 连接已更新');
|
||||
handleFormClose();
|
||||
// WorkspaceConnectionList 会自动从 store 更新
|
||||
};
|
||||
|
||||
// --- 移除本地会话管理函数 ---
|
||||
// findConnectionInfo, openNewSession, activateSession, closeSession,
|
||||
// handleConnectRequest, handleOpenNewSession 已移至 sessionStore
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-view">
|
||||
<!-- 标签栏: 绑定到 store 的状态和 actions -->
|
||||
<TerminalTabBar
|
||||
:sessions="sessionTabs"
|
||||
:active-session-id="activeSessionId"
|
||||
@activate-session="sessionStore.activateSession"
|
||||
@close-session="sessionStore.closeSession"
|
||||
/>
|
||||
|
||||
<div class="status-bar">
|
||||
<!-- 使用 t 函数渲染状态栏文本, 显示 activeConnectionId -->
|
||||
{{ t('workspace.statusBar', { status: statusMessage, id: activeConnectionId ?? 'N/A' }) }}
|
||||
<!-- 状态颜色仍然通过 class 绑定 -->
|
||||
<!-- 使用来自 useWebSocketConnection 的状态 -->
|
||||
<span :class="`status-${connectionStatus}`"></span>
|
||||
<!-- 状态栏显示活动会话的信息: 从 store getter 获取 -->
|
||||
{{ t('workspace.statusBar', {
|
||||
status: activeSession?.wsManager.statusMessage.value ?? t('workspace.status.disconnected'),
|
||||
id: activeSession?.connectionId ?? t('workspace.noActiveSession')
|
||||
})
|
||||
}}
|
||||
<!-- 从 activeSession getter 获取连接状态 -->
|
||||
<span :class="`status-${activeSession?.wsManager.connectionStatus.value ?? 'disconnected'}`"></span>
|
||||
</div>
|
||||
<div class="main-content-area">
|
||||
<!-- 新增左侧边栏 -->
|
||||
<!-- 左侧边栏: 事件绑定到 store actions -->
|
||||
<div class="left-sidebar">
|
||||
<!-- 监听新的事件 -->
|
||||
<WorkspaceConnectionListComponent
|
||||
@connect-request="handleConnectRequest"
|
||||
@connect-request="sessionStore.handleConnectRequest"
|
||||
@open-new-session="sessionStore.handleOpenNewSession"
|
||||
@request-add-connection="handleRequestAddConnection"
|
||||
@request-edit-connection="handleRequestEditConnection"
|
||||
/>
|
||||
</div>
|
||||
<!-- 主工作区 (添加 v-if/v-else), 条件改为 activeConnectionId -->
|
||||
<div v-if="activeConnectionId" class="main-workspace-area">
|
||||
<div class="left-pane">
|
||||
<div class="terminal-wrapper">
|
||||
<!-- 将事件绑定到 useSshTerminal 的处理函数 -->
|
||||
<TerminalComponent
|
||||
@ready="handleTerminalReady"
|
||||
@data="handleTerminalData"
|
||||
@resize="handleTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<!-- Removed :ws prop. Communication will be handled via composables -->
|
||||
<FileManagerComponent :is-connected="isConnected" />
|
||||
</div>
|
||||
<!-- 主工作区容器 -->
|
||||
<div class="main-workspace-container">
|
||||
<!-- 会话区域: 循环 store 中的 sessions Map -->
|
||||
<!-- 注意: v-for sessions.values() 可能不是响应式的,因为 sessions 是 shallowRef -->
|
||||
<!-- 改为 v-for session in sessionTabs,然后通过 session.sessionId 获取完整 session -->
|
||||
<div
|
||||
v-for="tabInfo in sessionTabs"
|
||||
:key="tabInfo.sessionId"
|
||||
v-show="tabInfo.sessionId === activeSessionId"
|
||||
class="main-workspace-area-session"
|
||||
>
|
||||
<!-- 获取当前循环的完整 session 对象 -->
|
||||
<template v-if="sessionStore.sessions.get(tabInfo.sessionId)">
|
||||
<div class="left-pane">
|
||||
<div class="terminal-wrapper" :data-session-id="tabInfo.sessionId">
|
||||
<!-- TerminalComponent: 事件绑定到 activeSession 的管理器方法 -->
|
||||
<TerminalComponent
|
||||
:key="tabInfo.sessionId"
|
||||
:session-id="tabInfo.sessionId"
|
||||
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
|
||||
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
|
||||
@resize="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<div class="file-manager-wrapper">
|
||||
<!-- FileManagerComponent: Props 绑定到 activeSession 的管理器 -->
|
||||
<!-- 确保传递正确的 wsDeps -->
|
||||
<FileManagerComponent
|
||||
:key="tabInfo.sessionId"
|
||||
:session-id="tabInfo.sessionId"
|
||||
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
|
||||
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
|
||||
:ws-deps="{
|
||||
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
|
||||
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
|
||||
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
|
||||
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-monitor-wrapper">
|
||||
<!-- StatusMonitorComponent: Props 绑定到 activeSession 的管理器状态 -->
|
||||
<StatusMonitorComponent
|
||||
:key="tabInfo.sessionId"
|
||||
:session-id="tabInfo.sessionId"
|
||||
:server-status="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.serverStatus.value) ?? null"
|
||||
:status-error="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.statusError.value) ?? null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 状态监控窗格 -->
|
||||
<div class="status-monitor-wrapper">
|
||||
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" />
|
||||
<!-- 占位符 -->
|
||||
<div v-if="!activeSessionId" class="main-workspace-area placeholder">
|
||||
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
||||
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当没有 connectionId 时显示提示 -->
|
||||
<div v-else class="main-workspace-area placeholder">
|
||||
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
||||
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑连接表单模态框 -->
|
||||
<!-- 添加/编辑连接表单模态框 (保持不变) -->
|
||||
<AddConnectionFormComponent
|
||||
v-if="showAddEditForm"
|
||||
:connection-to-edit="connectionToEdit"
|
||||
@@ -212,8 +184,7 @@ watch(isConnected, (connected) => {
|
||||
.workspace-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 调整高度计算以适应可能的 header/footer/status-bar */
|
||||
height: calc(100vh - 60px - 30px - 2rem); /* 假设 header 60px, footer 30px, padding 2rem */
|
||||
height: calc(100vh - 60px - 30px - 60px - 2rem); /* 调整以适应布局 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -232,83 +203,85 @@ watch(isConnected, (connected) => {
|
||||
|
||||
.main-content-area {
|
||||
display: flex;
|
||||
flex-grow: 1; /* Take remaining vertical space */
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
/* 新增样式 */
|
||||
border-top: 1px solid #ccc; /* Add a top border for separation */
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* 新增左侧边栏样式 */
|
||||
.left-sidebar {
|
||||
width: 250px; /* 示例宽度 */
|
||||
min-width: 200px; /* 最小宽度 */
|
||||
width: 250px;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
border-right: 2px solid #ccc;
|
||||
overflow-y: auto; /* 如果列表过长则允许滚动 */
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.left-sidebar > * {
|
||||
flex-grow: 1; /* 让 WorkspaceConnectionList 填充 */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
/* 主工作区容器 */
|
||||
.main-workspace-area {
|
||||
flex-grow: 1; /* 占据剩余空间 */
|
||||
.main-workspace-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-workspace-area-session {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* width: 80%; */ /* 不再固定宽度,改为 flex */
|
||||
flex-grow: 1; /* 占据主工作区大部分空间 */
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
min-width: 300px; /* 保证终端和文件管理器有最小宽度 */
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
height: 60%; /* 示例:终端占 60% 高度 */
|
||||
background-color: #1e1e1e; /* 终端背景色 */
|
||||
overflow: hidden; /* 内部滚动由 xterm 处理 */
|
||||
display: flex; /* Ensure TerminalComponent fills this wrapper */
|
||||
height: 60%;
|
||||
background-color: #1e1e1e;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-wrapper > * {
|
||||
flex-grow: 1; /* Make TerminalComponent fill the wrapper */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
.file-manager-wrapper {
|
||||
height: 40%; /* 示例:文件管理器占 40% 高度 */
|
||||
border-top: 2px solid #ccc; /* Add top border */
|
||||
overflow: hidden; /* 防止自身滚动 */
|
||||
display: flex; /* Ensure FileManagerComponent fills this wrapper */
|
||||
height: 40%;
|
||||
border-top: 2px solid #ccc;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.file-manager-wrapper > * {
|
||||
flex-grow: 1; /* Make FileManagerComponent fill the wrapper */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.status-monitor-wrapper {
|
||||
/* width: 20%; */ /* 不再固定宽度,改为 flex-basis */
|
||||
flex-basis: 250px; /* 示例基础宽度 */
|
||||
min-width: 200px; /* 最小宽度 */
|
||||
flex-basis: 250px;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
border-left: 2px solid #ccc;
|
||||
overflow: hidden;
|
||||
display: flex; /* Ensure StatusMonitorComponent fills this wrapper */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.status-monitor-wrapper > * {
|
||||
flex-grow: 1; /* Make StatusMonitorComponent fill the wrapper */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* 新增:占位符样式 */
|
||||
.main-workspace-area.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -316,7 +289,7 @@ watch(isConnected, (connected) => {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
padding: 2rem;
|
||||
background-color: #f8f9fa; /* Match sidebar background */
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.main-workspace-area.placeholder h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user