This commit is contained in:
2026-06-18 22:56:41 +08:00
parent 3d831650b7
commit db3d030169
15 changed files with 1123 additions and 0 deletions
+3
View File
@@ -32,6 +32,9 @@
<RouterLink :to="{ name: 'admin-acp-agent-kinds' }" class="text-sm hover:underline">
ACP 客户端
</RouterLink>
<RouterLink :to="{ name: 'admin-terminal' }" class="text-sm hover:underline">
终端
</RouterLink>
</template>
</nav>
<span v-else class="flex-1" />
+125
View File
@@ -0,0 +1,125 @@
// useTerminalSocket 封装管理员终端的双向 WebSocket,结构照搬 useRunStream:
// - connect + 自动重连(指数退避 1s/3s,最多 2 次)
// - server→client:二进制帧 = shell 输出,回调喂给 xterm
// - client→server:二进制帧 = stdin 原始字节;文本帧 JSON = resize 控制
// 浏览器原生 WS 不支持自定义 header,token 经 ?token= 传递(同 run/acp WS 约定)。
import { ref, onScopeDispose, type Ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
export type TerminalStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export interface UseTerminalSocketOptions {
// onData 接收 shell 输出(已解码为字符串)。
onData: (text: string) => void
// 首次连接的初始窗口尺寸。
initialRows?: number
initialCols?: number
}
export interface UseTerminalSocketReturn {
status: Ref<TerminalStatus>
sendInput: (data: string) => void
sendResize: (rows: number, cols: number) => void
reconnect: () => void
close: () => void
}
function wsURL(rows: number, cols: number, token: string): string {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host
return (
`${proto}://${host}/api/v1/admin/terminal/stream` +
`?rows=${rows}&cols=${cols}&token=${encodeURIComponent(token)}`
)
}
export function useTerminalSocket(opts: UseTerminalSocketOptions): UseTerminalSocketReturn {
const status = ref<TerminalStatus>('idle')
const rows = ref(opts.initialRows ?? 24)
const cols = ref(opts.initialCols ?? 80)
let ws: WebSocket | null = null
let retried = 0
let manualClose = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const auth = useAuthStore()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
function connect() {
if (ws || manualClose) return
status.value = 'connecting'
const token = auth.token ?? ''
const sock = new WebSocket(wsURL(rows.value, cols.value, token))
sock.binaryType = 'arraybuffer'
ws = sock
sock.onopen = () => {
status.value = 'open'
retried = 0
}
sock.onmessage = (ev: MessageEvent) => {
if (ev.data instanceof ArrayBuffer) {
opts.onData(decoder.decode(new Uint8Array(ev.data)))
} else if (typeof ev.data === 'string') {
// 兼容性兜底:服务端目前只发二进制帧。
opts.onData(ev.data)
}
}
sock.onerror = () => {
status.value = 'error'
}
sock.onclose = () => {
ws = null
if (manualClose) {
status.value = 'closed'
return
}
if (retried < 2) {
const delay = retried === 0 ? 1000 : 3000
retried += 1
reconnectTimer = setTimeout(connect, delay)
} else {
status.value = 'closed'
}
}
}
function sendInput(data: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(encoder.encode(data))
}
function sendResize(r: number, c: number) {
rows.value = r
cols.value = c
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'resize', rows: r, cols: c }))
}
function reconnect() {
if (ws) ws.close()
retried = 0
manualClose = false
connect()
}
function close() {
manualClose = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) ws.close()
status.value = 'closed'
}
connect()
onScopeDispose(close)
return { status, sendInput, sendResize, reconnect, close }
}
+6
View File
@@ -175,6 +175,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/admin/AgentKindEditView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/terminal',
name: 'admin-terminal',
component: () => import('@/views/admin/AdminTerminalView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
// 兜底:匹配不到的路径(如空参数的 /p/x/w/、手输错误 URL)统一回首页,避免白屏
path: '/:pathMatch(.*)*',
+141
View File
@@ -0,0 +1,141 @@
<template>
<AppShell>
<div class="space-y-3">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
终端
</h2>
<div class="flex items-center gap-3">
<NTag
:type="statusType"
size="small"
round
>
{{ statusLabel }}
</NTag>
<NButton
size="small"
:disabled="sock.status.value === 'connecting' || sock.status.value === 'open'"
@click="sock.reconnect()"
>
重连
</NButton>
</div>
</div>
<NAlert
type="warning"
:show-icon="true"
title="管理员终端"
>
命令在 server 宿主进程部署时即 runtime 容器内执行权限等同容器内 shell
安装 ACP 客户端示例<code>npm i -g @anthropic-ai/claude-code</code>
安装完成后将 <code>binary_path</code> 填入对应的 ACP 客户端配置
</NAlert>
<div
ref="containerEl"
class="terminal-host"
/>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NAlert, NButton, NTag } from 'naive-ui'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import AppShell from '@/layouts/AppShell.vue'
import { useTerminalSocket } from '@/composables/useTerminalSocket'
const containerEl = ref<HTMLElement | null>(null)
const term = new Terminal({
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
fontSize: 13,
cursorBlink: true,
convertEol: false,
scrollback: 5000,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
// 在 setup 同步阶段建立连接(onScopeDispose 才能正确挂到组件 scope)。
// term.write 在 open() 前会被 xterm 内部缓冲,待挂载后渲染。
const sock = useTerminalSocket({
onData: (text) => term.write(text),
initialRows: term.rows,
initialCols: term.cols,
})
term.onData((d) => sock.sendInput(d))
let resizeObserver: ResizeObserver | null = null
function fitAndReport() {
if (!containerEl.value) return
try {
fitAddon.fit()
} catch {
return
}
sock.sendResize(term.rows, term.cols)
}
onMounted(() => {
if (!containerEl.value) return
term.open(containerEl.value)
fitAndReport()
resizeObserver = new ResizeObserver(() => fitAndReport())
resizeObserver.observe(containerEl.value)
term.focus()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
sock.close()
term.dispose()
})
const statusLabel = computed(() => {
switch (sock.status.value) {
case 'connecting':
return '连接中'
case 'open':
return '已连接'
case 'error':
return '错误'
case 'closed':
return '已断开'
default:
return '空闲'
}
})
const statusType = computed<'success' | 'warning' | 'error' | 'default'>(() => {
switch (sock.status.value) {
case 'open':
return 'success'
case 'connecting':
return 'warning'
case 'error':
return 'error'
default:
return 'default'
}
})
</script>
<style scoped>
.terminal-host {
width: 100%;
height: calc(100vh - 220px);
min-height: 320px;
background: #000;
padding: 8px;
border-radius: 6px;
overflow: hidden;
}
</style>