f6e8b29640
终端独立 terminal_search 上下文(10条),与 servers_search、push_search 隔离。 Co-authored-by: Cursor <cursoragent@cursor.com>
826 lines
22 KiB
TypeScript
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,
|
|
}
|
|
}
|