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
@@ -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>