update
This commit is contained in:
@@ -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>© 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>
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "保存出错"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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