update
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user