feat: terminal SPA layout, route sync, and WebSocket URL helper
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user