Files
nexus-terminal/packages/frontend/src/views/SuspendedSshSessionsView.vue
T
2025-05-10 10:08:17 +08:00

320 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="suspended-ssh-sessions-view p-2 flex flex-col h-full" style="container-type: inline-size; container-name: suspended-sessions-view-pane;">
<div class="view-header mb-2">
<div class="relative w-full">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-text-secondary"></i>
</span>
<input
type="text"
v-model="searchTerm"
:placeholder="$t('suspendedSshSessions.searchPlaceholder')"
class="w-full pl-10 pr-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out"
@input="filterSessions"
/>
</div>
<!-- 可选显示挂起会话总数 -->
<!-- <div class="text-sm text-gray-500 mt-1">
当前挂起会话总数: {{ filteredSessions.length }} / {{ allSuspendedSshSessions.length }}
</div> -->
</div>
<div class="session-list-container flex-grow overflow-y-auto">
<div v-if="isLoading" class="text-center p-4">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
<p>{{ $t('suspendedSshSessions.loading') }}</p>
</div>
<div v-else-if="filteredSessions.length === 0 && !isLoading" class="text-center p-4">
<p>{{ $t('suspendedSshSessions.noResults') }}</p>
</div>
<ul v-else class="list-none p-0 m-0">
<li
v-for="session in filteredSessions"
:key="session.suspendSessionId"
class="session-item p-3 mb-2 border border-border/70 rounded-md bg-surface-ground"
:class="{ 'opacity-60': session.backendSshStatus === 'disconnected_by_backend' }"
>
<div class="flex justify-between items-center">
<div class="session-info flex-grow mr-2">
<div class="font-bold text-lg">
<span
v-if="editingSuspendSessionId !== session.suspendSessionId"
class="cursor-pointer hover:text-primary"
:title="$t('suspendedSshSessions.tooltip.editName')"
@click="startEditingName(session)"
>
{{ session.customSuspendName || session.connectionName }}
</span>
<input
v-else
ref="nameInputRef"
v-model="currentEditingNameValue"
type="text"
class="text-lg font-bold w-full px-1 py-0.5 border border-primary rounded-md bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
@blur="finishEditingName()"
@keydown.enter.prevent="finishEditingName()"
@keydown.esc.prevent="cancelEditingName()"
/>
</div>
<div class="text-sm text-muted-color">
{{ $t('suspendedSshSessions.label.originalConnection') }}: {{ session.connectionName }}
</div>
<div class="text-xs text-muted-color mt-1">
{{ $t('suspendedSshSessions.label.suspendedAt') }}: {{ formatDateTime(session.suspendStartTime) }}
</div>
<div
v-if="session.backendSshStatus === 'disconnected_by_backend' && session.disconnectionTimestamp"
class="text-xs text-orange-500 mt-1"
>
{{ $t('suspendedSshSessions.disconnectedAt', { time: formatDateTime(session.disconnectionTimestamp) }) }}
</div>
</div>
<div class="session-status-actions flex flex-col items-end space-y-2">
<span
:class="[
'px-2 py-1 text-xs font-semibold rounded-full',
session.backendSshStatus === 'hanging' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100'
]"
>
{{ session.backendSshStatus === 'hanging' ? $t('suspendedSshSessions.status.hanging') : $t('suspendedSshSessions.status.disconnected') }}
</span>
<div class="actions flex space-x-2">
<button
v-if="session.backendSshStatus === 'hanging'"
@click="resumeSession(session)"
:title="$t('suspendedSshSessions.action.resume')"
class="responsive-button-padding py-1.5 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-150 inline-flex items-center"
>
<i class="fas fa-play action-icon" style="color: white;"></i>
<span class="button-session-text">{{ $t('suspendedSshSessions.action.resume') }}</span>
</button>
<button
@click="removeSession(session)"
:title="$t('suspendedSshSessions.action.remove')"
class="responsive-button-padding py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-150 inline-flex items-center"
>
<i class="fas fa-trash-alt action-icon" style="color: white;"></i>
<span class="button-session-text">{{ $t('suspendedSshSessions.action.remove') }}</span>
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue'; // +++ 导入 nextTick 和 watch +++
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useSessionStore } from '../stores/session.store';
import type { SuspendedSshSession } from '../types/ssh-suspend.types';
const { t } = useI18n();
const sessionStore = useSessionStore();
const { suspendedSshSessions: storeSuspendedSshSessions, isLoadingSuspendedSessions: isLoading } = storeToRefs(sessionStore);
const searchTerm = ref('');
// +++ 组件级编辑状态 +++
const editingSuspendSessionId = ref<string | null>(null);
const currentEditingNameValue = ref<string>('');
const nameInputRef = ref<HTMLInputElement | null>(null);
// +++ 监听编辑ID变化以聚焦输入框 +++
watch(editingSuspendSessionId, async (newId) => {
if (newId !== null) {
await nextTick(); // 确保DOM已更新,输入框已渲染
if (nameInputRef.value && typeof nameInputRef.value.focus === 'function') {
nameInputRef.value.focus();
// nameInputRef.value.select(); // 可选:如果希望选中所有文本
} else {
console.warn('[SuspendedSshSessionsView] Watcher: nameInputRef.value is not a focusable input after nextTick.');
}
}
});
// filteredSessions 现在直接基于 storeSuspendedSshSessions
const filteredSessions = computed(() => {
if (!searchTerm.value.trim()) {
return storeSuspendedSshSessions.value;
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
return storeSuspendedSshSessions.value.filter((session: SuspendedSshSession) =>
(session.customSuspendName?.toLowerCase() || '').includes(lowerSearchTerm) ||
session.connectionName.toLowerCase().includes(lowerSearchTerm)
);
});
const filterSessions = () => {
// 计算属性会自动处理过滤
};
const formatDateTime = (isoString?: string) => {
if (!isoString) return t('time.unknown');
try {
return new Date(isoString).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
} catch (e) {
return t('time.invalidDate');
}
};
const startEditingName = (session: SuspendedSshSession) => { // async 不再需要,聚焦由 watcher 处理
editingSuspendSessionId.value = session.suspendSessionId;
currentEditingNameValue.value = session.customSuspendName || session.connectionName;
// 聚焦逻辑已移至 watcher
};
const finishEditingName = () => {
if (editingSuspendSessionId.value === null) return;
const sessionId = editingSuspendSessionId.value;
const newName = currentEditingNameValue.value.trim();
const originalSession = storeSuspendedSshSessions.value.find(s => s.suspendSessionId === sessionId);
if (!originalSession) {
editingSuspendSessionId.value = null; // 重置状态
return;
}
editingSuspendSessionId.value = null; // 退出编辑模式
if (newName && newName !== (originalSession.customSuspendName || originalSession.connectionName)) {
sessionStore.editSshSessionName(sessionId, newName);
}
// 如果名称未变或为空,则无需操作,因为 currentEditingNameValue 不会持久化
};
const cancelEditingName = () => {
editingSuspendSessionId.value = null;
// currentEditingNameValue 不需要显式重置,因为它会在下次 startEditingName 时被新值覆盖
};
const resumeSession = async (session: SuspendedSshSession) => { // 参数类型改为 SuspendedSshSession
console.log(`[SuspendedSshSessionsView] Attempting to resume session ID: ${session.suspendSessionId}, Name: ${session.customSuspendName || session.connectionName}`);
// 使用 JSON.parse(JSON.stringify()) 来记录会话对象的一个快照,避免在异步操作后因对象被修改而导致日志不准确
console.log('[SuspendedSshSessionsView] Session details snapshot:', JSON.parse(JSON.stringify(session)));
try {
// 假设 sessionStore.resumeSshSession 返回一个 Promise。
// 如果它不返回 Promise (例如,它是一个同步的 action dispatch)await 仍然是安全的,result 将会是 undefined。
// 为了获取详细信息(如是否真正恢复、历史日志),sessionStore.resumeSshSession 可能需要被修改以返回一个包含这些信息的对象。
const result = await sessionStore.resumeSshSession(session.suspendSessionId);
console.log('[SuspendedSshSessionsView] Call to sessionStore.resumeSshSession completed.');
// 检查 result 是否是包含期望信息的对象结构
// @ts-ignore (因为我们不确定 result 的确切类型,并且这是在 Vue 文件中)
if (result && typeof result === 'object' && ('isResumed' in result || 'historicalOutput' in result || 'message' in result)) {
console.log('[SuspendedSshSessionsView] Result from resumeSshSession:', result);
// @ts-ignore
console.log(`[SuspendedSshSessionsView] Is session truly resumed (based on backend response)? : ${result.isResumed ? 'Yes, existing session resumed.' : 'No, a new session was likely opened (or status unknown from response).'}`);
// @ts-ignore
console.log('[SuspendedSshSessionsView] Historical terminal log from backend:', result.historicalOutput || 'Not provided or empty.');
// @ts-ignore
if (result.message) {
// @ts-ignore
console.log('[SuspendedSshSessionsView] Backend message:', result.message);
}
} else {
console.log('[SuspendedSshSessionsView] sessionStore.resumeSshSession did not return the expected detailed information object (e.g., { isResumed: boolean, historicalOutput?: string, message?: string }). The action was dispatched.');
console.log('[SuspendedSshSessionsView] To get client-side confirmation of session state and historical logs, the sessionStore.resumeSshSession action might need to be updated to return this data.');
console.log('[SuspendedSshSessionsView] For now, please check browser developer console (network tab for backend responses) or backend logs for details on session restoration and historical log loading.');
if (result !== undefined) {
console.log('[SuspendedSshSessionsView] Actual value returned by resumeSshSession (if any):', result);
}
}
} catch (error) {
console.error(`[SuspendedSshSessionsView] Error during resumeSession for ${session.suspendSessionId}:`, error);
}
};
const removeSession = (session: SuspendedSshSession) => { // 参数类型改为 SuspendedSshSession
if (session.backendSshStatus === 'hanging') {
sessionStore.terminateAndRemoveSshSession(session.suspendSessionId);
} else if (session.backendSshStatus === 'disconnected_by_backend') {
sessionStore.removeSshSessionEntry(session.suspendSessionId);
}
};
onMounted(async () => {
await sessionStore.fetchSuspendedSshSessions();
});
</script>
<style scoped>
.suspended-ssh-sessions-view {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
.session-item {
transition: background-color 0.2s ease-in-out;
}
.session-item:hover {
background-color: var(--surface-hover); /* PrimeVue hover color */
}
/* 保持与 QuickCommandsView 类似的简洁风格 */
.p-inputtext-sm {
padding: 0.375rem 0.5rem; /* 调整输入框大小 */
font-size: 0.875rem;
}
.responsive-button-padding {
padding-left: 0.75rem; /* px-3 */
padding-right: 0.75rem; /* px-3 */
}
.action-icon {
margin-right: 0.375rem; /* mr-1.5 */
}
.button-session-text {
display: inline;
}
/* Apply styles when the container 'suspended-sessions-view-pane' is narrower than 480px */
@container suspended-sessions-view-pane (max-width: 320px) {
.button-session-text {
display: none;
}
.action-icon {
margin-right: 0;
}
.responsive-button-padding {
padding-left: 0.5rem; /* px-2 */
padding-right: 0.5rem; /* px-2 */
}
/* Adjust list item layout for narrow view */
.session-item > .flex { /* Targeting the main flex container inside session-item */
flex-direction: column;
align-items: stretch;
}
.session-item .session-info {
margin-right: 0;
margin-bottom: 0.5rem; /* mb-2 */
}
.session-item .session-status-actions {
margin-top: 0.5rem; /* mt-2 */
align-items: flex-start;
}
.session-item .session-status-actions .actions {
width: 100%;
justify-content: flex-start; /* Align buttons to the start */
}
}
</style>