初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
@@ -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>