update
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import ConnectionList from '../components/ConnectionList.vue'; // 引入列表组件
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue'; // 引入表单组件
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const showAddForm = ref(false); // 控制添加表单的显示状态
|
||||
|
||||
const handleConnectionAdded = () => {
|
||||
showAddForm.value = false; // 添加成功后隐藏表单
|
||||
// ConnectionList 组件会自动从 store 获取更新后的列表
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connections-view">
|
||||
<h2>{{ t('connections.title') }}</h2>
|
||||
|
||||
<button @click="showAddForm = true" v-if="!showAddForm">{{ t('connections.addConnection') }}</button>
|
||||
|
||||
<!-- 添加连接表单 (条件渲染) -->
|
||||
<AddConnectionForm
|
||||
v-if="showAddForm"
|
||||
@close="showAddForm = false"
|
||||
@connection-added="handleConnectionAdded"
|
||||
/>
|
||||
|
||||
<!-- 连接列表 -->
|
||||
<ConnectionList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connections-view {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const authStore = useAuthStore();
|
||||
const { isLoading, error } = storeToRefs(authStore); // 获取加载和错误状态
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
// 处理登录提交
|
||||
const handleLogin = async () => {
|
||||
await authStore.login(credentials);
|
||||
// 登录成功会自动重定向 (在 store action 中处理)
|
||||
// 登录失败会在模板中显示错误信息
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-view">
|
||||
<div class="login-form-container">
|
||||
<h2>{{ t('login.title') }}</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ t('login.username') }}:</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ t('login.password') }}:</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<!-- 可以直接显示后端返回的错误,或者映射到特定的 i18n key -->
|
||||
{{ error }} <!-- 保持显示后端错误,或者 t('login.error') -->
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('login.loggingIn') : t('login.loginButton') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-view {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 150px); /* Adjust based on header/footer height */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
background-color: #fff;
|
||||
padding: 2rem 3rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #eee;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #a0cfff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import TerminalComponent from '../components/Terminal.vue'; // 引入终端组件
|
||||
import FileManagerComponent from '../components/FileManager.vue'; // 引入文件管理器组件
|
||||
import type { Terminal } from 'xterm'; // 引入 Terminal 类型
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const route = useRoute();
|
||||
const connectionId = computed(() => route.params.connectionId as string); // 从路由获取 connectionId
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null); // 终端实例引用
|
||||
const ws = ref<WebSocket | null>(null); // WebSocket 实例引用
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const statusMessage = ref<string>(t('workspace.status.initializing')); // 使用 i18n
|
||||
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
|
||||
// 辅助函数:根据状态码获取 i18n 状态文本
|
||||
const getStatusText = (statusKey: string, params?: Record<string, any>): string => {
|
||||
return t(`workspace.status.${statusKey}`, params || {});
|
||||
};
|
||||
|
||||
// 辅助函数:获取终端消息文本
|
||||
const getTerminalText = (key: string, params?: Record<string, any>): string => {
|
||||
return t(`workspace.terminal.${key}`, params || {});
|
||||
};
|
||||
|
||||
|
||||
// 处理终端准备就绪事件
|
||||
const onTerminalReady = (term: Terminal) => {
|
||||
terminalInstance.value = term;
|
||||
// 将缓冲区的输出写入终端
|
||||
terminalOutputBuffer.value.forEach(data => term.write(data));
|
||||
terminalOutputBuffer.value = []; // 清空缓冲区
|
||||
console.log('终端准备就绪');
|
||||
};
|
||||
|
||||
// 处理终端用户输入
|
||||
const onTerminalData = (data: string) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:input', payload: { data } }));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理终端大小调整
|
||||
const onTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
console.log('发送终端大小调整:', dimensions);
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:resize', payload: dimensions }));
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
const initializeWebSocketConnection = () => {
|
||||
// 使用当前页面的协议和主机,但端口固定为后端端口 (3001),路径为 /
|
||||
// 注意:这里假设后端 WebSocket 监听根路径,如果不是,需要修改路径
|
||||
// 并且假设前端和后端在同一主机上,只是端口不同
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`; // 构建 WebSocket URL
|
||||
|
||||
console.log(`尝试连接 WebSocket: ${wsUrl}`);
|
||||
statusMessage.value = getStatusText('connectingWs', { url: wsUrl });
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
ws.value = new WebSocket(wsUrl);
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket 连接已打开');
|
||||
statusMessage.value = getStatusText('wsConnected');
|
||||
// 连接打开后,发送 ssh:connect 消息
|
||||
if (ws.value) {
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:connect', payload: { connectionId: connectionId.value } }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
// console.log('收到 WebSocket 消息:', message); // Debug log
|
||||
|
||||
switch (message.type) {
|
||||
case 'ssh:output':
|
||||
let outputData = message.payload;
|
||||
// 检查是否为 Base64 编码
|
||||
if (message.encoding === 'base64' && typeof outputData === 'string') {
|
||||
try {
|
||||
// 解码 Base64 并尝试用 UTF-8 解释
|
||||
// 注意:atob 在浏览器中可用,但在 Node.js 环境中可能需要 Buffer.from(..., 'base64').toString()
|
||||
outputData = atob(outputData);
|
||||
} catch (e) {
|
||||
console.error('Base64 解码失败:', e, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
|
||||
}
|
||||
}
|
||||
// 写入终端
|
||||
if (terminalInstance.value) {
|
||||
terminalInstance.value.write(outputData);
|
||||
} else {
|
||||
// 如果终端还没准备好,先缓冲输出 (缓冲解码后的数据)
|
||||
terminalOutputBuffer.value.push(message.payload);
|
||||
}
|
||||
break;
|
||||
case 'ssh:connected':
|
||||
console.log('SSH 会话已连接');
|
||||
connectionStatus.value = 'connected';
|
||||
statusMessage.value = getStatusText('connected');
|
||||
terminalInstance.value?.focus(); // 连接成功后聚焦终端
|
||||
break;
|
||||
case 'ssh:disconnected':
|
||||
const reasonDisconnect = message.payload || '未知原因';
|
||||
console.log('SSH 会话已断开:', reasonDisconnect);
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('disconnected', { reason: reasonDisconnect });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason: reasonDisconnect })}\x1b[0m`);
|
||||
break;
|
||||
case 'ssh:error':
|
||||
const errorMsg = message.payload || '未知 SSH 错误';
|
||||
console.error('SSH 错误:', errorMsg);
|
||||
connectionStatus.value = 'error';
|
||||
// 尝试匹配特定的错误 key
|
||||
let errorKey = 'sshError';
|
||||
if (errorMsg.includes('解密')) errorKey = 'decryptError';
|
||||
else if (errorMsg.includes('未找到 ID')) errorKey = 'noConnInfo';
|
||||
else if (errorMsg.includes('缺少密码')) errorKey = 'noPassword';
|
||||
else if (errorMsg.includes('打开 Shell 失败')) errorKey = 'shellError';
|
||||
else if (errorMsg.includes('已存在活动的 SSH 连接')) errorKey = 'alreadyConnected';
|
||||
|
||||
statusMessage.value = getStatusText(errorKey, { message: errorMsg, id: connectionId.value });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
break;
|
||||
case 'ssh:status':
|
||||
const statusKey = message.payload?.key || 'unknown'; // 假设后端会发送 key
|
||||
const statusParams = message.payload?.params || {};
|
||||
console.log('SSH 状态:', statusKey, statusParams);
|
||||
statusMessage.value = getStatusText(statusKey, statusParams); // 更新状态信息
|
||||
break;
|
||||
case 'info': // 处理后端发送的普通信息
|
||||
console.log('后端信息:', message.payload);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${message.payload}\x1b[0m`);
|
||||
break;
|
||||
case 'error': // 处理后端发送的通用错误
|
||||
console.error('后端错误:', message.payload);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('error', { message: message.payload });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${message.payload}\x1b[0m`);
|
||||
break;
|
||||
// default: // Removed default case to allow other components to handle messages
|
||||
// console.warn('WorkspaceView: 收到未处理的 WebSocket 消息类型:', message.type);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('处理 WebSocket 消息时出错:', e);
|
||||
// 如果收到的不是 JSON,直接写入终端
|
||||
if (terminalInstance.value && typeof event.data === 'string') {
|
||||
terminalInstance.value.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('wsError');
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsErrorMsg')}\x1b[0m`);
|
||||
};
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log('WebSocket 连接已关闭:', event.code, event.reason);
|
||||
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('wsClosed', { code: event.code });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsCloseMsg', { code: event.code })}\x1b[0m`);
|
||||
}
|
||||
ws.value = null; // 清理引用
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (connectionId.value) {
|
||||
initializeWebSocketConnection();
|
||||
} else {
|
||||
statusMessage.value = getStatusText('error', { message: '缺少连接 ID' });
|
||||
connectionStatus.value = 'error';
|
||||
console.error('WorkspaceView: 缺少 connectionId 路由参数。');
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (ws.value) {
|
||||
console.log('组件卸载,关闭 WebSocket 连接...');
|
||||
ws.value.close();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-view">
|
||||
<div class="status-bar">
|
||||
<!-- 使用 t 函数渲染状态栏文本 -->
|
||||
{{ t('workspace.statusBar', { status: statusMessage, id: connectionId }) }}
|
||||
<!-- 状态颜色仍然通过 class 绑定 -->
|
||||
<span :class="`status-${connectionStatus}`"></span>
|
||||
</div>
|
||||
<div class="terminal-wrapper">
|
||||
<TerminalComponent
|
||||
@ready="onTerminalReady"
|
||||
@data="onTerminalData"
|
||||
@resize="onTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<FileManagerComponent :ws="ws" :is-connected="connectionStatus === 'connected'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 调整高度计算以适应可能的 header/footer/status-bar */
|
||||
height: calc(100vh - 60px - 30px - 2rem); /* 假设 header 60px, footer 30px, padding 2rem */
|
||||
overflow: hidden; /* 防止页面滚动 */
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #eee;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-connecting { color: orange; }
|
||||
.status-connected { color: green; }
|
||||
.status-disconnected { color: grey; }
|
||||
.status-error { color: red; }
|
||||
|
||||
.terminal-wrapper {
|
||||
/* flex-grow: 1; */ /* 不再让终端独占剩余空间 */
|
||||
height: 60%; /* 示例:终端占 60% 高度 */
|
||||
background-color: #1e1e1e; /* 终端背景色 */
|
||||
overflow: hidden; /* 内部滚动由 xterm 处理 */
|
||||
}
|
||||
|
||||
.file-manager-wrapper {
|
||||
height: 40%; /* 示例:文件管理器占 40% 高度 */
|
||||
border-top: 2px solid #ccc; /* 添加分隔线 */
|
||||
overflow: hidden; /* 防止自身滚动 */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user