This commit is contained in:
Baobhan Sith
2025-04-14 22:51:05 +08:00
parent 286492fc63
commit a974b8b1d9
49 changed files with 13954 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n'; // 引入 useI18n
import { useAuthStore } from './stores/auth.store'; // 引入 Auth Store
import { storeToRefs } from 'pinia';
const { t } = useI18n(); // 获取 t 函数
const authStore = useAuthStore();
const { isAuthenticated } = storeToRefs(authStore); // 获取登录状态
const handleLogout = () => {
authStore.logout();
};
</script>
<template>
<div id="app-container">
<header>
<nav>
<RouterLink to="/">{{ t('nav.dashboard') }}</RouterLink> |
<RouterLink to="/connections">{{ t('nav.connections') }}</RouterLink> |
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
</nav>
</header>
<main>
<RouterView /> <!-- 路由对应的组件将在这里渲染 -->
</main>
<footer>
<!-- 使用 t 函数获取应用名称 -->
<p>&copy; 2025 {{ t('appName') }}</p>
</footer>
</div>
</template>
<style scoped>
#app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: sans-serif;
}
header {
background-color: #f0f0f0;
padding: 1rem;
border-bottom: 1px solid #ccc;
}
nav a {
margin: 0 0.5rem;
text-decoration: none;
color: #333;
}
nav a.router-link-exact-active {
font-weight: bold;
color: #007bff;
}
main {
flex-grow: 1;
padding: 1rem;
}
footer {
background-color: #f0f0f0;
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.8rem;
color: #666;
border-top: 1px solid #ccc;
margin-top: auto; /* Pushes footer to the bottom */
}
</style>
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import { useI18n } from 'vue-i18n'; // 引入 useI18n
import { useConnectionsStore } from '../stores/connections.store';
// 定义组件发出的事件
const emit = defineEmits(['close', 'connection-added']);
const { t } = useI18n(); // 获取 t 函数
const connectionsStore = useConnectionsStore();
const { isLoading, error } = storeToRefs(connectionsStore); // 获取加载和错误状态
// 表单数据模型
const formData = reactive({
name: '',
host: '',
port: 22,
username: '',
password: '',
});
const formError = ref<string | null>(null); // 表单级别的错误信息
// 处理表单提交
const handleSubmit = async () => {
formError.value = null; // 清除之前的错误
// 基础前端验证 (可以添加更复杂的验证)
if (!formData.name || !formData.host || !formData.username || !formData.password) {
formError.value = t('connections.form.errorRequired');
return;
}
if (formData.port <= 0 || formData.port > 65535) {
formError.value = t('connections.form.errorPort');
return;
}
const success = await connectionsStore.addConnection({
name: formData.name,
host: formData.host,
port: formData.port,
username: formData.username,
password: formData.password,
});
if (success) {
emit('connection-added'); // 通知父组件添加成功
} else {
// 如果 store action 返回 false,则显示 store 中的错误信息
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
}
};
</script>
<template>
<div class="add-connection-form-overlay">
<div class="add-connection-form">
<h3>{{ t('connections.form.title') }}</h3>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="conn-name">{{ t('connections.form.name') }}</label>
<input type="text" id="conn-name" v-model="formData.name" required />
</div>
<div class="form-group">
<label for="conn-host">{{ t('connections.form.host') }}</label>
<input type="text" id="conn-host" v-model="formData.host" required />
</div>
<div class="form-group">
<label for="conn-port">{{ t('connections.form.port') }}</label>
<input type="number" id="conn-port" v-model.number="formData.port" required min="1" max="65535" />
</div>
<div class="form-group">
<label for="conn-username">{{ t('connections.form.username') }}</label>
<input type="text" id="conn-username" v-model="formData.username" required />
</div>
<div class="form-group">
<label for="conn-password">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password" v-model="formData.password" required />
<!-- 提示MVP 仅支持密码认证 -->
</div>
<div v-if="formError || error" class="error-message">
{{ formError || error }} <!-- 保持显示具体错误 -->
</div>
<div class="form-actions">
<button type="submit" :disabled="isLoading">
{{ isLoading ? t('connections.form.adding') : t('connections.form.confirm') }}
</button>
<button type="button" @click="emit('close')" :disabled="isLoading">{{ t('connections.form.cancel') }}</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.add-connection-form-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000; /* Ensure it's on top */
}
.add-connection-form {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 500px;
}
h3 {
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.3rem;
font-weight: bold;
}
input[type="text"],
input[type="number"],
input[type="password"] {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box; /* Include padding and border in element's total width and height */
}
.error-message {
color: red;
margin-bottom: 1rem;
text-align: center;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
.form-actions button {
margin-left: 0.5rem;
padding: 0.6rem 1.2rem;
cursor: pointer;
border: none;
border-radius: 4px;
}
.form-actions button[type="submit"] {
background-color: #007bff;
color: white;
}
.form-actions button[type="button"] {
background-color: #ccc;
color: #333;
}
.form-actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router'; // 引入 useRouter
import { useI18n } from 'vue-i18n'; // 引入 useI18n
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
const { t } = useI18n(); // 获取 t 函数
const router = useRouter(); // 获取 router 实例
const connectionsStore = useConnectionsStore();
// 使用 storeToRefs 来保持 state 属性的响应性
const { connections, isLoading, error } = storeToRefs(connectionsStore);
// 组件挂载时获取连接列表
onMounted(() => {
connectionsStore.fetchConnections();
});
// 辅助函数:格式化时间戳
const formatTimestamp = (timestamp: number | null): string => {
if (!timestamp) return t('connections.status.never'); // 使用 i18n
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
return new Date(timestamp * 1000).toLocaleString(); // 乘以 1000 转换为毫秒
};
</script>
<template>
<div class="connection-list">
<!-- 标题移到父组件 ConnectionsView.vue -->
<div v-if="isLoading" class="loading">{{ t('connections.loading') }}</div>
<div v-else-if="error" class="error">{{ t('connections.error', { error: error }) }}</div>
<div v-else-if="connections.length === 0" class="no-connections">
{{ t('connections.noConnections') }}
</div>
<table v-else>
<thead>
<tr>
<th>{{ t('connections.table.name') }}</th>
<th>{{ t('connections.table.host') }}</th>
<th>{{ t('connections.table.port') }}</th>
<th>{{ t('connections.table.user') }}</th>
<th>{{ t('connections.table.authMethod') }}</th>
<th>{{ t('connections.table.lastConnected') }}</th>
<th>{{ t('connections.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="conn in connections" :key="conn.id">
<td>{{ conn.name }}</td>
<td>{{ conn.host }}</td>
<td>{{ conn.port }}</td>
<td>{{ conn.username }}</td>
<td>{{ conn.auth_method }}</td>
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
<td>
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
<button @click="">{{ t('connections.actions.edit') }}</button> <!-- TODO: 实现编辑逻辑 -->
<button @click="">{{ t('connections.actions.delete') }}</button> <!-- TODO: 实现删除逻辑 -->
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
// 在 <script setup> 之外定义需要在模板中调用的方法
export default {
methods: {
connectToServer(connectionId: number) {
console.log(`请求连接到服务器 ID: ${connectionId}`);
// 使用 router 实例进行导航
this.$router.push({ name: 'Workspace', params: { connectionId: connectionId.toString() } });
}
}
}
</script>
<style scoped>
.connection-list {
margin-top: 1rem;
}
.loading, .error, .no-connections {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 1rem;
}
.error {
color: red;
border-color: red;
}
.no-connections {
color: #666;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
border: 1px solid #ddd;
padding: 0.5rem;
text-align: left;
}
th {
background-color: #f2f2f2;
}
button {
margin-right: 0.5rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
<template>
<div ref="editorContainer" class="monaco-editor-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as monaco from 'monaco-editor';
// Props for the component (will be expanded later)
const props = defineProps({
modelValue: { // Use modelValue for v-model support
type: String,
default: '',
},
language: {
type: String,
default: 'plaintext', // Default language
},
theme: {
type: String,
default: 'vs-dark', // Default theme (can be 'vs', 'vs-dark', 'hc-black')
},
readOnly: {
type: Boolean,
default: false,
}
});
// Emits for v-model update
const emit = defineEmits(['update:modelValue']);
const editorContainer = ref<HTMLElement | null>(null);
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
onMounted(() => {
if (editorContainer.value) {
editorInstance = monaco.editor.create(editorContainer.value, {
value: props.modelValue,
language: props.language,
theme: props.theme,
automaticLayout: true, // Auto resize editor on container resize
readOnly: props.readOnly,
// Add more options as needed
minimap: { enabled: true },
lineNumbers: 'on',
scrollBeyondLastLine: false,
});
// Listen for content changes and emit update event for v-model
editorInstance.onDidChangeModelContent(() => {
if (editorInstance) {
const currentValue = editorInstance.getValue();
if (currentValue !== props.modelValue) {
emit('update:modelValue', currentValue);
}
}
});
}
});
// Update editor content if modelValue prop changes from outside
watch(() => props.modelValue, (newValue) => {
if (editorInstance && editorInstance.getValue() !== newValue) {
editorInstance.setValue(newValue);
}
});
// Update language if prop changes
watch(() => props.language, (newLanguage) => {
if (editorInstance && editorInstance.getModel()) {
monaco.editor.setModelLanguage(editorInstance.getModel()!, newLanguage);
}
});
// Update theme if prop changes
watch(() => props.theme, (newTheme) => {
if (editorInstance) {
monaco.editor.setTheme(newTheme);
}
});
// Update readOnly status if prop changes
watch(() => props.readOnly, (newReadOnly) => {
if (editorInstance) {
editorInstance.updateOptions({ readOnly: newReadOnly });
}
});
onBeforeUnmount(() => {
if (editorInstance) {
editorInstance.dispose();
editorInstance = null;
}
});
// Expose a method to get the current value if needed (optional)
// defineExpose({
// getValue: () => editorInstance?.getValue()
// });
</script>
<style scoped>
.monaco-editor-container {
width: 100%;
height: 100%; /* Ensure the container has height */
min-height: 300px; /* Example minimum height */
text-align: left; /* Ensure editor content aligns left */
}
</style>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css'; // 引入 xterm 样式
// 定义 props 和 emits
const props = defineProps<{
stream?: ReadableStream<string>; // 用于接收来自 WebSocket 的数据流 (可选)
options?: object; // xterm 的配置选项
}>();
const emit = defineEmits<{
(e: 'data', data: string): void; // 用户输入事件
(e: 'resize', dimensions: { cols: number; rows: number }): void; // 终端大小调整事件
(e: 'ready', terminal: Terminal): void; // 终端准备就绪事件
}>();
const terminalRef = ref<HTMLElement | null>(null); // 终端容器的引用
let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
// 初始化终端
onMounted(() => {
if (terminalRef.value) {
terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: { // 简单主题示例
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
},
rows: 24, // 初始行数
cols: 80, // 初始列数
...props.options, // 合并外部传入的选项
});
// 加载插件
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
// 将终端附加到 DOM
terminal.open(terminalRef.value);
// 适应容器大小
fitAddon.fit();
emit('resize', { cols: terminal.cols, rows: terminal.rows }); // 触发初始 resize 事件
// 监听用户输入
terminal.onData((data) => {
emit('data', data);
});
// 监听终端大小变化 (通过 ResizeObserver)
if (terminalRef.value) {
resizeObserver = new ResizeObserver(() => {
try {
fitAddon?.fit();
} catch (e) {
console.warn("Fit addon resize failed:", e);
}
});
resizeObserver.observe(terminalRef.value);
}
// 监听 fitAddon 的 resize 事件,获取新的尺寸并触发 emit
// 注意:fitAddon 本身不直接触发 resize 事件,我们需要在 fit() 后手动获取
const originalFit = fitAddon.fit.bind(fitAddon);
fitAddon.fit = () => {
originalFit();
if (terminal) {
emit('resize', { cols: terminal.cols, rows: terminal.rows });
}
};
// 处理传入的数据流 (如果提供了 stream prop)
watch(() => props.stream, async (newStream) => {
if (newStream) {
const reader = newStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (terminal && value) {
terminal.write(value); // 将流数据写入终端
}
}
} catch (error) {
console.error('读取终端流时出错:', error);
} finally {
reader.releaseLock();
}
}
}, { immediate: true }); // 立即执行一次 watch
// 触发 ready 事件
emit('ready', terminal);
// 聚焦终端
terminal.focus();
}
});
// 组件卸载前清理资源
onBeforeUnmount(() => {
if (resizeObserver && terminalRef.value) {
resizeObserver.unobserve(terminalRef.value);
}
if (terminal) {
terminal.dispose();
terminal = null;
}
});
// 暴露 write 方法给父组件 (可选)
const write = (data: string | Uint8Array) => {
terminal?.write(data);
};
defineExpose({ write });
</script>
<template>
<div ref="terminalRef" class="terminal-container"></div>
</template>
<style scoped>
.terminal-container {
width: 100%;
height: 100%; /* 高度需要由父容器控制 */
overflow: hidden; /* 防止滚动条出现 */
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { createI18n } from 'vue-i18n';
// 导入语言文件
import enMessages from './locales/en.json';
import zhMessages from './locales/zh.json';
// 类型推断 (可选,但推荐)
type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key
// 获取浏览器语言或默认语言
const getInitialLocale = (): string => {
const navigatorLang = navigator.language?.split('-')[0]; // 获取 'en', 'zh' 等
if (navigatorLang === 'zh') {
return 'zh';
}
// 可以添加更多语言支持
return 'en'; // 默认英文
};
const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({
legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n
locale: getInitialLocale(), // 设置初始语言
fallbackLocale: 'en', // 如果当前语言缺少某个 key,则回退到英文
messages: {
en: enMessages,
zh: zhMessages,
},
// 可选:关闭控制台的 i18n 警告 (例如缺少 key 的警告)
// silentTranslationWarn: true,
// silentFallbackWarn: true,
});
export default i18n;
+151
View File
@@ -0,0 +1,151 @@
{
"appName": "Nexus Terminal",
"nav": {
"dashboard": "Dashboard",
"connections": "Connections",
"login": "Login",
"logout": "Logout"
},
"login": {
"title": "User Login",
"username": "Username",
"password": "Password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"error": "Login failed. Please check your username and password."
},
"connections": {
"title": "Connection Management",
"addConnection": "Add New Connection",
"loading": "Loading connections...",
"error": "Failed to load connections: {error}",
"noConnections": "No connections yet. Click 'Add New Connection' to create one!",
"table": {
"name": "Name",
"host": "Host",
"port": "Port",
"user": "User",
"authMethod": "Auth Method",
"lastConnected": "Last Connected",
"actions": "Actions"
},
"actions": {
"connect": "Connect",
"edit": "Edit",
"delete": "Delete"
},
"form": {
"title": "Add New Connection",
"name": "Name:",
"host": "Host/IP:",
"port": "Port:",
"username": "Username:",
"password": "Password:",
"confirm": "Confirm Add",
"adding": "Adding...",
"cancel": "Cancel",
"errorRequired": "All fields are required.",
"errorPort": "Port must be between 1 and 65535.",
"errorAdd": "Failed to add connection: {error}"
},
"status": {
"never": "Never"
}
},
"workspace": {
"statusBar": "Status: {status} (Connection ID: {id})",
"status": {
"initializing": "Initializing...",
"connectingWs": "Connecting to {url}...",
"wsConnected": "WebSocket connected, requesting SSH session...",
"connectingSsh": "Connecting to {host}...",
"sshConnected": "SSH connected, opening shell...",
"connected": "Connected",
"disconnected": "Disconnected: {reason}",
"wsClosed": "WebSocket closed (Code: {code})",
"error": "Error: {message}",
"wsError": "WebSocket connection error",
"sshError": "SSH Error: {message}",
"decryptError": "Cannot decrypt credentials.",
"noConnInfo": "Connection config not found for ID {id}.",
"noPassword": "Connection config is missing password.",
"shellError": "Failed to open shell: {message}",
"alreadyConnected": "An active SSH connection already exists.",
"unknown": "Unknown status"
},
"terminal": {
"infoPrefix": "[INFO]",
"errorPrefix": "[ERROR]",
"disconnectMsg": "--- SSH Connection Closed ({reason}) ---",
"wsCloseMsg": "--- WebSocket Connection Closed (Code: {code}) ---",
"wsErrorMsg": "--- WebSocket Connection Error ---",
"decryptErrorMsg": "--- Error: Cannot decrypt credentials ---",
"genericErrorMsg": "--- Error: {message} ---"
}
},
"fileManager": {
"currentPath": "Current Path",
"loading": "Loading directory...",
"emptyDirectory": "Directory is empty",
"uploadTasks": "Upload Tasks",
"actions": {
"refresh": "Refresh",
"parentDirectory": "Parent Directory",
"uploadFile": "Upload File",
"upload": "Upload",
"newFolder": "New Folder",
"rename": "Rename",
"changePermissions": "Change Permissions",
"delete": "Delete",
"deleteMultiple": "Delete {count} items",
"download": "Download",
"cancel": "Cancel",
"save": "Save"
},
"headers": {
"type": "Type",
"name": "Name",
"size": "Size",
"permissions": "Permissions",
"modified": "Modified",
"actions": "Actions"
},
"uploadStatus": {
"pending": "Pending",
"uploading": "Uploading",
"paused": "Paused",
"success": "Success",
"error": "Error",
"cancelled": "Cancelled"
},
"errors": {
"generic": "Error",
"websocketNotConnected": "WebSocket not connected",
"missingConnectionId": "Cannot get current connection ID",
"createFolderFailed": "Failed to create folder",
"deleteFailed": "Failed to delete",
"renameFailed": "Failed to rename",
"chmodFailed": "Failed to change permissions",
"invalidPermissionsFormat": "Invalid permissions format. Please enter 3 or 4 octal digits (e.g., 755 or 0755).",
"readFileError": "Error reading file",
"readFileFailed": "Failed to read file",
"fileDecodeError": "File decoding failed (likely not UTF-8)",
"saveFailed": "Failed to save file",
"saveTimeout": "Save timed out"
},
"prompts": {
"enterFolderName": "Enter the name for the new folder:",
"confirmOverwrite": "File \"{name}\" already exists. Overwrite?",
"confirmDeleteMultiple": "Are you sure you want to delete the selected {count} items? This cannot be undone.",
"confirmDeleteFolder": "Are you sure you want to delete the directory \"{name}\" and all its contents? This cannot be undone.",
"confirmDeleteFile": "Are you sure you want to delete the file \"{name}\"? This cannot be undone.",
"enterNewName": "Enter the new name for \"{oldName}\":",
"enterNewPermissions": "Enter new permissions for \"{name}\" (octal, e.g., 755):"
},
"editingFile": "Editing",
"loadingFile": "Loading file...",
"saving": "Saving",
"saveSuccess": "Save successful",
"saveError": "Save error"
}
}
+151
View File
@@ -0,0 +1,151 @@
{
"appName": "星枢终端",
"nav": {
"dashboard": "仪表盘",
"connections": "连接管理",
"login": "登录",
"logout": "登出"
},
"login": {
"title": "用户登录",
"username": "用户名",
"password": "密码",
"loginButton": "登录",
"loggingIn": "正在登录...",
"error": "登录失败,请检查用户名或密码。"
},
"connections": {
"title": "连接管理",
"addConnection": "添加新连接",
"loading": "正在加载连接...",
"error": "加载连接失败: {error}",
"noConnections": "还没有任何连接。点击“添加新连接”来创建一个吧!",
"table": {
"name": "名称",
"host": "主机",
"port": "端口",
"user": "用户名",
"authMethod": "认证方式",
"lastConnected": "上次连接",
"actions": "操作"
},
"actions": {
"connect": "连接",
"edit": "编辑",
"delete": "删除"
},
"form": {
"title": "添加新连接",
"name": "名称:",
"host": "主机/IP:",
"port": "端口:",
"username": "用户名:",
"password": "密码:",
"confirm": "确认添加",
"adding": "正在添加...",
"cancel": "取消",
"errorRequired": "所有字段均为必填项。",
"errorPort": "端口号必须在 1 到 65535 之间。",
"errorAdd": "添加连接失败: {error}"
},
"status": {
"never": "从未"
}
},
"workspace": {
"statusBar": "状态: {status} (连接 ID: {id})",
"status": {
"initializing": "正在初始化...",
"connectingWs": "正在连接到 {url}...",
"wsConnected": "WebSocket 已连接,正在请求 SSH 会话...",
"connectingSsh": "正在连接到 {host}...",
"sshConnected": "SSH 连接成功,正在打开 Shell...",
"connected": "已连接",
"disconnected": "已断开: {reason}",
"wsClosed": "WebSocket 已关闭 (代码: {code})",
"error": "错误: {message}",
"wsError": "WebSocket 连接错误",
"sshError": "SSH 错误: {message}",
"decryptError": "无法解密连接凭证。",
"noConnInfo": "未找到 ID 为 {id} 的连接配置。",
"noPassword": "连接配置缺少密码信息。",
"shellError": "打开 Shell 失败: {message}",
"alreadyConnected": "已存在活动的 SSH 连接。",
"unknown": "未知状态"
},
"terminal": {
"infoPrefix": "[信息]",
"errorPrefix": "[错误]",
"disconnectMsg": "--- SSH 连接已关闭 ({reason}) ---",
"wsCloseMsg": "--- WebSocket 连接已关闭 (代码: {code}) ---",
"wsErrorMsg": "--- WebSocket 连接错误 ---",
"decryptErrorMsg": "--- 错误:无法解密连接凭证 ---",
"genericErrorMsg": "--- 错误: {message} ---"
}
},
"fileManager": {
"currentPath": "当前路径",
"loading": "正在加载目录...",
"emptyDirectory": "目录为空",
"uploadTasks": "上传任务",
"actions": {
"refresh": "刷新",
"parentDirectory": "上一级",
"uploadFile": "上传文件",
"upload": "上传",
"newFolder": "新建文件夹",
"rename": "重命名",
"changePermissions": "修改权限",
"delete": "删除",
"deleteMultiple": "删除 {count} 个项目",
"download": "下载",
"cancel": "取消",
"save": "保存"
},
"headers": {
"type": "类型",
"name": "名称",
"size": "大小",
"permissions": "权限",
"modified": "修改时间",
"actions": "操作"
},
"uploadStatus": {
"pending": "等待中",
"uploading": "上传中",
"paused": "已暂停",
"success": "成功",
"error": "错误",
"cancelled": "已取消"
},
"errors": {
"generic": "错误",
"websocketNotConnected": "WebSocket 未连接",
"missingConnectionId": "无法获取当前连接 ID",
"createFolderFailed": "创建文件夹失败",
"deleteFailed": "删除失败",
"renameFailed": "重命名失败",
"chmodFailed": "修改权限失败",
"invalidPermissionsFormat": "无效的权限格式。请输入 3 或 4 位八进制数字 (例如 755 或 0755)。",
"readFileError": "读取文件时出错",
"readFileFailed": "读取文件失败",
"fileDecodeError": "文件解码失败 (可能不是 UTF-8 编码)",
"saveFailed": "保存文件失败",
"saveTimeout": "保存超时"
},
"prompts": {
"enterFolderName": "请输入新文件夹的名称:",
"confirmOverwrite": "文件 \"{name}\" 已存在。是否覆盖?",
"confirmDeleteMultiple": "确定要删除选定的 {count} 个项目吗?此操作不可撤销。",
"confirmDeleteFolder": "确定要删除目录 \"{name}\" 及其所有内容吗?此操作不可撤销。",
"confirmDeleteFile": "确定要删除文件 \"{name}\" 吗?此操作不可撤销。",
"enterNewName": "请输入 \"{oldName}\" 的新名称:",
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):"
},
"editingFile": "正在编辑",
"loadingFile": "正在加载文件...",
"saving": "正在保存",
"saveSuccess": "保存成功",
"saveError": "保存出错"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia'; // 引入 Pinia
import App from './App.vue';
import router from './router'; // 引入我们创建的 router
import i18n from './i18n'; // 引入 i18n 实例
import './style.css';
const app = createApp(App);
app.use(createPinia()); // 使用 Pinia
app.use(router); // 使用 Router
app.use(i18n); // 使用 i18n
app.mount('#app');
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.store'; // 导入 Auth Store
// 路由配置
const routes: Array<RouteRecordRaw> = [
// 首页/仪表盘 (占位符)
{
path: '/',
name: 'Dashboard',
// component: () => import('../views/DashboardView.vue') // 稍后创建
component: { template: '<div>仪表盘 (建设中)</div>' } // 临时占位
},
// 登录页面 (占位符)
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue') // 指向实际的登录组件
},
// 连接管理页面
{
path: '/connections',
name: 'Connections',
component: () => import('../views/ConnectionsView.vue')
},
// 工作区页面,需要 connectionId 参数
{
path: '/workspace/:connectionId', // 使用动态路由段
name: 'Workspace',
component: () => import('../views/WorkspaceView.vue'),
props: true // 将路由参数作为 props 传递给组件
},
// 其他路由...
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 使用 HTML5 History 模式
routes,
});
// 添加全局前置守卫
router.beforeEach((to, from, next) => {
// 在守卫内部获取 store 实例,确保 Pinia 已初始化
const authStore = useAuthStore();
const requiresAuth = !['Login'].includes(to.name as string); // 需要认证的路由 (除了登录页)
if (requiresAuth && !authStore.isAuthenticated) {
// 如果需要认证但用户未登录,重定向到登录页
console.log('路由守卫:未登录,重定向到 /login');
next({ name: 'Login' });
} else if (to.name === 'Login' && authStore.isAuthenticated) {
// 如果用户已登录但尝试访问登录页,重定向到仪表盘
console.log('路由守卫:已登录,从 /login 重定向到 /');
next({ name: 'Dashboard' });
} else {
// 其他情况允许导航
next();
}
});
export default router;
@@ -0,0 +1,82 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import router from '../router'; // 引入 router 用于重定向
// 用户信息接口 (不含敏感信息)
interface UserInfo {
id: number;
username: string;
}
// Auth Store State 接口
interface AuthState {
isAuthenticated: boolean;
user: UserInfo | null;
isLoading: boolean;
error: string | null;
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
isAuthenticated: false, // 初始为未登录
user: null,
isLoading: false,
error: null,
}),
getters: {
// 可以添加一些 getter,例如获取用户名
loggedInUser: (state) => state.user?.username,
},
actions: {
// 登录 Action
async login(credentials: { username: string; password: string }) {
this.isLoading = true;
this.error = null;
try {
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login', credentials);
// 登录成功
this.isAuthenticated = true;
this.user = response.data.user;
console.log('登录成功:', this.user);
// 登录成功后重定向到连接管理页面 (或仪表盘)
await router.push({ name: 'Connections' }); // 使用 await 确保导航完成
return true;
} catch (err: any) {
console.error('登录失败:', err);
this.isAuthenticated = false;
this.user = null;
this.error = err.response?.data?.message || err.message || '登录时发生未知错误。';
return false;
} finally {
this.isLoading = false;
}
},
// 登出 Action (占位符)
async logout() {
this.isLoading = true;
this.error = null;
try {
// TODO: 调用后端的登出 API (如果需要)
// await axios.post('/api/v1/auth/logout');
// 清除本地状态
this.isAuthenticated = false;
this.user = null;
console.log('已登出');
// 登出后重定向到登录页
await router.push({ name: 'Login' });
} catch (err: any) {
console.error('登出失败:', err);
this.error = err.response?.data?.message || err.message || '登出时发生未知错误。';
} finally {
this.isLoading = false;
}
},
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
// async checkAuthStatus() { ... }
},
// 可选:开启持久化 (例如使用 pinia-plugin-persistedstate)
// persist: true,
});
@@ -0,0 +1,74 @@
import { defineStore } from 'pinia';
import axios from 'axios'; // 引入 axios
// 定义连接信息接口 (与后端对应,不含敏感信息)
export interface ConnectionInfo {
id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: 'password';
created_at: number;
updated_at: number;
last_connected_at: number | null;
}
// 定义 Store State 的接口
interface ConnectionsState {
connections: ConnectionInfo[];
isLoading: boolean;
error: string | null;
}
// 定义 Pinia Store
export const useConnectionsStore = defineStore('connections', {
state: (): ConnectionsState => ({
connections: [],
isLoading: false,
error: null,
}),
actions: {
// 获取连接列表 Action
async fetchConnections() {
this.isLoading = true;
this.error = null;
try {
// 注意:axios 默认会携带 cookie,因此如果用户已登录,会话 cookie 会被发送
const response = await axios.get<ConnectionInfo[]>('/api/v1/connections');
this.connections = response.data;
} catch (err: any) {
console.error('获取连接列表失败:', err);
this.error = err.response?.data?.message || err.message || '获取连接列表时发生未知错误。';
// 如果是 401 未授权,可能需要触发重新登录逻辑
if (err.response?.status === 401) {
// TODO: 处理未授权情况,例如跳转到登录页
console.warn('未授权,需要登录才能获取连接列表。');
}
} finally {
this.isLoading = false;
}
},
// 添加新连接 Action
async addConnection(newConnectionData: { name: string; host: string; port: number; username: string; password: string }) {
this.isLoading = true; // 可以为添加操作单独设置加载状态,或共用 isLoading
this.error = null;
try {
const response = await axios.post<{ message: string; connection: ConnectionInfo }>('/api/v1/connections', newConnectionData);
// 添加成功后,将新连接添加到列表前面 (或重新获取整个列表)
this.connections.unshift(response.data.connection);
return true; // 表示成功
} catch (err: any) {
console.error('添加连接失败:', err);
this.error = err.response?.data?.message || err.message || '添加连接时发生未知错误。';
if (err.response?.status === 401) {
console.warn('未授权,需要登录才能添加连接。');
}
return false; // 表示失败
} finally {
this.isLoading = false;
}
},
},
});
+1
View File
@@ -0,0 +1 @@
/* Global styles will go here */
@@ -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>
+129
View File
@@ -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>