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>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
<!-- ── Main Content ── -->
|
<!-- ── Main Content ── -->
|
||||||
<v-main>
|
<v-main :class="{ 'nexus-terminal-main': route.path === '/terminal' }">
|
||||||
<router-view />
|
<router-view />
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
@@ -378,3 +378,20 @@ interface SearchResults {
|
|||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
</script>
|
</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-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-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-refresh" @click="browse">刷新</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-console"
|
||||||
|
:disabled="!selectedServer"
|
||||||
|
@click="openInTerminal"
|
||||||
|
>
|
||||||
|
终端
|
||||||
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -209,7 +218,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, defineAsyncComponent } from 'vue'
|
import { ref, computed, onMounted, defineAsyncComponent } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useServerList } from '@/composables/useServerList'
|
import { useServerList } from '@/composables/useServerList'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
@@ -226,6 +235,7 @@ const snackbar = useSnackbar()
|
|||||||
const { servers: serverList, loadServers } = useServerList()
|
const { servers: serverList, loadServers } = useServerList()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
const selectedServer = ref<number | null>(null)
|
const selectedServer = ref<number | null>(null)
|
||||||
@@ -291,6 +301,17 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Data loading ──
|
// ── Data loading ──
|
||||||
|
function openInTerminal() {
|
||||||
|
if (!selectedServer.value) return
|
||||||
|
router.push({
|
||||||
|
path: '/terminal',
|
||||||
|
query: {
|
||||||
|
server_id: String(selectedServer.value),
|
||||||
|
path: currentPath.value || '/',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function browse() {
|
async function browse() {
|
||||||
if (!selectedServer.value) return
|
if (!selectedServer.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 ── -->
|
<!-- ── Server Sidebar ── -->
|
||||||
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
|
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
|
||||||
<v-list-subheader>服务器列表</v-list-subheader>
|
<v-list-subheader>服务器列表</v-list-subheader>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
@click="switchTab(tab.id)"
|
@click="switchTab(tab.id)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<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>
|
</template>
|
||||||
<span class="text-truncate" style="max-width: 100px">{{ tab.serverName }}</span>
|
<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>
|
<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>
|
<span v-if="activeSession?.cwd" class="text-caption text-medium-emphasis font-mono" style="max-width: 200px">{{ activeSession.cwd }}</span>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="activeSession"
|
v-if="activeSession"
|
||||||
:color="activeSession.ws ? 'success' : 'grey'"
|
:color="sessionStatusColor(activeSession)"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
label
|
label
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon start size="8">mdi-circle</v-icon>
|
<v-icon start size="8">mdi-circle</v-icon>
|
||||||
</template>
|
</template>
|
||||||
{{ activeSession.ws ? '已连接' : '未连接' }}
|
{{ sessionStatusLabel(activeSession) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<span v-if="activeSession?.uptime" class="text-caption text-medium-emphasis">{{ activeSession.uptime }}</span>
|
<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>
|
<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 -->
|
<!-- Disconnect overlay -->
|
||||||
<v-overlay
|
<v-overlay
|
||||||
:model-value="showDisconnectOverlay"
|
:model-value="showDisconnectOverlay"
|
||||||
@@ -262,6 +275,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
|
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -269,12 +283,12 @@ const theme = useTheme()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
|
|
||||||
// ── Props from route ──
|
type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'
|
||||||
const initialServerId = computed(() => Number(route.query.server_id) || 0)
|
|
||||||
const initialPath = computed(() => {
|
function sanitizeRemotePath(raw: string): string {
|
||||||
const p = (route.query.path as string) || ''
|
const p = (raw || '').trim()
|
||||||
return p && /^[^\x00-\x1f\x7f"']+$/.test(p) ? p : ''
|
return p && /^[^\x00-\x1f\x7f"'`\\]+$/.test(p) ? p : ''
|
||||||
})
|
}
|
||||||
|
|
||||||
// ── localStorage keys ──
|
// ── localStorage keys ──
|
||||||
const K_FONT = 'nexus_term_fontSize'
|
const K_FONT = 'nexus_term_fontSize'
|
||||||
@@ -301,7 +315,9 @@ interface TermSession {
|
|||||||
uptime: string
|
uptime: string
|
||||||
pingMs: number | null
|
pingMs: number | null
|
||||||
initialCdSent: boolean
|
initialCdSent: boolean
|
||||||
|
pendingPath: string
|
||||||
manualDisconnect: boolean
|
manualDisconnect: boolean
|
||||||
|
status: SessionStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = ref<TermSession[]>([])
|
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))
|
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 ──
|
// ── Shell syntax highlighter ──
|
||||||
const SHELL_KEYWORDS = new Set([
|
const SHELL_KEYWORDS = new Set([
|
||||||
'sudo', 'cd', 'ls', 'grep', 'find', 'cat', 'chmod', 'chown', 'systemctl',
|
'sudo', 'cd', 'ls', 'grep', 'find', 'cat', 'chmod', 'chown', 'systemctl',
|
||||||
@@ -500,7 +562,7 @@ async function closeTab(sessionId: string) {
|
|||||||
await switchTab(sessions.value[newIdx].id)
|
await switchTab(sessions.value[newIdx].id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newSession(serverId?: number) {
|
async function newSession(serverId?: number, opts?: { path?: string }) {
|
||||||
if (sessions.value.length >= 10) {
|
if (sessions.value.length >= 10) {
|
||||||
snackbar('最多10个标签', 'warning')
|
snackbar('最多10个标签', 'warning')
|
||||||
return
|
return
|
||||||
@@ -542,9 +604,12 @@ async function newSession(serverId?: number) {
|
|||||||
uptime: '',
|
uptime: '',
|
||||||
pingMs: null,
|
pingMs: null,
|
||||||
initialCdSent: false,
|
initialCdSent: false,
|
||||||
|
pendingPath: sanitizeRemotePath(opts?.path || ''),
|
||||||
manualDisconnect: false,
|
manualDisconnect: false,
|
||||||
|
status: 'idle',
|
||||||
}
|
}
|
||||||
sessions.value.push(session)
|
sessions.value.push(session)
|
||||||
|
showDisconnectOverlay.value = false
|
||||||
|
|
||||||
await switchTab(sid)
|
await switchTab(sid)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -602,6 +667,13 @@ async function connectSession(sessionId: string) {
|
|||||||
const s = sessions.value.find(t => t.id === sessionId)
|
const s = sessions.value.find(t => t.id === sessionId)
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
|
if (s.ws) {
|
||||||
|
try { s.ws.close(1000) } catch { /* ignore */ }
|
||||||
|
s.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
s.status = 'connecting'
|
||||||
|
|
||||||
// 1. Get WebSSH token
|
// 1. Get WebSSH token
|
||||||
let websshToken: string
|
let websshToken: string
|
||||||
try {
|
try {
|
||||||
@@ -611,19 +683,24 @@ async function connectSession(sessionId: string) {
|
|||||||
websshToken = res.webssh_token
|
websshToken = res.webssh_token
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message || '无法获取终端令牌'
|
const msg = e?.message || '无法获取终端令牌'
|
||||||
|
s.status = 'error'
|
||||||
s.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`)
|
s.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`)
|
||||||
|
if (sessionId === activeSessionId.value) {
|
||||||
|
showDisconnectOverlay.value = true
|
||||||
|
disconnectMsg.value = msg
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open WebSocket
|
// 2. Open WebSocket
|
||||||
const apiBase = import.meta.env.VITE_API_BASE || `${location.protocol}//${location.host}`
|
const wsUrl = buildWebSocketUrl(`/ws/terminal/${s.serverId}`, {
|
||||||
const apiUrl = new URL(apiBase)
|
token: websshToken,
|
||||||
const wsProto = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
})
|
||||||
const wsUrl = `${wsProto}//${apiUrl.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`
|
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
s.ws = ws
|
s.ws = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
s.status = 'connected'
|
||||||
s.manualDisconnect = false
|
s.manualDisconnect = false
|
||||||
s.reconnectAttempt = 0
|
s.reconnectAttempt = 0
|
||||||
if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null }
|
if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null }
|
||||||
@@ -646,12 +723,8 @@ async function connectSession(sessionId: string) {
|
|||||||
if (dims && ws.readyState === WebSocket.OPEN) {
|
if (dims && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows }))
|
ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows }))
|
||||||
}
|
}
|
||||||
// Auto-cd to initial path
|
if (s.pendingPath && !s.initialCdSent && ws.readyState === WebSocket.OPEN) {
|
||||||
if (initialPath.value && !s.initialCdSent && ws.readyState === WebSocket.OPEN) {
|
setTimeout(() => sendCdToSession(s, s.pendingPath), 200)
|
||||||
s.initialCdSent = true
|
|
||||||
setTimeout(() => {
|
|
||||||
ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath.value}\r` }))
|
|
||||||
}, 200)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'DATA':
|
case 'DATA':
|
||||||
@@ -681,10 +754,16 @@ async function connectSession(sessionId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (ev.code === 4003) {
|
if (ev.code === 4003) {
|
||||||
|
s.status = 'error'
|
||||||
s.term?.write(`\r\n\x1b[31m⚠ 授权失败: ${ev.reason || 'SSH凭据未配置'}\x1b[0m\r\n`)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.status = 'error'
|
||||||
s.ws = null
|
s.ws = null
|
||||||
|
|
||||||
if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
|
if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
|
||||||
@@ -1031,7 +1110,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
if (s?.term) {
|
if (s?.term) {
|
||||||
s.term.clear()
|
s.term.clear()
|
||||||
if (s.ws?.readyState === WebSocket.OPEN) {
|
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
|
return
|
||||||
@@ -1043,17 +1122,16 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
|
watch(
|
||||||
|
() => [route.query.server_id, route.query.path, route.query.new_tab],
|
||||||
|
() => { void applyRouteQuery() },
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', onKeydown)
|
document.addEventListener('keydown', onKeydown)
|
||||||
await loadServers()
|
await loadServers()
|
||||||
await loadQuickCmds()
|
await loadQuickCmds()
|
||||||
|
await applyRouteQuery()
|
||||||
if (initialServerId.value) {
|
|
||||||
await newSession(initialServerId.value)
|
|
||||||
} else {
|
|
||||||
// No server_id — show server selector
|
|
||||||
showServerDialog.value = true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1073,9 +1151,20 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.cursor-pointer { cursor: pointer; }
|
||||||
.font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; }
|
.font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; }
|
||||||
:deep(.xterm) { padding: 4px; height: 100%; }
|
:deep(.xterm) { padding: 4px; height: 100%; }
|
||||||
|
:deep(.xterm-viewport) { overflow-y: auto !important; }
|
||||||
|
|
||||||
/* ── Shell syntax highlight colors ── */
|
/* ── Shell syntax highlight colors ── */
|
||||||
.sh-keyword { color: #60a5fa; font-weight: 500; }
|
.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