This commit is contained in:
Baobhan Sith
2025-04-15 20:39:30 +08:00
parent 37eb5ee9ab
commit 6ee18743ad
14 changed files with 622 additions and 117 deletions
+1
View File
@@ -134,3 +134,4 @@ dist
*.db
/packages/data
*.db
*.db
@@ -20,9 +20,10 @@ const auditLogService = new AuditLogService(); // 实例化 AuditLogService
export const createConnection = async (req: Request, res: Response): Promise<void> => {
try {
// 基本输入验证(更复杂的验证可以在服务层或使用中间件)
const { name, host, username, auth_method, password, private_key } = req.body;
if (!name || !host || !username || !auth_method) {
res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, auth_method)。' });
// 移除控制器层对 name 的验证,服务层会处理
const { host, username, auth_method, password, private_key } = req.body;
if (!host || !username || !auth_method) { // 移除 !name 检查
res.status(400).json({ message: '缺少必要的连接信息 (host, username, auth_method)。' }); // 更新错误消息
return;
}
if (auth_method === 'password' && !password) {
+1 -1
View File
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS proxies (
const createConnectionsTableSQL = `
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
name TEXT NULL, -- 允许 name 为空
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
@@ -7,7 +7,7 @@ const db = getDb();
// 注意:这里不包含加密字段,因为 Repository 不应处理解密
interface ConnectionBase {
id: number;
name: string;
name: string | null; // 允许 name 为 null
host: string;
port: number;
username: string;
@@ -126,7 +126,8 @@ export const findFullConnectionById = async (id: number): Promise<any | null> =>
/**
* 创建新连接
*/
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'>): Promise<number> => {
// Update function signature to accept name as string | null
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { name: string | null }): Promise<number> => {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
@@ -134,7 +135,8 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
stmt.run(
data.name, data.host, data.port, data.username, data.auth_method,
data.name ?? null, // Ensure null is passed if name is null/undefined
data.host, data.port, data.username, data.auth_method,
data.encrypted_password ?? null, data.encrypted_private_key ?? null, data.encrypted_passphrase ?? null,
data.proxy_id ?? null,
now, now,
@@ -153,7 +155,8 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
/**
* 更新连接信息
*/
export const updateConnection = async (id: number, data: Partial<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at'>>): Promise<boolean> => {
// Update function signature to accept name as string | null | undefined
export const updateConnection = async (id: number, data: Partial<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at'> & { name?: string | null }>): Promise<boolean> => {
const fieldsToUpdate: { [key: string]: any } = { ...data };
const params: any[] = [];
@@ -6,7 +6,7 @@ import { encrypt, decrypt } from '../utils/crypto';
// For now, let's reuse the interfaces from the repository (adjust as needed)
export interface ConnectionBase {
id: number;
name: string;
name: string | null; // Allow name to be null
host: string;
port: number;
username: string;
@@ -23,7 +23,7 @@ export interface ConnectionWithTags extends ConnectionBase {
// Input type for creating a connection (from controller)
export interface CreateConnectionInput {
name: string;
name?: string; // Name is now optional
host: string;
port?: number; // Optional, defaults in service/repo
username: string;
@@ -70,8 +70,9 @@ export const getConnectionById = async (id: number): Promise<ConnectionWithTags
*/
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
// 1. Validate input (basic validation, more complex validation can be added)
if (!input.name || !input.host || !input.username || !input.auth_method) {
throw new Error('缺少必要的连接信息 (name, host, username, auth_method)。');
// Removed name validation: if (!input.name || !input.host || !input.username || !input.auth_method) {
if (!input.host || !input.username || !input.auth_method) { // Validate required fields except name
throw new Error('缺少必要的连接信息 (host, username, auth_method)。');
}
if (input.auth_method === 'password' && !input.password) {
throw new Error('密码认证方式需要提供 password。');
@@ -97,7 +98,7 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
// 3. Prepare data for repository
const connectionData = {
name: input.name,
name: input.name || '', // Use empty string '' if name is empty or undefined
host: input.host,
port: input.port ?? 22, // Default port
username: input.username,
@@ -142,7 +143,7 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
let newAuthMethod = input.auth_method || currentFullConnection.auth_method;
// Update non-credential fields
if (input.name !== undefined) dataToUpdate.name = input.name;
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // Use empty string '' if name is empty string or null/undefined
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username;
@@ -223,8 +223,17 @@ export class StatusMonitorService {
* @returns Promise<NetworkStats | null> null
*/
private async parseProcNetDev(sshClient: Client): Promise<NetworkStats | null> {
let output: string;
try {
// 将命令执行放入 try...catch
output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
} catch (error) {
// 如果命令失败,记录警告并返回 null
console.warn("[StatusMonitor] Failed to execute 'cat /proc/net/dev':", error);
return null;
}
// 如果命令成功,继续解析
try {
const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
const lines = output.split('\n').slice(2); // Skip header lines
const stats: NetworkStats = {};
for (const line of lines) {
@@ -238,8 +247,9 @@ export class StatusMonitorService {
}
}
return Object.keys(stats).length > 0 ? stats : null;
} catch (error) {
console.error("[StatusMonitor] Error parsing /proc/net/dev:", error);
} catch (parseError) {
// 如果解析失败,记录错误并返回 null
console.error("[StatusMonitor] Error parsing /proc/net/dev output:", parseError);
return null;
}
}
@@ -253,9 +263,13 @@ export class StatusMonitorService {
try {
// 使用 ip route 命令查找默认路由对应的接口
const output = await this.executeSshCommand(sshClient, "ip route get 1.1.1.1 | grep -oP 'dev\\s+\\K\\S+'");
return output.trim() || null;
const interfaceName = output.trim();
if (interfaceName) return interfaceName;
// 如果 ip route 没返回有效接口名,也尝试 fallback
console.warn("[StatusMonitor] 'ip route' did not return a valid interface name. Falling back...");
} catch (error) {
console.warn("[StatusMonitor] Failed to get default interface using 'ip route':", error);
console.warn("[StatusMonitor] Failed to get default interface using 'ip route', falling back:", error);
// Fallback: 尝试查找第一个非 lo 接口
try {
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
@@ -269,8 +283,12 @@ export class StatusMonitorService {
} catch (fallbackError) {
console.error("[StatusMonitor] Failed to fallback to /proc/net/dev for interface:", fallbackError);
}
// Ensure null is returned if both primary and fallback fail within the outer catch
return null;
}
// This part should ideally not be reached if the first try succeeded or the catch block returned.
// Adding a final return null for safety and to satisfy TS if logic paths are complex.
return null;
}
/**
Binary file not shown.
+1
View File
@@ -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>
+6
View File
@@ -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": "未标记"
}
}
+3 -3
View File
@@ -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
},
// 新增:设置页面
{
+162 -55
View File
@@ -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
//
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
// 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); // connect isConnected
}
// / isConnected watch
});
// WebSocket /
watch(isConnected, (connected) => {
@@ -109,18 +113,63 @@ 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-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 的处理函数 -->
@@ -141,6 +190,21 @@ watch(isConnected, (connected) => {
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" />
</div>
</div>
<!-- 当没有 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>