Files
Nexus/frontend/src/composables/terminal/useTerminalSessions.ts
T
Nexus Agent f6e8b29640 fix(ui): 服务器/终端/推送搜索历史互不共享
终端独立 terminal_search 上下文(10条),与 servers_search、push_search 隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 02:03:45 +08:00

826 lines
22 KiB
TypeScript

import { ref, computed, watch, nextTick, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import {
createNative,
deleteNative,
getNative,
setNativeFitAddon,
setNativeResizeObserver,
setNativeTerm,
isSessionWsOpen,
forEachNative,
} from './termNativeStore'
import {
isSessionReady,
sanitizeRemotePath,
type TermSessionData,
type TerminalServerItem,
} from './types'
import { useTerminalSettings } from './useTerminalSettings'
import { useTerminalCmdBar } from './useTerminalCmdBar'
import { XTERM_CLASSIC_THEME } from './xtermTheme'
import { decodeTerminalPayload } from '@/utils/terminalPayload'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { ServerApiItem } from '@/types/api'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
const MAX_TABS = 10
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbar()
const settings = useTerminalSettings()
const sessions = ref<TermSessionData[]>([])
const activeSessionId = ref('')
const termContainer = ref<HTMLElement | null>(null)
const termRefs = new Map<string, HTMLElement>()
const showServerDialog = ref(false)
const servers = ref<TerminalServerItem[]>([])
const serversLoading = ref(false)
const serverSearch = ref('')
const quickCmds = ref<QuickCmdItem[]>([])
const { items: terminalSearchHistory, loadHistoryOnly, record: recordTerminalSearch } =
useServerSearchHistory('terminal')
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
)
const activeSessionConnected = computed(() => {
const s = activeSession.value
return s ? isSessionReady(s) : false
})
const cmdBarDisabled = computed(() => !activeSession.value)
function parseRouteServerId(): number {
const raw = route.query.server_id ?? route.query.server
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : 0
}
async function waitForTermContainer(sessionId: string, attempts = 24): Promise<HTMLElement | null> {
for (let i = 0; i < attempts; i += 1) {
const el = termRefs.get(sessionId)
if (el) return el
await nextTick()
}
return termRefs.get(sessionId) ?? null
}
const filteredServers = computed(() => {
const q = serverSearch.value.toLowerCase().trim()
if (!q) return servers.value
return servers.value.filter(
(s) => s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q),
)
})
function toTerminalServerItem(row: Pick<ServerApiItem, 'id' | 'name' | 'domain' | 'is_online'>): TerminalServerItem {
return {
id: row.id,
name: row.name,
domain: row.domain,
is_online: row.is_online,
}
}
async function resolveServerMeta(serverId: number): Promise<TerminalServerItem | null> {
const cached = servers.value.find((s) => s.id === serverId)
if (cached) return cached
try {
const row = await http.get<ServerApiItem>(`/servers/${serverId}`)
const item = toTerminalServerItem(row)
if (!servers.value.some((s) => s.id === item.id)) {
servers.value = [...servers.value, item]
}
return item
} catch {
return null
}
}
const showEmptyPicker = computed(
() => sessions.value.length === 0 && !showServerDialog.value,
)
function getSession(sessionId: string): TermSessionData | undefined {
return sessions.value.find((t) => t.id === sessionId)
}
function sendWsData(sessionId: string, data: string): boolean {
const ws = getNative(sessionId)?.ws
if (!ws || ws.readyState !== WebSocket.OPEN) return false
ws.send(JSON.stringify({ type: 'DATA', data }))
return true
}
const cmdBar = useTerminalCmdBar(
() => activeSession.value,
sendWsData,
() => snackbar('终端连接中,请稍候', 'warning'),
)
function refitAllTerminals() {
forEachNative((_id, native) => {
try {
native.fitAddon?.fit()
} catch {
/* container size 0 */
}
})
}
watch(nexusDrawer, () => {
nextTick(() => {
refitAllTerminals()
setTimeout(refitAllTerminals, 220)
})
})
function setTermRef(el: unknown, sessionId: string) {
if (el) termRefs.set(sessionId, el as HTMLElement)
}
function createTerminal(): Terminal {
return new Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: settings.fontSize.value,
scrollback: settings.scrollback.value,
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
theme: XTERM_CLASSIC_THEME,
allowProposedApi: true,
drawBoldTextInBrightColors: true,
})
}
function sendCdToSession(s: TermSessionData, path: string) {
const dir = sanitizeRemotePath(path)
if (!dir || !isSessionReady(s)) return
sendWsData(s.id, `cd ${dir}\r`)
s.initialCdSent = true
}
function stopUptimeCounter(s: TermSessionData) {
const native = getNative(s.id)
if (native?.uptimeTimer) {
clearInterval(native.uptimeTimer)
native.uptimeTimer = null
}
s.uptime = ''
}
function updateUptime(s: TermSessionData) {
if (!s.connectionStartTime) {
s.uptime = ''
return
}
const sec = Math.floor((Date.now() - s.connectionStartTime) / 1000)
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const ss = sec % 60
s.uptime = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
}
function startUptimeCounter(s: TermSessionData) {
s.connectionStartTime = Date.now()
stopUptimeCounter(s)
updateUptime(s)
const native = getNative(s.id)
if (!native) return
native.uptimeTimer = setInterval(() => updateUptime(s), 1000)
}
function stopPing(s: TermSessionData) {
const native = getNative(s.id)
if (native?.pingTimer) {
clearInterval(native.pingTimer)
native.pingTimer = null
}
s.pingMs = null
}
function startPing(s: TermSessionData) {
stopPing(s)
s.pingMs = null
const native = getNative(s.id)
if (!native) return
native.pingTimer = setInterval(() => {
if (isSessionWsOpen(s.id)) {
getNative(s.id)?.ws?.send(JSON.stringify({ type: 'PING', ts: Date.now() }))
}
}, 5000)
}
const SSH_INIT_TIMEOUT_MS = 45_000
function clearInitTimeout(sessionId: string) {
const native = getNative(sessionId)
if (native?.initTimeout) {
clearTimeout(native.initTimeout)
native.initTimeout = null
}
}
function disposeSessionNative(sessionId: string) {
const native = getNative(sessionId)
if (!native) return
clearInitTimeout(sessionId)
if (native.reconnectTimer) {
clearTimeout(native.reconnectTimer)
native.reconnectTimer = null
}
if (native.pingTimer) {
clearInterval(native.pingTimer)
native.pingTimer = null
}
if (native.uptimeTimer) {
clearInterval(native.uptimeTimer)
native.uptimeTimer = null
}
if (native.ws) {
try {
native.ws.close(1000)
} catch {
/* already closed */
}
native.ws = null
}
if (native.term) {
try {
native.term.dispose()
} catch {
/* already disposed */
}
native.term = null
}
if (native.resizeObserver) {
try {
native.resizeObserver.disconnect()
} catch {
/* ignore */
}
native.resizeObserver = null
}
native.fitAddon = null
deleteNative(sessionId)
}
async function connectSession(
sessionId: string,
opts?: { websshTokenPromise?: Promise<{ webssh_token: string }> },
) {
const s = getSession(sessionId)
if (!s) return
const native = getNative(sessionId)
if (!native) return
if (native.ws) {
try {
native.ws.close(1000)
} catch {
/* ignore */
}
native.ws = null
}
s.status = 'connecting'
let websshToken: string
try {
const res = opts?.websshTokenPromise
? await opts.websshTokenPromise
: await http.post<{ webssh_token: string }>('/auth/webssh-token', {
server_id: s.serverId,
})
websshToken = res.webssh_token
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '无法获取终端令牌'
s.status = 'error'
native.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`)
s.showOverlay = true
s.overlayMsg = msg
s.reconnectCountdown = ''
return
}
const wsUrl = buildWebSocketUrl(`/ws/terminal/${s.serverId}`, { token: websshToken })
const ws = new WebSocket(wsUrl)
native.ws = ws
ws.onopen = () => {
s.status = 'connecting'
s.manualDisconnect = false
s.reconnectAttempt = 0
if (native.reconnectTimer) {
clearTimeout(native.reconnectTimer)
native.reconnectTimer = null
}
s.showOverlay = false
s.overlayMsg = ''
s.reconnectCountdown = ''
clearInitTimeout(sessionId)
native.initTimeout = setTimeout(() => {
if (s.status !== 'connecting') return
s.status = 'error'
s.showOverlay = true
s.overlayMsg = 'SSH 连接超时,请重试'
s.reconnectCountdown = ''
native.term?.writeln('\r\n\x1b[31m⚠ SSH 连接超时\x1b[0m')
try {
ws.close(4000, 'SSH init timeout')
} catch {
/* ignore */
}
}, SSH_INIT_TIMEOUT_MS)
native.term?.focus()
}
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data as string)
switch (msg.type) {
case 'TERMINAL_INIT':
clearInitTimeout(sessionId)
s.status = 'connected'
s.serverName = msg.server_name || s.serverName
s.showOverlay = false
s.overlayMsg = ''
startUptimeCounter(s)
startPing(s)
{
const dims = native.fitAddon?.proposeDimensions()
if (dims && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows }))
}
}
if (s.pendingPath && !s.initialCdSent && isSessionReady(s)) {
sendCdToSession(s, s.pendingPath)
}
native.term?.focus()
break
case 'DATA':
native.term?.write(decodeTerminalPayload(msg))
break
case 'ERROR':
native.term?.write(`\r\n\x1b[31m⚠ ${msg.message}\x1b[0m\r\n`)
break
case 'PONG':
s.pingMs = Date.now() - (msg.ts || 0)
break
case 'CLOSE':
native.term?.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n')
break
}
} catch {
native.term?.write(e.data)
}
}
ws.onclose = (ev) => {
clearInitTimeout(sessionId)
stopPing(s)
stopUptimeCounter(s)
if (ev.code === 4001) {
native.term?.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n')
setTimeout(() => router.push('/login'), 2000)
return
}
if (ev.code === 4003) {
s.status = 'error'
native.term?.write(
`\r\n\x1b[31m⚠ 授权失败: ${ev.reason || 'SSH凭据未配置'}\x1b[0m\r\n`,
)
s.showOverlay = true
s.overlayMsg = ev.reason || 'SSH 凭据未配置'
s.reconnectCountdown = ''
return
}
s.status = 'error'
native.ws = null
if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
startReconnect(s)
} else {
s.showOverlay = true
s.overlayMsg = '连接已断开'
s.reconnectCountdown = ''
}
}
ws.onerror = () => {
/* onclose handles UI */
}
}
function startReconnect(s: TermSessionData) {
const native = getNative(s.id)
if (!native) return
s.reconnectAttempt++
const delay = Math.min(1000 * 2 ** (s.reconnectAttempt - 1), 30000)
s.showOverlay = true
s.overlayMsg = '连接已断开'
s.reconnectCountdown = `${Math.ceil(delay / 1000)} 秒后自动重连…`
native.reconnectTimer = setTimeout(() => {
void doReconnect(s)
}, delay)
}
async function doReconnect(s: TermSessionData) {
const native = getNative(s.id)
if (!native) return
s.showOverlay = false
s.reconnectCountdown = ''
s.manualDisconnect = false
s.reconnectAttempt = 0
native.term?.clear()
await connectSession(s.id)
}
function reconnect() {
const s = activeSession.value
if (!s) return
const native = getNative(s.id)
if (native?.reconnectTimer) {
clearTimeout(native.reconnectTimer)
native.reconnectTimer = null
}
s.reconnectAttempt = 0
void doReconnect(s)
}
function disconnect() {
const s = activeSession.value
if (!s) return
const native = getNative(s.id)
if (!native) return
s.manualDisconnect = true
if (native.reconnectTimer) {
clearTimeout(native.reconnectTimer)
native.reconnectTimer = null
}
if (native.ws) {
if (native.ws.readyState === WebSocket.OPEN) {
native.ws.send(JSON.stringify({ type: 'CLOSE' }))
native.ws.close(1000, 'User disconnect')
}
native.ws = null
}
stopPing(s)
stopUptimeCounter(s)
s.status = 'error'
s.showOverlay = true
s.overlayMsg = '连接已断开'
s.reconnectCountdown = ''
}
async function switchTab(sessionId: string) {
activeSessionId.value = sessionId
await nextTick()
const native = getNative(sessionId)
if (native?.fitAddon) {
setTimeout(() => {
try {
native.fitAddon!.fit()
} catch {
/* size 0 */
}
}, 50)
}
native?.term?.focus()
}
async function closeTab(sessionId: string) {
const idx = sessions.value.findIndex((s) => s.id === sessionId)
if (idx < 0) return
disposeSessionNative(sessionId)
termRefs.delete(sessionId)
sessions.value.splice(idx, 1)
if (sessions.value.length === 0) {
activeSessionId.value = ''
return
}
const newIdx = Math.min(idx, sessions.value.length - 1)
await switchTab(sessions.value[newIdx].id)
}
async function newSession(serverId?: number, opts?: { path?: string }) {
if (sessions.value.length >= MAX_TABS) {
snackbar('最多10个标签', 'warning')
return
}
if (!serverId) {
showServerDialog.value = true
return
}
const existing = sessions.value.find((s) => s.serverId === serverId)
if (existing) {
snackbar('该服务器已有会话,已切换过去', 'info')
await switchTab(existing.id)
if (opts?.path) sendCdToSession(existing, opts.path)
return
}
const websshTokenPromise = http.post<{ webssh_token: string }>('/auth/webssh-token', {
server_id: serverId,
})
const server = await resolveServerMeta(serverId)
const name = server?.name ?? `#${serverId}`
const sid = `term_${Date.now()}`
const session: TermSessionData = {
id: sid,
serverId,
serverName: name,
cwd: '',
cmdHistory: cmdBar.loadHistoryForNewSession(),
cmdIdx: -1,
reconnectAttempt: 0,
manualDisconnect: false,
status: 'connecting',
showOverlay: false,
overlayMsg: '',
reconnectCountdown: '',
uptime: '',
pingMs: null,
connectionStartTime: 0,
pendingPath: sanitizeRemotePath(opts?.path || ''),
initialCdSent: false,
}
createNative(sid)
sessions.value.push(session)
await switchTab(sid)
const container = await waitForTermContainer(sid)
if (!container) {
snackbar('终端面板未就绪,请关闭标签后重试', 'error')
await closeTab(sid)
return
}
const term = createTerminal()
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
term.open(container)
setNativeTerm(sid, term)
setNativeFitAddon(sid, fitAddon)
term.onData((data) => {
const sess = getSession(sid)
if (sess && isSessionReady(sess)) sendWsData(sid, data)
})
term.onResize(({ cols, rows }) => {
const ws = getNative(sid)?.ws
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }))
}
})
term.onTitleChange((title) => {
const sess = getSession(sid)
if (sess) {
const m = title.match(/^(\/.+)/)
if (m) sess.cwd = m[1]
}
})
const ro = new ResizeObserver(() => {
try {
fitAddon.fit()
} catch {
/* size 0 */
}
})
ro.observe(container)
setNativeResizeObserver(sid, ro)
await connectSession(sid, { websshTokenPromise })
setTimeout(() => {
try {
fitAddon.fit()
} catch {
/* size 0 */
}
}, 100)
term.focus()
}
async function applyRouteQuery() {
const id = parseRouteServerId()
const path = sanitizeRemotePath(String(route.query.path || ''))
const forceNew = route.query.new_tab === '1' || route.query.new_tab === 'true'
if (!id) return
if (!forceNew) {
const existing = sessions.value.find((s) => s.serverId === id)
if (existing) {
await switchTab(existing.id)
if (path) sendCdToSession(existing, path)
return
}
}
await newSession(id, { path })
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
termContainer.value?.requestFullscreen?.()
} else {
document.exitFullscreen?.()
}
}
async function loadQuickCmds() {
try {
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
quickCmds.value = sortQuickCommands(res || [])
} catch {
snackbar('快捷命令加载失败,已使用内置命令', 'warning')
quickCmds.value = sortQuickCommands([
{
id: 1,
name: '系统状态',
cmd: 'systemctl status --no-pager 2>&1 | head -20\r',
is_builtin: true,
sort_order: 1001,
},
{
id: 2,
name: '系统日志',
cmd: 'journalctl -n 30 --no-pager 2>&1\r',
is_builtin: true,
sort_order: 1002,
},
{
id: 3,
name: '磁盘使用',
cmd: 'df -h\r',
is_builtin: true,
sort_order: 1003,
},
{
id: 4,
name: '内存使用',
cmd: 'free -h\r',
is_builtin: true,
sort_order: 1004,
},
{
id: 5,
name: '进程列表',
cmd: 'ps aux --sort=-%mem 2>&1 | head -15\r',
is_builtin: true,
sort_order: 1005,
},
])
}
}
function execQuickCmd(cmd: string) {
const s = activeSession.value
if (!s || !isSessionReady(s)) {
snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning')
return
}
sendWsData(s.id, cmd)
getNative(s.id)?.term?.focus()
}
async function loadServers() {
serversLoading.value = true
try {
await loadHistoryOnly()
const { items } = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
servers.value = items.map(toTerminalServerItem)
} catch {
servers.value = []
snackbar('加载服务器列表失败', 'warning')
} finally {
serversLoading.value = false
}
}
function commitServerSearch() {
void recordTerminalSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
if (val == null || val === '') {
serverSearch.value = ''
void recordTerminalSearch('')
return
}
const picked = String(val).trim()
serverSearch.value = picked
if (terminalSearchHistory.value.includes(picked)) {
void recordTerminalSearch(picked)
}
}
function onGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'l') {
e.preventDefault()
const s = activeSession.value
const native = s ? getNative(s.id) : undefined
if (native?.term) {
native.term.clear()
if (isSessionReady(s!)) sendWsData(s!.id, '\x0c')
}
return
}
// US 键盘 Ctrl+加号 可能上报 '+' 或 '='(同一物理键),只处理一次
if (e.ctrlKey && (e.key === '+' || e.key === '=')) {
e.preventDefault()
settings.changeFontSize(1)
return
}
if (e.ctrlKey && e.key === '-') {
e.preventDefault()
settings.changeFontSize(-1)
}
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault()
toggleFullscreen()
}
}
function disposeAllSessions() {
const ids = sessions.value.map((s) => s.id)
ids.forEach(disposeSessionNative)
termRefs.clear()
sessions.value = []
activeSessionId.value = ''
}
watch(
() => [route.query.server_id, route.query.server, route.query.path, route.query.new_tab],
() => {
void applyRouteQuery()
},
)
return {
sessions,
activeSessionId,
activeSession,
activeSessionConnected,
cmdBarDisabled,
termContainer,
showServerDialog,
servers,
serversLoading,
serverSearch,
terminalSearchHistory,
filteredServers,
showEmptyPicker,
quickCmds,
fontSize: settings.fontSize,
scrollback: settings.scrollback,
scrollbackOptions: settings.scrollbackOptions,
changeFontSize: settings.changeFontSize,
changeScrollback: settings.changeScrollback,
cmdInput: cmdBar.cmdInput,
sendCmd: cmdBar.sendCmd,
onCmdKeydown: cmdBar.onCmdKeydown,
setTermRef,
switchTab,
closeTab,
newSession,
disconnect,
reconnect,
toggleFullscreen,
loadQuickCmds,
execQuickCmd,
loadServers,
commitServerSearch,
onServerSearchUpdate,
applyRouteQuery,
onGlobalKeydown,
disposeAllSessions,
getNative,
sendWsData,
}
}