feat: terminal SPA layout, route sync, and WebSocket URL helper

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-01 15:41:59 +08:00
parent 8935081ac5
commit 799140f6c4
6 changed files with 251 additions and 31 deletions
@@ -0,0 +1,36 @@
# Changelog — 终端页 SPA 迭代
**日期**: 2026-06-01
## 摘要
迭代 `#/terminal`:全屏布局、路由 `server_id`/`path` 联动、统一 WebSocket URL、连接状态与空状态 UX;文件管理页增加「终端」入口。
## 动机
用户要求对 `app/#/terminal` 继续迭代;生产页存在终端区域高度不足、从服务器二次进入不建会话、连接态不清晰等问题。
## 涉及文件
- `frontend/src/App.vue``nexus-terminal-main` 布局
- `frontend/src/pages/TerminalPage.vue` — 核心迭代
- `frontend/src/utils/wsUrl.ts`(新)
- `frontend/src/pages/FilesPage.vue` — 带当前路径打开终端
- `docs/design/specs/2026-06-01-terminal-spa-iteration-design.md`(新)
## 验证
1. `cd frontend && npm run type-check`
2. 打开 `#/terminal?server_id=8` → 终端区占满、状态「已连接」、可输入
3. 已在终端时从服务器点「终端」→ 切换对应标签
4. 文件管理选服务器+路径 →「终端」→ 自动 `cd` 到当前目录
5. `npm run test:e2e` 中 terminal 用例
## 迁移 / 重启
- 仅前端:需 `vite build` + 上传 `web/app/`
- 后端无变更
## 回滚
还原上述前端文件并重新构建部署。
@@ -0,0 +1,34 @@
# 终端页 SPA 迭代设计
**日期**: 2026-06-01
**路由**: `/app/#/terminal``?server_id=&path=`
## 背景
Vue `TerminalPage` 已具备多标签 WebSSH、重连、快捷命令等能力,但存在:
1. `v-main` 未限定高度,终端区域在部分视口下高度为 0 或过小
2. 从服务器页带 `server_id` 跳转时,若已在终端路由则不会新建/切换会话(无 `watch`
3. WebSocket URL 与 `useWebSocket` 构建方式不一致,`VITE_API_BASE=/api` 时 dev 可能异常
4. 无「连接中」态,失败时仅禁用发送按钮
5. `path` 查询参数仅全局生效,多标签会重复 cd
6. Ctrl+L 向 shell 发送 `clear\r` 非标准清屏
## 方案
| 项 | 做法 |
|----|------|
| 布局 | `App.vue``/terminal` 路由给 `v-main` 增加 `nexus-terminal-main``100dvh - app-bar` + flex |
| WS URL | 新增 `utils/wsUrl.ts`,与告警 WS 同源规则 |
| 路由 | `watch(route.query)``server_id` / `path` / `new_tab` |
| 会话 | `TermSession.pendingPath``status`connecting/connected/error |
| 空状态 | 无会话时内嵌服务器列表 +「选择服务器」 |
| 清屏 | Ctrl+L 仅 `term.clear()` + 可选 `\x0c` |
## 验收
- `#/terminal?server_id=8` 首屏终端区域占满内容区且可输入
- 已在 `#/terminal` 时从服务器点「终端」能切换/新建会话
- `#/terminal?server_id=8&path=/var/log` 首包后自动 cd
- 断线显示重连 overlay;手动断开不自动重连
- 生产 `wss://host/ws/terminal/{id}?token=…` 可连
+18 -1
View File
@@ -219,7 +219,7 @@
</v-navigation-drawer>
<!-- Main Content -->
<v-main>
<v-main :class="{ 'nexus-terminal-main': route.path === '/terminal' }">
<router-view />
</v-main>
@@ -378,3 +378,20 @@ interface SearchResults {
query: string
}
</script>
<style>
/* 终端页占满主内容区,避免 xterm 高度为 0 */
.v-application .v-main.nexus-terminal-main {
display: flex;
flex-direction: column;
height: calc(100dvh - 64px);
min-height: 0;
overflow: hidden;
padding: 0;
}
.v-application .v-main.nexus-terminal-main > .v-container {
flex: 1 1 auto;
min-height: 0;
max-width: 100%;
}
</style>
+22 -1
View File
@@ -32,6 +32,15 @@
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="showUpload = true">上传</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="showMkdir = true">新建目录</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="browse">刷新</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-console"
:disabled="!selectedServer"
@click="openInTerminal"
>
终端
</v-btn>
</v-col>
</v-row>
</v-card>
@@ -209,7 +218,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
@@ -226,6 +235,7 @@ const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
const route = useRoute()
const router = useRouter()
// ── State ──
const selectedServer = ref<number | null>(null)
@@ -291,6 +301,17 @@ const breadcrumbs = computed(() => {
})
// ── Data loading ──
function openInTerminal() {
if (!selectedServer.value) return
router.push({
path: '/terminal',
query: {
server_id: String(selectedServer.value),
path: currentPath.value || '/',
},
})
}
async function browse() {
if (!selectedServer.value) return
loading.value = true
+118 -29
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="pa-0 d-flex flex-column" style="background: #0b1120; height: 100%">
<v-container fluid class="pa-0 d-flex flex-column terminal-root">
<!-- Server Sidebar -->
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
<v-list-subheader>服务器列表</v-list-subheader>
@@ -69,7 +69,7 @@
@click="switchTab(tab.id)"
>
<template #prepend>
<v-icon start size="8" :color="tab.ws ? 'success' : 'grey'">mdi-circle</v-icon>
<v-icon start size="8" :color="tab.status === 'connected' ? 'success' : tab.status === 'connecting' ? 'warning' : 'grey'">mdi-circle</v-icon>
</template>
<span class="text-truncate" style="max-width: 100px">{{ tab.serverName }}</span>
<v-icon end size="12" class="ml-1" @click.stop="closeTab(tab.id)">mdi-close</v-icon>
@@ -87,7 +87,7 @@
<span v-if="activeSession?.cwd" class="text-caption text-medium-emphasis font-mono" style="max-width: 200px">{{ activeSession.cwd }}</span>
<v-chip
v-if="activeSession"
:color="activeSession.ws ? 'success' : 'grey'"
:color="sessionStatusColor(activeSession)"
variant="tonal"
size="x-small"
label
@@ -95,7 +95,7 @@
<template #prepend>
<v-icon start size="8">mdi-circle</v-icon>
</template>
{{ activeSession.ws ? '已连接' : '未连接' }}
{{ sessionStatusLabel(activeSession) }}
</v-chip>
<span v-if="activeSession?.uptime" class="text-caption text-medium-emphasis">{{ activeSession.uptime }}</span>
<span v-if="activeSession?.pingMs !== null && activeSession?.pingMs !== undefined" class="text-caption text-medium-emphasis">{{ activeSession.pingMs }}ms</span>
@@ -137,6 +137,19 @@
}"
/>
<!-- Empty: no sessions -->
<div
v-if="sessions.length === 0 && !showDisconnectOverlay"
class="d-flex flex-column align-center justify-center fill-height pa-8 text-center"
>
<v-icon size="56" color="primary" class="mb-4">mdi-console-network</v-icon>
<div class="text-h6 mb-2">WebSSH 终端</div>
<div class="text-body-2 text-medium-emphasis mb-6">选择一台服务器开始会话或从服务器列表点击终端进入</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-server" @click="showServerDialog = true">
选择服务器
</v-btn>
</div>
<!-- Disconnect overlay -->
<v-overlay
:model-value="showDisconnectOverlay"
@@ -262,6 +275,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { useSnackbar } from '@/composables/useSnackbar'
import { buildWebSocketUrl } from '@/utils/wsUrl'
const route = useRoute()
const router = useRouter()
@@ -269,12 +283,12 @@ const theme = useTheme()
const auth = useAuthStore()
const snackbar = useSnackbar()
// ── Props from route ──
const initialServerId = computed(() => Number(route.query.server_id) || 0)
const initialPath = computed(() => {
const p = (route.query.path as string) || ''
return p && /^[^\x00-\x1f\x7f"']+$/.test(p) ? p : ''
})
type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'
function sanitizeRemotePath(raw: string): string {
const p = (raw || '').trim()
return p && /^[^\x00-\x1f\x7f"'`\\]+$/.test(p) ? p : ''
}
// ── localStorage keys ──
const K_FONT = 'nexus_term_fontSize'
@@ -301,7 +315,9 @@ interface TermSession {
uptime: string
pingMs: number | null
initialCdSent: boolean
pendingPath: string
manualDisconnect: boolean
status: SessionStatus
}
const sessions = ref<TermSession[]>([])
@@ -358,6 +374,52 @@ const filteredServers = computed(() => {
return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q))
})
function sessionStatusLabel(s: TermSession): string {
switch (s.status) {
case 'connecting': return '连接中…'
case 'connected': return '已连接'
case 'error': return '连接失败'
default: return '未连接'
}
}
function sessionStatusColor(s: TermSession): string {
switch (s.status) {
case 'connecting': return 'warning'
case 'connected': return 'success'
case 'error': return 'error'
default: return 'grey'
}
}
function sendCdToSession(s: TermSession, path: string) {
const dir = sanitizeRemotePath(path)
if (!dir || !s.ws || s.ws.readyState !== WebSocket.OPEN) return
s.ws.send(JSON.stringify({ type: 'DATA', data: `cd ${dir}\r` }))
s.initialCdSent = true
}
async function applyRouteQuery() {
const id = Number(route.query.server_id) || 0
const path = sanitizeRemotePath(String(route.query.path || ''))
const forceNew = route.query.new_tab === '1' || route.query.new_tab === 'true'
if (!id) {
if (sessions.value.length === 0) showServerDialog.value = true
return
}
if (!forceNew) {
const existing = sessions.value.find(s => s.serverId === id)
if (existing) {
await switchTab(existing.id)
if (path) sendCdToSession(existing, path)
return
}
}
await newSession(id, { path })
}
// ── Shell syntax highlighter ──
const SHELL_KEYWORDS = new Set([
'sudo', 'cd', 'ls', 'grep', 'find', 'cat', 'chmod', 'chown', 'systemctl',
@@ -500,7 +562,7 @@ async function closeTab(sessionId: string) {
await switchTab(sessions.value[newIdx].id)
}
async function newSession(serverId?: number) {
async function newSession(serverId?: number, opts?: { path?: string }) {
if (sessions.value.length >= 10) {
snackbar('最多10个标签', 'warning')
return
@@ -542,9 +604,12 @@ async function newSession(serverId?: number) {
uptime: '',
pingMs: null,
initialCdSent: false,
pendingPath: sanitizeRemotePath(opts?.path || ''),
manualDisconnect: false,
status: 'idle',
}
sessions.value.push(session)
showDisconnectOverlay.value = false
await switchTab(sid)
await nextTick()
@@ -602,6 +667,13 @@ async function connectSession(sessionId: string) {
const s = sessions.value.find(t => t.id === sessionId)
if (!s) return
if (s.ws) {
try { s.ws.close(1000) } catch { /* ignore */ }
s.ws = null
}
s.status = 'connecting'
// 1. Get WebSSH token
let websshToken: string
try {
@@ -611,19 +683,24 @@ async function connectSession(sessionId: string) {
websshToken = res.webssh_token
} catch (e: any) {
const msg = e?.message || '无法获取终端令牌'
s.status = 'error'
s.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`)
if (sessionId === activeSessionId.value) {
showDisconnectOverlay.value = true
disconnectMsg.value = msg
}
return
}
// 2. Open WebSocket
const apiBase = import.meta.env.VITE_API_BASE || `${location.protocol}//${location.host}`
const apiUrl = new URL(apiBase)
const wsProto = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${apiUrl.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`
const wsUrl = buildWebSocketUrl(`/ws/terminal/${s.serverId}`, {
token: websshToken,
})
const ws = new WebSocket(wsUrl)
s.ws = ws
ws.onopen = () => {
s.status = 'connected'
s.manualDisconnect = false
s.reconnectAttempt = 0
if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null }
@@ -646,12 +723,8 @@ async function connectSession(sessionId: string) {
if (dims && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows }))
}
// Auto-cd to initial path
if (initialPath.value && !s.initialCdSent && ws.readyState === WebSocket.OPEN) {
s.initialCdSent = true
setTimeout(() => {
ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath.value}\r` }))
}, 200)
if (s.pendingPath && !s.initialCdSent && ws.readyState === WebSocket.OPEN) {
setTimeout(() => sendCdToSession(s, s.pendingPath), 200)
}
break
case 'DATA':
@@ -681,10 +754,16 @@ async function connectSession(sessionId: string) {
return
}
if (ev.code === 4003) {
s.status = 'error'
s.term?.write(`\r\n\x1b[31m⚠ 授权失败: ${ev.reason || 'SSH凭据未配置'}\x1b[0m\r\n`)
if (sessionId === activeSessionId.value) {
showDisconnectOverlay.value = true
disconnectMsg.value = ev.reason || 'SSH 凭据未配置'
}
return
}
s.status = 'error'
s.ws = null
if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
@@ -1031,7 +1110,7 @@ function onKeydown(e: KeyboardEvent) {
if (s?.term) {
s.term.clear()
if (s.ws?.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'DATA', data: 'clear\r' }))
s.ws.send(JSON.stringify({ type: 'DATA', data: '\x0c' }))
}
}
return
@@ -1043,17 +1122,16 @@ function onKeydown(e: KeyboardEvent) {
}
// ── Lifecycle ──
watch(
() => [route.query.server_id, route.query.path, route.query.new_tab],
() => { void applyRouteQuery() },
)
onMounted(async () => {
document.addEventListener('keydown', onKeydown)
await loadServers()
await loadQuickCmds()
if (initialServerId.value) {
await newSession(initialServerId.value)
} else {
// No server_id — show server selector
showServerDialog.value = true
}
await applyRouteQuery()
})
onBeforeUnmount(() => {
@@ -1073,9 +1151,20 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.terminal-root {
background: #0b1120;
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.fill-height {
flex: 1 1 auto;
min-height: 200px;
}
.cursor-pointer { cursor: pointer; }
.font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; }
:deep(.xterm) { padding: 4px; height: 100%; }
:deep(.xterm-viewport) { overflow-y: auto !important; }
/* ── Shell syntax highlight colors ── */
.sh-keyword { color: #60a5fa; font-weight: 500; }
+23
View File
@@ -0,0 +1,23 @@
/**
* Build WebSocket URLs consistent with HTTP API base (VITE_API_BASE or same-origin).
*/
export function buildWebSocketUrl(path: string, query?: Record<string, string>): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const base = (import.meta.env.VITE_API_BASE as string | undefined) || ''
let origin: string
if (base.startsWith('http://') || base.startsWith('https://')) {
origin = base.replace(/^http/, 'ws').replace(/\/api\/?$/, '')
} else {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
origin = `${proto}//${window.location.host}`
}
const url = new URL(normalizedPath, `${origin}/`)
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
}
return url.toString()
}