Files
oci-portal-dash/src/components/instance/SerialConsolePanel.vue
T
2026-07-22 16:51:45 +08:00

340 lines
10 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.
<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>