初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { NSpin, useMessage } from 'naive-ui'
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
instanceName?: string
|
||||
rootPassword?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||||
|
||||
const message = useMessage()
|
||||
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()
|
||||
|
||||
async function open() {
|
||||
phase.value = 'creating'
|
||||
error.value = ''
|
||||
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
||||
try {
|
||||
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
||||
sessionId = res.sessionId
|
||||
} catch (e) {
|
||||
phase.value = 'closed'
|
||||
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
||||
return
|
||||
}
|
||||
phase.value = 'connecting'
|
||||
await nextTick()
|
||||
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()
|
||||
}
|
||||
|
||||
async function copyRootPassword() {
|
||||
await navigator.clipboard.writeText(props.rootPassword ?? '')
|
||||
message.success('已复制 root 密码')
|
||||
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() {
|
||||
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 v-if="rootPassword" class="ser-btn" @click="copyRootPassword">复制 root 密码</button>
|
||||
<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>
|
||||
Reference in New Issue
Block a user