update
This commit is contained in:
@@ -19,6 +19,7 @@ const handleLogout = () => {
|
||||
<nav>
|
||||
<RouterLink to="/">{{ t('nav.dashboard') }}</RouterLink> |
|
||||
<RouterLink to="/connections">{{ t('nav.connections') }}</RouterLink> |
|
||||
<RouterLink to="/workspace">{{ t('nav.terminal') }}</RouterLink> | <!-- 新增终端链接 -->
|
||||
<RouterLink to="/proxies">{{ t('nav.proxies') }}</RouterLink> | <!-- 新增代理链接 -->
|
||||
<RouterLink to="/tags">{{ t('nav.tags') }}</RouterLink> | <!-- 新增标签链接 -->
|
||||
<RouterLink to="/notifications">{{ t('nav.notifications') }}</RouterLink> | <!-- 新增通知链接 -->
|
||||
|
||||
@@ -93,9 +93,9 @@ const handleSubmit = async () => {
|
||||
connectionsStore.error = null;
|
||||
proxiesStore.error = null; // 同时清除代理 store 的错误
|
||||
|
||||
// 基础前端验证 (保持不变)
|
||||
if (!formData.name || !formData.host || !formData.username) {
|
||||
formError.value = t('connections.form.errorRequiredFields'); // 更通用的错误消息
|
||||
// 基础前端验证 (移除名称验证)
|
||||
if (!formData.host || !formData.username) { // 移除 !formData.name
|
||||
formError.value = t('connections.form.errorRequiredFields'); // 保持通用错误消息,或可以细化
|
||||
return;
|
||||
}
|
||||
if (formData.port <= 0 || formData.port > 65535) {
|
||||
@@ -195,8 +195,8 @@ const handleSubmit = async () => {
|
||||
<h3>{{ formTitle }}</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 />
|
||||
<label for="conn-name">{{ t('connections.form.name') }} ({{ t('connections.form.optional') }})</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>
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
<div v-else class="status-grid">
|
||||
<div class="status-item cpu-model">
|
||||
<label>CPU 型号:</label>
|
||||
<span class="cpu-model-value" :title="statusData.cpuModel">{{ statusData.cpuModel ?? 'N/A' }}</span>
|
||||
<!-- 使用 displayCpuModel 计算属性 -->
|
||||
<span class="cpu-model-value" :title="displayCpuModel">{{ displayCpuModel }}</span>
|
||||
</div>
|
||||
<!-- Added OS Name Display -->
|
||||
<div class="status-item os-name">
|
||||
<label>系统:</label>
|
||||
<span class="os-name-value" :title="statusData.osName">{{ statusData.osName ?? 'N/A' }}</span>
|
||||
<!-- 使用 displayOsName 计算属性 -->
|
||||
<span class="os-name-value" :title="displayOsName">{{ displayOsName }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>CPU:</label>
|
||||
@@ -56,9 +58,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue'; // 引入 watch
|
||||
|
||||
// Interface matching the backend's ServerStatusDetails
|
||||
// 接口定义,与后端 ServerStatusDetails 匹配
|
||||
interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
memPercent?: number;
|
||||
@@ -74,16 +76,46 @@ interface ServerStatus {
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
netInterface?: string;
|
||||
osName?: string; // Added OS Name
|
||||
osName?: string; // 操作系统名称
|
||||
}
|
||||
|
||||
// Props to receive status data from parent
|
||||
// Props 用于接收父组件传递的状态数据和错误信息
|
||||
const props = defineProps<{
|
||||
statusData: ServerStatus | null;
|
||||
error?: string | null;
|
||||
}>();
|
||||
|
||||
// Helper function to format bytes into appropriate units (B, KB, MB, GB) /s
|
||||
// --- 缓存状态 ---
|
||||
const cachedCpuModel = ref<string | null>(null);
|
||||
const cachedOsName = ref<string | null>(null);
|
||||
|
||||
// 监听传入的 statusData 变化以更新缓存
|
||||
watch(() => props.statusData, (newData) => {
|
||||
if (newData) {
|
||||
// 仅当新数据有效时更新缓存
|
||||
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
|
||||
cachedCpuModel.value = newData.cpuModel;
|
||||
}
|
||||
if (newData.osName !== undefined && newData.osName !== null && newData.osName !== '') {
|
||||
cachedOsName.value = newData.osName;
|
||||
}
|
||||
}
|
||||
// 如果 newData 为 null (例如断开连接),不清除缓存
|
||||
}, { immediate: true }); // 立即执行一次以初始化缓存
|
||||
|
||||
// --- 显示计算属性 (包含缓存逻辑) ---
|
||||
const displayCpuModel = computed(() => {
|
||||
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
|
||||
return (props.statusData?.cpuModel ?? cachedCpuModel.value) || 'N/A';
|
||||
});
|
||||
|
||||
const displayOsName = computed(() => {
|
||||
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
|
||||
return (props.statusData?.osName ?? cachedOsName.value) || 'N/A';
|
||||
});
|
||||
|
||||
|
||||
// 辅助函数:格式化字节/秒为合适的单位 (B, KB, MB, GB)
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B/s`;
|
||||
@@ -93,37 +125,39 @@ const formatBytesPerSecond = (bytes?: number): string => {
|
||||
};
|
||||
|
||||
|
||||
// Helper function to format bytes (KB from backend) into GB
|
||||
// 辅助函数:格式化 KB 为 GB
|
||||
const formatKbToGb = (kb?: number): string => {
|
||||
if (kb === undefined || kb === null) return 'N/A';
|
||||
if (kb === 0) return '0.0 GB';
|
||||
if (kb === undefined || kb === null) return 'N/A'; // 处理无效输入
|
||||
if (kb === 0) return '0.0 GB'; // 处理 0 的情况
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
// Computed properties for display
|
||||
// 计算属性用于显示内存信息
|
||||
const memDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A';
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A'; // 检查数据有效性
|
||||
const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : '';
|
||||
// Ensure MB values are displayed without decimals if they are integers
|
||||
// 确保 MB 值在是整数时不显示小数
|
||||
const usedMb = Number.isInteger(data.memUsed) ? data.memUsed : data.memUsed.toFixed(1);
|
||||
const totalMb = Number.isInteger(data.memTotal) ? data.memTotal : data.memTotal.toFixed(1);
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
// 计算属性用于显示磁盘信息
|
||||
const diskDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A';
|
||||
// Percentage represents used space
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A'; // 检查数据有效性
|
||||
// 百分比代表已用空间
|
||||
const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : '';
|
||||
// Display Used / Total
|
||||
// 显示 已用 / 总量
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`;
|
||||
});
|
||||
|
||||
// 计算属性用于显示 Swap 信息
|
||||
const swapDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
// Handle cases where swap might be undefined or 0
|
||||
// 处理 swap 可能为 undefined 或 0 的情况
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
// import { useRouter } from 'vue-router'; // 不再需要 router
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store';
|
||||
import { useTagsStore, TagInfo } from '../stores/tags.store';
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['connect-request', 'request-add-connection', 'request-edit-connection']); // 添加新事件
|
||||
|
||||
const { t } = useI18n();
|
||||
// const router = useRouter(); // 不再需要
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const tagsStore = useTagsStore();
|
||||
|
||||
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
|
||||
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
|
||||
|
||||
// 右键菜单状态
|
||||
const contextMenuVisible = ref(false);
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||
const contextTargetConnection = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// 分组展开状态
|
||||
const expandedGroups = ref<Record<string, boolean>>({}); // 使用 Record<string, boolean>
|
||||
|
||||
// 计算属性:按标签分组连接
|
||||
const groupedConnections = computed(() => {
|
||||
const groups: Record<string, ConnectionInfo[]> = {};
|
||||
const untagged: ConnectionInfo[] = [];
|
||||
const tagMap = new Map(tags.value.map(tag => [tag.id, tag]));
|
||||
|
||||
connections.value.forEach(conn => {
|
||||
if (conn.tag_ids && conn.tag_ids.length > 0) {
|
||||
conn.tag_ids.forEach(tagId => {
|
||||
const tag = tagMap.get(tagId);
|
||||
const groupName = tag ? tag.name : t('workspaceConnectionList.untagged'); // Fallback if tag not found
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
if (expandedGroups.value[groupName] === undefined) {
|
||||
expandedGroups.value[groupName] = true; // 默认展开
|
||||
}
|
||||
}
|
||||
groups[groupName].push(conn);
|
||||
});
|
||||
} else {
|
||||
untagged.push(conn);
|
||||
}
|
||||
});
|
||||
|
||||
// 对每个分组内的连接按名称或主机排序
|
||||
for (const groupName in groups) {
|
||||
groups[groupName].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
|
||||
}
|
||||
untagged.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
|
||||
|
||||
|
||||
// 将未标记的分组放在最后
|
||||
const sortedGroupNames = Object.keys(groups).sort();
|
||||
const result: { groupName: string; connections: ConnectionInfo[] }[] = sortedGroupNames.map(name => ({
|
||||
groupName: name,
|
||||
connections: groups[name]
|
||||
}));
|
||||
|
||||
if (untagged.length > 0) {
|
||||
const untaggedGroupName = t('workspaceConnectionList.untagged');
|
||||
if (expandedGroups.value[untaggedGroupName] === undefined) {
|
||||
expandedGroups.value[untaggedGroupName] = true; // 默认展开
|
||||
}
|
||||
result.push({ groupName: untaggedGroupName, connections: untagged });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// 切换分组展开/折叠
|
||||
const toggleGroup = (groupName: string) => {
|
||||
expandedGroups.value[groupName] = !expandedGroups.value[groupName];
|
||||
};
|
||||
|
||||
// 处理单击连接
|
||||
const handleConnect = (connectionId: number) => {
|
||||
emit('connect-request', connectionId);
|
||||
closeContextMenu(); // 点击连接后关闭菜单
|
||||
};
|
||||
|
||||
// 显示右键菜单
|
||||
const showContextMenu = (event: MouseEvent, connection: ConnectionInfo) => {
|
||||
contextTargetConnection.value = connection;
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||||
contextMenuVisible.value = true;
|
||||
// 添加全局点击监听器以关闭菜单
|
||||
document.addEventListener('click', closeContextMenu, { once: true });
|
||||
};
|
||||
|
||||
// 关闭右键菜单
|
||||
const closeContextMenu = () => {
|
||||
contextMenuVisible.value = false;
|
||||
contextTargetConnection.value = null;
|
||||
document.removeEventListener('click', closeContextMenu);
|
||||
};
|
||||
|
||||
// 处理右键菜单操作
|
||||
const handleMenuAction = (action: 'add' | 'edit' | 'delete') => {
|
||||
const conn = contextTargetConnection.value;
|
||||
closeContextMenu(); // 先关闭菜单
|
||||
|
||||
if (action === 'add') {
|
||||
// router.push('/connections/add'); // 改为触发事件
|
||||
emit('request-add-connection');
|
||||
} else if (conn) {
|
||||
if (action === 'edit') {
|
||||
// router.push(`/connections/edit/${conn.id}`); // 改为触发事件
|
||||
emit('request-edit-connection', conn); // 传递整个连接对象
|
||||
} else if (action === 'delete') {
|
||||
if (confirm(t('connections.prompts.confirmDelete', { name: conn.name || conn.host }))) {
|
||||
connectionsStore.deleteConnection(conn.id);
|
||||
// 注意:删除后列表会自动更新,因为 store 是响应式的
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取数据
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections();
|
||||
tagsStore.fetchTags();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-connection-list">
|
||||
<div v-if="connectionsLoading || tagsLoading" class="loading">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="connectionsError || tagsError" class="error">
|
||||
{{ connectionsError || tagsError }}
|
||||
</div>
|
||||
<div v-else-if="connections.length === 0" class="no-connections">
|
||||
{{ t('connections.noConnections') }}
|
||||
<button @click="handleMenuAction('add')">{{ t('connections.addConnection') }}</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- 添加连接按钮(总是在顶部) -->
|
||||
<button class="add-connection-button" @click="handleMenuAction('add')">
|
||||
<i class="fas fa-plus"></i> {{ t('connections.addConnection') }}
|
||||
</button>
|
||||
<div v-for="group in groupedConnections" :key="group.groupName" class="connection-group">
|
||||
<div class="group-header" @click="toggleGroup(group.groupName)">
|
||||
<i :class="['fas', expandedGroups[group.groupName] ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||||
<span>{{ group.groupName }}</span>
|
||||
</div>
|
||||
<ul v-show="expandedGroups[group.groupName]" class="connection-items">
|
||||
<li
|
||||
v-for="conn in group.connections"
|
||||
:key="conn.id"
|
||||
class="connection-item"
|
||||
@click="handleConnect(conn.id)"
|
||||
@contextmenu.prevent="showContextMenu($event, conn)"
|
||||
>
|
||||
<i class="fas fa-server connection-icon"></i>
|
||||
<span class="connection-name" :title="conn.name || conn.host">
|
||||
{{ conn.name || conn.host }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenuVisible"
|
||||
class="context-menu"
|
||||
:style="{ top: `${contextMenuPosition.y}px`, left: `${contextMenuPosition.x}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 防止点击菜单内部关闭菜单 -->
|
||||
<ul>
|
||||
<li @click="handleMenuAction('add')"><i class="fas fa-plus"></i> {{ t('connections.addConnection') }}</li>
|
||||
<li v-if="contextTargetConnection" @click="handleMenuAction('edit')"><i class="fas fa-edit"></i> {{ t('connections.actions.edit') }}</li>
|
||||
<li v-if="contextTargetConnection" @click="handleMenuAction('delete')"><i class="fas fa-trash-alt"></i> {{ t('connections.actions.delete') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-connection-list {
|
||||
padding: 0.5rem 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background-color: #f8f9fa; /* Slightly different background */
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.loading, .error, .no-connections {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
.no-connections button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
.add-connection-button {
|
||||
display: block;
|
||||
width: calc(100% - 1rem); /* Adjust width */
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
background-color: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
color: #495057;
|
||||
}
|
||||
.add-connection-button:hover {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
.add-connection-button i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.connection-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
background-color: #e9ecef;
|
||||
border-top: 1px solid #dee2e6;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #495057;
|
||||
}
|
||||
.group-header:hover {
|
||||
background-color: #ced4da;
|
||||
}
|
||||
|
||||
.group-header i {
|
||||
margin-right: 0.5rem;
|
||||
width: 1em; /* Ensure icon takes space */
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
/* Rotate chevron when collapsed */
|
||||
.group-header i.fa-chevron-right {
|
||||
/* transform: rotate(0deg); */ /* Default state */
|
||||
}
|
||||
.group-header i.fa-chevron-down {
|
||||
/* transform: rotate(90deg); */ /* Rotated state */
|
||||
}
|
||||
|
||||
|
||||
.connection-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
padding: 0.5rem 1rem 0.5rem 1.5rem; /* Indent items */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f3f5; /* Lighter separator */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.connection-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
margin-right: 0.6rem;
|
||||
color: #6c757d;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Context Menu Styles */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0;
|
||||
z-index: 1001; /* Above the list */
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.context-menu ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.context-menu li {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.context-menu li:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
.context-menu li i {
|
||||
margin-right: 0.75rem;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
color: #495057;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"connections": "连接管理",
|
||||
"terminal": "终端",
|
||||
"proxies": "代理管理",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
@@ -170,6 +171,8 @@
|
||||
"alreadyConnected": "已存在活动的 SSH 连接。",
|
||||
"unknown": "未知状态"
|
||||
},
|
||||
"selectConnectionPrompt": "请选择一个连接",
|
||||
"selectConnectionHint": "从左侧列表中选择一个连接以开始。",
|
||||
"terminal": {
|
||||
"infoPrefix": "[信息]",
|
||||
"errorPrefix": "[错误]",
|
||||
@@ -506,5 +509,8 @@
|
||||
"SERVER_ERROR": "服务器错误",
|
||||
"DATABASE_MIGRATION": "数据库迁移"
|
||||
}
|
||||
},
|
||||
"workspaceConnectionList": {
|
||||
"untagged": "未标记"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'Tags',
|
||||
component: () => import('../views/TagsView.vue')
|
||||
},
|
||||
// 工作区页面,需要 connectionId 参数
|
||||
// 工作区页面 (不再需要 connectionId 参数)
|
||||
{
|
||||
path: '/workspace/:connectionId', // 使用动态路由段
|
||||
path: '/workspace', // 移除动态路由段
|
||||
name: 'Workspace',
|
||||
component: () => import('../views/WorkspaceView.vue'),
|
||||
props: true // 将路由参数作为 props 传递给组件
|
||||
// props: true // 不再需要传递 props
|
||||
},
|
||||
// 新增:设置页面
|
||||
{
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'; // Added watch
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import TerminalComponent from '../components/Terminal.vue'; // 引入终端组件
|
||||
import FileManagerComponent from '../components/FileManager.vue'; // 引入文件管理器组件
|
||||
import StatusMonitorComponent from '../components/StatusMonitor.vue'; // 引入状态监控组件
|
||||
import type { Terminal } from 'xterm'; // 引入 Terminal 类型
|
||||
import { useWebSocketConnection } from '../composables/useWebSocketConnection'; // 只导入 hook
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; // 移除 computed, useRoute, useRouter
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TerminalComponent from '../components/Terminal.vue';
|
||||
import FileManagerComponent from '../components/FileManager.vue';
|
||||
import StatusMonitorComponent from '../components/StatusMonitor.vue';
|
||||
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
|
||||
import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; // 引入表单组件
|
||||
import type { ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
|
||||
import type { Terminal } from 'xterm';
|
||||
import { useWebSocketConnection } from '../composables/useWebSocketConnection';
|
||||
import { useSshTerminal } from '../composables/useSshTerminal'; // 导入 SSH 终端模块
|
||||
import { useStatusMonitor } from '../composables/useStatusMonitor'; // 导入状态监控模块
|
||||
import type { ServerStatus } from '../types/server.types'; // 从类型文件导入 ServerStatus
|
||||
// Removed duplicate/unused import: import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
import { useStatusMonitor } from '../composables/useStatusMonitor';
|
||||
import type { ServerStatus } from '../types/server.types';
|
||||
// import { useConnectionsStore } from '../stores/connections.store'; // 不再直接在此处使用 store
|
||||
// import { storeToRefs } from 'pinia'; // 不再直接在此处使用 storeToRefs
|
||||
|
||||
// --- 接口定义 ---
|
||||
// ServerStatus 现在从 types/server.types.ts 导入
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const route = useRoute();
|
||||
const connectionId = computed(() => route.params.connectionId as string); // 从路由获取 connectionId
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- 内部状态 ---
|
||||
const activeConnectionId = ref<string | null>(null);
|
||||
const showAddEditForm = ref(false); // 控制表单模态框显示
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null); // 要编辑的连接
|
||||
|
||||
// --- 连接 Store (不再需要在此处直接引用 connections, loading, error) ---
|
||||
// const connectionsStore = useConnectionsStore();
|
||||
// const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
|
||||
|
||||
// --- WebSocket 连接模块 ---
|
||||
const {
|
||||
@@ -51,15 +61,16 @@ const {
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(() => {
|
||||
if (connectionId.value) {
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`; // 构建 WebSocket URL
|
||||
connect(wsUrl, connectionId.value); // 使用 WebSocket 模块的 connect
|
||||
// 组件挂载时不自动连接,等待用户选择
|
||||
// if (activeConnectionId.value) {
|
||||
// const wsUrl = `ws://${window.location.hostname}:3001`;
|
||||
// connect(wsUrl, activeConnectionId.value);
|
||||
// 不在此处立即注册,等待 isConnected 变为 true
|
||||
// registerSshHandlers();
|
||||
// registerStatusHandlers();
|
||||
} else {
|
||||
console.error('[工作区视图] 缺少 connectionId 路由参数。');
|
||||
}
|
||||
// } else {
|
||||
// console.log('[工作区视图] 没有活动的连接 ID。'); // 不再是错误
|
||||
// }
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -68,28 +79,21 @@ onBeforeUnmount(() => {
|
||||
unregisterAllStatusHandlers(); // 使用状态监控模块的注销函数
|
||||
});
|
||||
|
||||
// 监听 connectionId 变化 (例如,在工作区之间导航)
|
||||
watch(connectionId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
console.log(`[工作区视图] 连接 ID 从 ${oldId} 更改为 ${newId}。正在重新连接...`);
|
||||
// 断开现有连接,注销处理器,然后为新 ID 连接并注册
|
||||
disconnect();
|
||||
unregisterAllSshHandlers();
|
||||
unregisterAllStatusHandlers(); // 使用状态监控模块的注销函数
|
||||
// serverStatus 和 statusError 由 useStatusMonitor 自动管理,无需手动重置
|
||||
|
||||
// 重新连接
|
||||
// 监听 activeConnectionId 变化以处理连接切换
|
||||
watch(activeConnectionId, (newId, oldId) => {
|
||||
console.log(`[工作区视图] 活动连接 ID 从 ${oldId} 更改为 ${newId}`);
|
||||
// 断开旧连接 (如果存在)
|
||||
if (oldId) {
|
||||
disconnect(); // isConnected 会变为 false,触发清理
|
||||
}
|
||||
// 连接新的 WebSocket (如果新 ID 有效)
|
||||
if (newId) {
|
||||
console.log(`[工作区视图] 正在连接到 ID: ${newId}...`);
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`;
|
||||
connect(wsUrl, newId);
|
||||
// registerSshHandlers(); // 注册移至 isConnected watch
|
||||
// registerStatusHandlers(); // 注册移至 isConnected watch
|
||||
} else if (!newId && oldId) {
|
||||
// 导航离开工作区视图
|
||||
disconnect(); // isConnected 会变为 false,自动触发清理
|
||||
// unregisterAllSshHandlers(); // 注销移至 isConnected watch
|
||||
// unregisterAllStatusHandlers(); // 注销移至 isConnected watch
|
||||
}
|
||||
});
|
||||
connect(wsUrl, newId); // connect 会处理 isConnected 状态
|
||||
}
|
||||
// 注意:处理器的注册/注销现在完全由 isConnected 的 watch 驱动
|
||||
});
|
||||
|
||||
// 监听 WebSocket 连接状态变化来注册/注销处理器
|
||||
watch(isConnected, (connected) => {
|
||||
@@ -109,38 +113,98 @@ watch(isConnected, (connected) => {
|
||||
|
||||
// 辅助函数:获取终端消息文本 (已移至 useSshTerminal)
|
||||
|
||||
// --- 连接列表点击处理 ---
|
||||
const handleConnectRequest = (id: number | string) => {
|
||||
console.log(`[工作区视图] 请求激活连接 ID: ${id}`);
|
||||
activeConnectionId.value = String(id);
|
||||
};
|
||||
|
||||
// --- 表单模态框处理 ---
|
||||
const handleRequestAddConnection = () => {
|
||||
connectionToEdit.value = null; // 确保是添加模式
|
||||
showAddEditForm.value = true;
|
||||
};
|
||||
|
||||
const handleRequestEditConnection = (connection: ConnectionInfo) => {
|
||||
connectionToEdit.value = connection; // 设置要编辑的连接
|
||||
showAddEditForm.value = true;
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
showAddEditForm.value = false;
|
||||
connectionToEdit.value = null; // 清除编辑状态
|
||||
};
|
||||
|
||||
const handleConnectionAdded = () => {
|
||||
console.log('[工作区视图] 连接已添加');
|
||||
handleFormClose();
|
||||
// WorkspaceConnectionList 会自动从 store 更新
|
||||
};
|
||||
|
||||
const handleConnectionUpdated = () => {
|
||||
console.log('[工作区视图] 连接已更新');
|
||||
handleFormClose();
|
||||
// WorkspaceConnectionList 会自动从 store 更新
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-view">
|
||||
<div class="status-bar">
|
||||
<!-- 使用 t 函数渲染状态栏文本 -->
|
||||
{{ t('workspace.statusBar', { status: statusMessage, id: connectionId }) }}
|
||||
<!-- 使用 t 函数渲染状态栏文本, 显示 activeConnectionId -->
|
||||
{{ t('workspace.statusBar', { status: statusMessage, id: activeConnectionId ?? 'N/A' }) }}
|
||||
<!-- 状态颜色仍然通过 class 绑定 -->
|
||||
<!-- 使用来自 useWebSocketConnection 的状态 -->
|
||||
<span :class="`status-${connectionStatus}`"></span>
|
||||
</div>
|
||||
<div class="main-content-area">
|
||||
<div class="left-pane">
|
||||
<div class="terminal-wrapper">
|
||||
<!-- 将事件绑定到 useSshTerminal 的处理函数 -->
|
||||
<!-- 新增左侧边栏 -->
|
||||
<div class="left-sidebar">
|
||||
<!-- 监听新的事件 -->
|
||||
<WorkspaceConnectionListComponent
|
||||
@connect-request="handleConnectRequest"
|
||||
@request-add-connection="handleRequestAddConnection"
|
||||
@request-edit-connection="handleRequestEditConnection"
|
||||
/>
|
||||
</div>
|
||||
<!-- 主工作区 (添加 v-if/v-else), 条件改为 activeConnectionId -->
|
||||
<div v-if="activeConnectionId" class="main-workspace-area">
|
||||
<div class="left-pane">
|
||||
<div class="terminal-wrapper">
|
||||
<!-- 将事件绑定到 useSshTerminal 的处理函数 -->
|
||||
<TerminalComponent
|
||||
@ready="handleTerminalReady"
|
||||
@data="handleTerminalData"
|
||||
@resize="handleTerminalResize"
|
||||
/>
|
||||
@resize="handleTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<!-- Removed :ws prop. Communication will be handled via composables -->
|
||||
<FileManagerComponent :is-connected="isConnected" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<!-- Removed :ws prop. Communication will be handled via composables -->
|
||||
<FileManagerComponent :is-connected="isConnected" />
|
||||
<!-- 状态监控窗格 -->
|
||||
<div class="status-monitor-wrapper">
|
||||
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态监控窗格 -->
|
||||
<div class="status-monitor-wrapper">
|
||||
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" />
|
||||
<!-- 当没有 connectionId 时显示提示 -->
|
||||
<div v-else class="main-workspace-area placeholder">
|
||||
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
||||
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑连接表单模态框 -->
|
||||
<AddConnectionFormComponent
|
||||
v-if="showAddEditForm"
|
||||
:connection-to-edit="connectionToEdit"
|
||||
@close="handleFormClose"
|
||||
@connection-added="handleConnectionAdded"
|
||||
@connection-updated="handleConnectionUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -150,7 +214,7 @@ watch(isConnected, (connected) => {
|
||||
flex-direction: column;
|
||||
/* 调整高度计算以适应可能的 header/footer/status-bar */
|
||||
height: calc(100vh - 60px - 30px - 2rem); /* 假设 header 60px, footer 30px, padding 2rem */
|
||||
overflow: hidden; /* 防止页面滚动 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
@@ -169,21 +233,45 @@ watch(isConnected, (connected) => {
|
||||
.main-content-area {
|
||||
display: flex;
|
||||
flex-grow: 1; /* Take remaining vertical space */
|
||||
overflow: hidden; /* Prevent this container from scrolling */
|
||||
overflow: hidden;
|
||||
/* 新增样式 */
|
||||
border-top: 1px solid #ccc; /* Add a top border for separation */
|
||||
}
|
||||
|
||||
/* 新增左侧边栏样式 */
|
||||
.left-sidebar {
|
||||
width: 250px; /* 示例宽度 */
|
||||
min-width: 200px; /* 最小宽度 */
|
||||
height: 100%;
|
||||
border-right: 2px solid #ccc;
|
||||
overflow-y: auto; /* 如果列表过长则允许滚动 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.left-sidebar > * {
|
||||
flex-grow: 1; /* 让 WorkspaceConnectionList 填充 */
|
||||
}
|
||||
|
||||
|
||||
/* 主工作区容器 */
|
||||
.main-workspace-area {
|
||||
flex-grow: 1; /* 占据剩余空间 */
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%; /* Example width, adjust as needed */
|
||||
/* width: 80%; */ /* 不再固定宽度,改为 flex */
|
||||
flex-grow: 1; /* 占据主工作区大部分空间 */
|
||||
height: 100%;
|
||||
min-width: 300px; /* 保证终端和文件管理器有最小宽度 */
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
/* flex-grow: 1; */ /* 不再让终端独占剩余空间 */
|
||||
height: 60%; /* 示例:终端占 60% 高度 */
|
||||
/* width: 50%; */ /* Removed width */
|
||||
/* height: 100%; */ /* Removed height */
|
||||
background-color: #1e1e1e; /* 终端背景色 */
|
||||
overflow: hidden; /* 内部滚动由 xterm 处理 */
|
||||
display: flex; /* Ensure TerminalComponent fills this wrapper */
|
||||
@@ -196,9 +284,6 @@ watch(isConnected, (connected) => {
|
||||
|
||||
.file-manager-wrapper {
|
||||
height: 40%; /* 示例:文件管理器占 40% 高度 */
|
||||
/* width: 30%; */ /* Removed width */
|
||||
/* height: 100%; */ /* Removed height */
|
||||
/* border-left: 2px solid #ccc; */ /* Removed left border */
|
||||
border-top: 2px solid #ccc; /* Add top border */
|
||||
overflow: hidden; /* 防止自身滚动 */
|
||||
display: flex; /* Ensure FileManagerComponent fills this wrapper */
|
||||
@@ -209,14 +294,36 @@ watch(isConnected, (connected) => {
|
||||
}
|
||||
|
||||
.status-monitor-wrapper {
|
||||
width: 20%; /* Example width */
|
||||
/* width: 20%; */ /* 不再固定宽度,改为 flex-basis */
|
||||
flex-basis: 250px; /* 示例基础宽度 */
|
||||
min-width: 200px; /* 最小宽度 */
|
||||
height: 100%;
|
||||
border-left: 2px solid #ccc; /* Separator */
|
||||
overflow: hidden; /* Prevent scrolling */
|
||||
border-left: 2px solid #ccc;
|
||||
overflow: hidden;
|
||||
display: flex; /* Ensure StatusMonitorComponent fills this wrapper */
|
||||
flex-direction: column;
|
||||
}
|
||||
.status-monitor-wrapper > * {
|
||||
flex-grow: 1; /* Make StatusMonitorComponent fill the wrapper */
|
||||
}
|
||||
|
||||
/* 新增:占位符样式 */
|
||||
.main-workspace-area.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
padding: 2rem;
|
||||
background-color: #f8f9fa; /* Match sidebar background */
|
||||
}
|
||||
.main-workspace-area.placeholder h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 300;
|
||||
color: #495057;
|
||||
}
|
||||
.main-workspace-area.placeholder p {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user