340 lines
10 KiB
Vue
340 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { FitAddon } from '@xterm/addon-fit'
|
||
import { Terminal } from '@xterm/xterm'
|
||
import '@xterm/xterm/css/xterm.css'
|
||
import { NSpin } from 'naive-ui'
|
||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
|
||
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const props = defineProps<{
|
||
show: boolean
|
||
cfgId: number
|
||
instanceId: string
|
||
region?: string
|
||
instanceName?: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||
|
||
const message = useToast()
|
||
const auth = useAuthStore()
|
||
|
||
const termEl = ref<HTMLDivElement | null>(null)
|
||
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
|
||
const error = ref('')
|
||
const minimized = ref(false)
|
||
const fullscreen = ref(false)
|
||
const height = ref(360)
|
||
|
||
let term: Terminal | null = null
|
||
let fit: FitAddon | null = null
|
||
let ws: WebSocket | null = null
|
||
let ro: ResizeObserver | null = null
|
||
let sessionId = ''
|
||
const encoder = new TextEncoder()
|
||
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
|
||
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
|
||
// B 实际连着 A,用户输入发往错误实例
|
||
let openSeq = 0
|
||
|
||
async function open() {
|
||
const seq = ++openSeq
|
||
phase.value = 'creating'
|
||
error.value = ''
|
||
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
||
let created = ''
|
||
try {
|
||
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
||
created = res.sessionId
|
||
} catch (e) {
|
||
if (seq !== openSeq) return
|
||
phase.value = 'closed'
|
||
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
||
return
|
||
}
|
||
if (seq !== openSeq) {
|
||
void deleteConsoleSession(created).catch(() => undefined)
|
||
return
|
||
}
|
||
sessionId = created
|
||
phase.value = 'connecting'
|
||
await nextTick()
|
||
if (seq !== openSeq) return
|
||
mountTerm()
|
||
connectWs()
|
||
}
|
||
|
||
/** 深色终端主题固定不随明暗切换(对齐 Cloud Shell 观感) */
|
||
function newTerminal(): Terminal {
|
||
return new Terminal({
|
||
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
||
fontSize: 13,
|
||
lineHeight: 1.2,
|
||
cursorBlink: true,
|
||
scrollback: 5000,
|
||
theme: {
|
||
background: '#1c1b1a',
|
||
foreground: '#eceae4',
|
||
cursor: '#d97757',
|
||
selectionBackground: 'rgba(217, 119, 87, 0.35)',
|
||
},
|
||
})
|
||
}
|
||
|
||
function mountTerm() {
|
||
if (term || !termEl.value) return
|
||
term = newTerminal()
|
||
fit = new FitAddon()
|
||
term.loadAddon(fit)
|
||
term.open(termEl.value)
|
||
fit.fit()
|
||
term.onData((d) => wsSendText(d))
|
||
term.onResize(({ cols, rows }) => wsSendCtl({ type: 'resize', cols, rows }))
|
||
// 选中即复制:终端里 Ctrl+C 保留 SIGINT 语义,复制交给选区
|
||
term.onSelectionChange(() => {
|
||
const sel = term?.getSelection()
|
||
if (sel) void navigator.clipboard.writeText(sel).catch(() => undefined)
|
||
})
|
||
term.attachCustomKeyEventHandler(copyKeyHandler)
|
||
ro = new ResizeObserver(() => fit?.fit())
|
||
ro.observe(termEl.value)
|
||
}
|
||
|
||
/** Cmd+C / Ctrl+Shift+C 在有选区时作复制,其余按键交还终端 */
|
||
function copyKeyHandler(ev: KeyboardEvent): boolean {
|
||
if (ev.type !== 'keydown') return true
|
||
const combo = (ev.metaKey && !ev.ctrlKey) || (ev.ctrlKey && ev.shiftKey)
|
||
if (combo && ev.key.toLowerCase() === 'c' && term?.hasSelection()) {
|
||
void navigator.clipboard.writeText(term.getSelection()).catch(() => undefined)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function connectWs() {
|
||
if (!term) return
|
||
const url = consoleSessionWsUrl(sessionId, auth.token, `rows=${term.rows}&cols=${term.cols}`)
|
||
ws = new WebSocket(url)
|
||
ws.binaryType = 'arraybuffer'
|
||
ws.onopen = () => {
|
||
phase.value = 'connected'
|
||
term?.focus()
|
||
}
|
||
ws.onmessage = (ev) => {
|
||
if (ev.data instanceof ArrayBuffer) term?.write(new Uint8Array(ev.data))
|
||
}
|
||
ws.onclose = (ev) => {
|
||
if (phase.value === 'idle' || phase.value === 'closed') return
|
||
phase.value = 'closed'
|
||
if (!error.value) error.value = mapCloseReason(ev.reason)
|
||
}
|
||
}
|
||
|
||
function mapCloseReason(reason: string): string {
|
||
if (!reason) return '连接已断开'
|
||
if (reason.includes('administratively prohibited'))
|
||
return '串行控制台已被占用:同一时刻仅允许一路连接(可能有本地 ssh 或其他会话)'
|
||
return reason
|
||
}
|
||
|
||
function wsSendText(data: string) {
|
||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data))
|
||
}
|
||
|
||
function wsSendCtl(ctl: { type: string; cols: number; rows: number }) {
|
||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(ctl))
|
||
}
|
||
|
||
async function pasteFromClipboard() {
|
||
try {
|
||
const text = await navigator.clipboard.readText()
|
||
if (text) term?.paste(text)
|
||
} catch {
|
||
message.warning('无法读取剪贴板,请直接按 Cmd/Ctrl+V 粘贴')
|
||
}
|
||
term?.focus()
|
||
}
|
||
|
||
/** 串口 tty 收不到 SIGWINCH,向 shell 注入 stty 同步行列数(需处于提示符) */
|
||
function syncWinsize() {
|
||
if (!term) return
|
||
wsSendText(`stty rows ${term.rows} cols ${term.cols}\r`)
|
||
message.info('已发送 stty 同步命令(需处于 shell 提示符)')
|
||
term.focus()
|
||
}
|
||
|
||
function reconnect() {
|
||
teardown()
|
||
void open()
|
||
}
|
||
|
||
function toggleFullscreen() {
|
||
fullscreen.value = !fullscreen.value
|
||
minimized.value = false
|
||
void nextTick(() => fit?.fit())
|
||
}
|
||
|
||
function toggleMinimize() {
|
||
minimized.value = !minimized.value
|
||
if (!minimized.value) void nextTick(() => fit?.fit())
|
||
}
|
||
|
||
function startDrag(e: MouseEvent) {
|
||
const startY = e.clientY
|
||
const startH = height.value
|
||
const move = (ev: MouseEvent) => {
|
||
height.value = Math.min(Math.max(startH + (startY - ev.clientY), 200), window.innerHeight - 72)
|
||
}
|
||
const up = () => {
|
||
window.removeEventListener('mousemove', move)
|
||
window.removeEventListener('mouseup', up)
|
||
}
|
||
window.addEventListener('mousemove', move)
|
||
window.addEventListener('mouseup', up)
|
||
e.preventDefault()
|
||
}
|
||
|
||
function teardown() {
|
||
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
|
||
ro?.disconnect()
|
||
ro = null
|
||
if (ws) {
|
||
ws.onclose = null
|
||
ws.close()
|
||
ws = null
|
||
}
|
||
if (sessionId) {
|
||
void deleteConsoleSession(sessionId).catch(() => undefined)
|
||
sessionId = ''
|
||
}
|
||
term?.dispose()
|
||
term = null
|
||
fit = null
|
||
phase.value = 'idle'
|
||
error.value = ''
|
||
}
|
||
|
||
function close() {
|
||
teardown()
|
||
minimized.value = false
|
||
fullscreen.value = false
|
||
emit('update:show', false)
|
||
}
|
||
|
||
watch(
|
||
() => props.show,
|
||
(v) => {
|
||
if (v) void open()
|
||
else teardown()
|
||
},
|
||
)
|
||
|
||
onBeforeUnmount(teardown)
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="show"
|
||
class="fixed inset-x-0 bottom-0 z-[1500] flex flex-col border-t border-[#3a3835] shadow-[0_-6px_24px_rgba(0,0,0,0.35)]"
|
||
:class="fullscreen && !minimized ? 'top-0' : ''"
|
||
>
|
||
<div
|
||
v-if="!fullscreen && !minimized"
|
||
class="h-[3px] shrink-0 cursor-ns-resize bg-[#141413] transition-colors hover:bg-[#d97757]"
|
||
@mousedown="startDrag"
|
||
/>
|
||
<!-- 标题栏(Cloud Shell 风格) -->
|
||
<div class="flex h-9 shrink-0 items-center gap-2 bg-[#141413] px-3 text-[12.5px] text-[#a8a69e]">
|
||
<span
|
||
class="inline-block h-2 w-2 rounded-full"
|
||
:style="{ background: phase === 'connected' ? '#8fa574' : phase === 'closed' ? '#d06060' : '#c29135' }"
|
||
/>
|
||
<span class="truncate font-medium text-[#eceae4]">
|
||
{{ instanceName || instanceId }} · 串行控制台
|
||
</span>
|
||
<span v-if="phase === 'creating'" class="text-xs">准备连接中…</span>
|
||
<div class="flex-1" />
|
||
<button class="ser-btn" :disabled="phase !== 'connected'" @click="pasteFromClipboard">
|
||
粘贴
|
||
</button>
|
||
<button
|
||
class="ser-btn"
|
||
:disabled="phase !== 'connected'"
|
||
title="向 shell 发送 stty 命令同步终端行列数"
|
||
@click="syncWinsize"
|
||
>
|
||
同步窗口
|
||
</button>
|
||
<button v-if="phase === 'closed'" class="ser-btn text-[#d97757]" @click="reconnect">
|
||
重连
|
||
</button>
|
||
<div class="mx-1 h-4 w-px bg-[#3a3835]" />
|
||
<button class="ser-btn" :title="minimized ? '还原' : '最小化'" @click="toggleMinimize">
|
||
{{ minimized ? '◱' : '─' }}
|
||
</button>
|
||
<button class="ser-btn" :title="fullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
|
||
⛶
|
||
</button>
|
||
<button class="ser-btn" title="关闭并结束会话" @click="close">✕</button>
|
||
</div>
|
||
<!-- 终端区 -->
|
||
<div
|
||
v-show="!minimized"
|
||
class="relative min-h-0 bg-[#1c1b1a]"
|
||
:class="fullscreen ? 'flex-1' : ''"
|
||
:style="fullscreen ? {} : { height: height + 'px' }"
|
||
>
|
||
<div ref="termEl" class="absolute inset-0 py-1 pl-2" />
|
||
<div
|
||
v-if="phase !== 'connected'"
|
||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#1c1b1a]/92 text-[13px] text-[#a8a69e]"
|
||
>
|
||
<template v-if="phase === 'creating' || phase === 'connecting'">
|
||
<NSpin size="small" />
|
||
<div>
|
||
{{
|
||
phase === 'creating'
|
||
? '正在清理旧连接并创建控制台连接(约 30-90 秒)…'
|
||
: '正在建立串行隧道…'
|
||
}}
|
||
</div>
|
||
</template>
|
||
<template v-else-if="phase === 'closed'">
|
||
<div class="max-w-[80%] break-all text-center">{{ error || '连接已关闭' }}</div>
|
||
<div class="max-w-[80%] text-center text-xs leading-relaxed">
|
||
串口登录需实例本地账户密码(SSH 公钥无效);随机 root 密码可在实例详情
|
||
验证身份后查看。无密码也可查看启动日志或经 GRUB 单用户模式救援。
|
||
</div>
|
||
<button class="ser-btn !border !border-[#3a3835] !px-3 !py-1" @click="reconnect">
|
||
重新连接
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ser-btn {
|
||
border-radius: 4px;
|
||
padding: 2px 7px;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
color: inherit;
|
||
transition: background-color 0.15s;
|
||
}
|
||
.ser-btn:hover:not(:disabled) {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: #eceae4;
|
||
}
|
||
.ser-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
</style>
|