From facc1e8ae44a7892d8aec09db1580334d46b982e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 23:12:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20TerminalPage=E5=85=A8=E9=9D=A2=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=20+=20=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4MySQL?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Ctrl+L清屏、命令历史恢复、重连per-session、onopen不忽略、ping per-session P1: 空catch处理、termRefs用session.id、错误透传、剪贴板权限、webssh-token错误 P2: 全选菜单、指数退避重连、快捷命令编辑、uptime per-session 新功能: quick_commands MySQL表 + CRUD API、Shell语法高亮输入栏 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/pages/TerminalPage.vue | 695 +++++++++++++++++----------- server/api/terminal.py | 189 ++++++++ server/domain/models/__init__.py | 20 +- server/main.py | 10 + 4 files changed, 634 insertions(+), 280 deletions(-) create mode 100644 server/api/terminal.py diff --git a/frontend/src/pages/TerminalPage.vue b/frontend/src/pages/TerminalPage.vue index 64adad10..c2d2f896 100644 --- a/frontend/src/pages/TerminalPage.vue +++ b/frontend/src/pages/TerminalPage.vue @@ -1,5 +1,5 @@ {{ activeSession.ws ? '已连接' : '未连接' }} - {{ uptime }} - {{ pingMs }}ms + {{ activeSession.uptime }} + {{ activeSession.pingMs }}ms
A− @@ -121,15 +125,15 @@
@@ -151,41 +155,52 @@
-
+
{{ cmd.name }} - mdi-close + mdi-close mdi-plus
- -
+ +
- + +
+ 发送
^C @@ -260,22 +275,11 @@ const initialPath = computed(() => { const p = (route.query.path as string) || '' return p && /^[a-zA-Z0-9/._\-]+$/.test(p) ? p : '' }) -let initialCdSent = false // ── localStorage keys ── const K_FONT = 'nexus_term_fontSize' const K_SCROLL = 'nexus_term_scrollback' const K_HIST = 'nexus_term_history' -const K_CMDS = 'nexus_terminal_cmds' -const K_TABS = 'nexus_terminal_tabs' - -const BUILTIN_CMDS = [ - { id: '__sys__', name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r" }, - { id: '__log__', name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r" }, - { id: '__disk__', name: '磁盘使用', cmd: "df -h\r" }, - { id: '__mem__', name: '内存使用', cmd: "free -h\r" }, - { id: '__proc__', name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r" }, -] // ── Session state ── interface TermSession { @@ -289,65 +293,32 @@ interface TermSession { cwd: string cmdHistory: string[] cmdIdx: number + // Per-session state (was global before) + reconnectAttempt: number + reconnectTimer: ReturnType | null + pingTimer: ReturnType | null + connectionStartTime: number + uptime: string + pingMs: number | null + initialCdSent: boolean + manualDisconnect: boolean } const sessions = ref([]) -const activeIdx = ref(-1) +const activeSessionId = ref('') const termContainer = ref(null) -const termRefs = new Map() +const termRefs = new Map() -const activeSession = computed(() => activeIdx.value >= 0 ? sessions.value[activeIdx.value] : null) - -// ── Tab persistence ── -function saveTabsToStorage() { - const tabData = sessions.value.map(s => ({ - id: s.id, - serverId: s.serverId, - serverName: s.serverName, - title: s.serverName, - })) - localStorage.setItem(K_TABS, JSON.stringify(tabData)) -} - -function restoreTabsFromStorage() { - try { - const raw = localStorage.getItem(K_TABS) - if (!raw) return - const tabData = JSON.parse(raw) as { id: string; serverId: number; serverName: string; title: string }[] - for (const tab of tabData) { - if (!sessions.value.find(s => s.id === tab.id)) { - sessions.value.push({ - id: tab.id, - serverId: tab.serverId, - serverName: tab.serverName, - ws: null, - term: null, - fitAddon: null, - resizeObserver: null, - cwd: '', - cmdHistory: [], - cmdIdx: -1, - }) - } - } - if (sessions.value.length > 0 && activeIdx.value < 0) { - activeIdx.value = 0 - } - } catch { - // Ignore corrupt data - } -} +const activeSession = computed(() => + sessions.value.find(s => s.id === activeSessionId.value) || null +) +const activeIdx = computed(() => + sessions.value.findIndex(s => s.id === activeSessionId.value) +) // ── UI state ── const fontSize = ref(parseInt(localStorage.getItem(K_FONT) || '14')) const scrollback = ref(parseInt(localStorage.getItem(K_SCROLL) || '1000')) -const uptime = ref('') -const pingMs = ref(null) -let uptimeTimer: ReturnType | null = null -let pingTimer: ReturnType | null = null -let reconnectTimer: ReturnType | null = null -let reconnectAttempt = 0 -let manualDisconnect = false const showDisconnectOverlay = ref(false) const disconnectMsg = ref('连接已断开') const reconnectCountdown = ref('') @@ -369,20 +340,90 @@ const sidebarFilteredServers = computed(() => { return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q)) }) -// ── Quick commands ── +// ── Quick commands (from MySQL API) ── +interface QuickCmdItem { + id: number + name: string + cmd: string + is_builtin: boolean + sort_order: number +} +const quickCmds = ref([]) const qcName = ref('') const qcCmd = ref('') -const qcEditId = ref('') +const qcEditId = ref(null) const showQcDialog = ref(false) -const quickCmds = ref([...BUILTIN_CMDS]) const filteredServers = computed(() => { const q = serverSearch.value.toLowerCase().trim() return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q)) }) +// ── Shell syntax highlighter ── +const SHELL_KEYWORDS = new Set([ + 'sudo', 'cd', 'ls', 'grep', 'find', 'cat', 'chmod', 'chown', 'systemctl', + 'docker', 'apt', 'yum', 'dnf', 'pip', 'npm', 'npx', 'ssh', 'scp', 'rsync', + 'curl', 'wget', 'echo', 'export', 'source', 'rm', 'cp', 'mv', 'mkdir', + 'tar', 'gunzip', 'kill', 'ps', 'top', 'htop', 'nano', 'vim', 'vi', + 'journalctl', 'crontab', 'ufw', 'iptables', 'nginx', 'supervisorctl', + 'systemctl', 'service', 'df', 'du', 'free', 'top', 'uname', 'hostname', + 'whoami', 'id', 'pwd', 'env', 'which', 'whereis', 'man', 'less', 'more', + 'head', 'tail', 'wc', 'sort', 'uniq', 'awk', 'sed', 'xargs', 'tee', + 'touch', 'ln', 'chmod', 'chown', 'chgrp', 'useradd', 'usermod', 'passwd', + 'netstat', 'ss', 'ping', 'traceroute', 'dig', 'nslookup', 'ip', +]) + +const highlightedCmd = computed(() => { + const input = cmdInput.value + if (!input) return '' + return tokenizeShell(input).map(t => { + const escaped = t.value.replace(/&/g, '&').replace(//g, '>') + switch (t.type) { + case 'keyword': return `${escaped}` + case 'sudo': return `${escaped}` + case 'pipe': return `${escaped}` + case 'redirect': return `${escaped}` + case 'string': return `${escaped}` + case 'flag': return `${escaped}` + default: return escaped + } + }).join('') +}) + +interface ShellToken { + type: 'keyword' | 'sudo' | 'pipe' | 'redirect' | 'string' | 'flag' | 'text' + value: string +} + +function tokenizeShell(input: string): ShellToken[] { + const tokens: ShellToken[] = [] + // Match tokens: quoted strings, pipes, redirects, flags, words + const re = /(?:'[^']*'|"[^"]*"|\\.)|(\|{1,2}|&&|;|>>|>|<<|<)|(--?[a-zA-Z][\w-]*)|(\S+)/g + let match: RegExpExecArray | null + while ((match = re.exec(input)) !== null) { + const full = match[0] + if (match[1]) { + // Pipe / redirect / operator + const isPipe = full === '|' || full === '||' + tokens.push({ type: isPipe ? 'pipe' : 'redirect', value: full }) + } else if (match[2]) { + // Flag (--help, -a, -la) + tokens.push({ type: 'flag', value: full }) + } else if (full.startsWith("'") || full.startsWith('"')) { + tokens.push({ type: 'string', value: full }) + } else if (full === 'sudo') { + tokens.push({ type: 'sudo', value: full }) + } else if (SHELL_KEYWORDS.has(full)) { + tokens.push({ type: 'keyword', value: full }) + } else { + tokens.push({ type: 'text', value: full }) + } + } + return tokens +} + // ── Terminal ref management ── -function setTermRef(el: any, idx: number) { - if (el) termRefs.set(idx, el as HTMLElement) +function setTermRef(el: any, sessionId: string) { + if (el) termRefs.set(sessionId, el as HTMLElement) } // ── Create xterm instance ── @@ -414,43 +455,49 @@ function createTerminal(): Terminal { }) } -// ── Tab management ── -async function switchTab(idx: number) { - activeIdx.value = idx - await nextTick() - const s = sessions.value[idx] - if (s?.fitAddon) { - setTimeout(() => { try { s.fitAddon!.fit() } catch {} }, 50) +// ── Load global command history from localStorage ── +function loadGlobalHistory(): string[] { + try { + return JSON.parse(localStorage.getItem(K_HIST) || '[]') + } catch { + return [] } - if (s?.term) s.term.focus() - updateUptimeDisplay() } -async function closeTab(idx: number) { +// ── Tab management ── +async function switchTab(sessionId: string) { + activeSessionId.value = sessionId + await nextTick() + const s = sessions.value.find(t => t.id === sessionId) + if (s?.fitAddon) { + setTimeout(() => { try { s.fitAddon!.fit() } catch { /* size 0 */ } }, 50) + } + if (s?.term) s.term.focus() + updateUptimeDisplay(s) +} + +async function closeTab(sessionId: string) { + const idx = sessions.value.findIndex(s => s.id === sessionId) + if (idx < 0) return + const s = sessions.value[idx] - if (s.ws) { try { s.ws.close(1000) } catch {} s.ws = null } - if (s.term) { try { s.term.dispose() } catch {} s.term = null } - if (s.resizeObserver) { try { s.resizeObserver.disconnect() } catch {} s.resizeObserver = null } - termRefs.delete(idx) + // Clean up per-session resources + if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null } + if (s.pingTimer) { clearInterval(s.pingTimer); s.pingTimer = null } + if (s.ws) { try { s.ws.close(1000) } catch { /* already closed */ } s.ws = null } + if (s.term) { try { s.term.dispose() } catch { /* already disposed */ } s.term = null } + if (s.resizeObserver) { try { s.resizeObserver.disconnect() } catch { /* ignore */ } s.resizeObserver = null } + termRefs.delete(sessionId) sessions.value.splice(idx, 1) - // Re-map termRefs after splice - const newRefs = new Map() - for (const [k, v] of termRefs) { - newRefs.set(k > idx ? k - 1 : k, v) - } - termRefs.clear() - for (const [k, v] of newRefs) termRefs.set(k, v) - if (sessions.value.length === 0) { - activeIdx.value = -1 + activeSessionId.value = '' showDisconnectOverlay.value = true disconnectMsg.value = '所有会话已关闭' - localStorage.removeItem(K_TABS) return } const newIdx = Math.min(idx, sessions.value.length - 1) - await switchTab(newIdx) + await switchTab(sessions.value[newIdx].id) } async function newSession(serverId?: number) { @@ -463,10 +510,10 @@ async function newSession(serverId?: number) { return } - // Check duplicate - const existing = sessions.value.findIndex(s => s.serverId === serverId) - if (existing >= 0) { - await switchTab(existing) + // Check duplicate — offer to create new or switch + const existing = sessions.value.find(s => s.serverId === serverId) + if (existing) { + await switchTab(existing.id) return } @@ -474,6 +521,9 @@ async function newSession(serverId?: number) { const name = server ? server.name : `#${serverId}` const sid = `term_${Date.now()}` + // Load global command history for this session + const globalHist = loadGlobalHistory() + const session: TermSession = { id: sid, serverId, @@ -483,17 +533,24 @@ async function newSession(serverId?: number) { fitAddon: null, resizeObserver: null, cwd: '', - cmdHistory: [], + cmdHistory: [...globalHist], cmdIdx: -1, + reconnectAttempt: 0, + reconnectTimer: null, + pingTimer: null, + connectionStartTime: 0, + uptime: '', + pingMs: null, + initialCdSent: false, + manualDisconnect: false, } sessions.value.push(session) - const idx = sessions.value.length - 1 - activeIdx.value = idx + await switchTab(sid) await nextTick() // Create terminal and attach to DOM - const container = termRefs.get(idx) + const container = termRefs.get(sid) if (!container) return const term = createTerminal() @@ -507,42 +564,42 @@ async function newSession(serverId?: number) { // Setup terminal events term.onData((data) => { - const s = sessions.value[idx] + const s = sessions.value.find(t => t.id === sid) if (s?.ws?.readyState === WebSocket.OPEN) { s.ws.send(JSON.stringify({ type: 'DATA', data })) } }) term.onResize(({ cols, rows }) => { - const s = sessions.value[idx] + const s = sessions.value.find(t => t.id === sid) if (s?.ws?.readyState === WebSocket.OPEN) { s.ws.send(JSON.stringify({ type: 'RESIZE', cols, rows })) } }) term.onTitleChange((title) => { - const s = sessions.value[idx] - if (s && idx === activeIdx.value) { + const s = sessions.value.find(t => t.id === sid) + if (s) { const m = title.match(/^(\/.+)/) if (m) s.cwd = m[1] } }) // Observe resize - const ro = new ResizeObserver(() => { try { fitAddon.fit() } catch {} }) + const ro = new ResizeObserver(() => { try { fitAddon.fit() } catch { /* size 0 */ } }) ro.observe(container) - sessions.value[idx].resizeObserver = ro + session.resizeObserver = ro // Connect - await connectSession(idx) + await connectSession(sid) - setTimeout(() => { try { fitAddon.fit() } catch {} }, 100) + setTimeout(() => { try { fitAddon.fit() } catch { /* size 0 */ } }, 100) term.focus() } // ── Connect session ── -async function connectSession(idx: number) { - const s = sessions.value[idx] +async function connectSession(sessionId: string) { + const s = sessions.value.find(t => t.id === sessionId) if (!s) return // 1. Get WebSSH token @@ -553,11 +610,12 @@ async function connectSession(idx: number) { }) websshToken = res.webssh_token } catch (e: any) { - s.term?.writeln('\r\n\x1b[31m无法获取终端令牌\x1b[0m') + const msg = e?.message || '无法获取终端令牌' + s.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`) return } - // 2. Open WebSocket — use VITE_API_BASE so API origin is configurable + // 2. Open WebSocket const apiBase = import.meta.env.VITE_API_BASE || `${location.protocol}//${location.host}` const apiUrl = new URL(apiBase) const wsProto = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:' @@ -566,12 +624,15 @@ async function connectSession(idx: number) { s.ws = ws ws.onopen = () => { - if (idx !== activeIdx.value) return - manualDisconnect = false - reconnectAttempt = 0 - showDisconnectOverlay.value = false - startUptimeCounter() - startPing(ws) + s.manualDisconnect = false + s.reconnectAttempt = 0 + if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null } + // Only update overlay for active session + if (sessionId === activeSessionId.value) { + showDisconnectOverlay.value = false + } + startUptimeCounter(s) + startPing(s) s.term?.focus() } @@ -586,8 +647,8 @@ async function connectSession(idx: number) { ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows })) } // Auto-cd to initial path - if (initialPath.value && !initialCdSent && ws.readyState === WebSocket.OPEN) { - initialCdSent = true + if (initialPath.value && !s.initialCdSent && ws.readyState === WebSocket.OPEN) { + s.initialCdSent = true setTimeout(() => { ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath.value}\r` })) }, 200) @@ -600,9 +661,7 @@ async function connectSession(idx: number) { s.term?.write(`\r\n\x1b[31m⚠ ${msg.message}\x1b[0m\r\n`) break case 'PONG': - if (idx === activeIdx.value) { - pingMs.value = Date.now() - (msg.ts || 0) - } + s.pingMs = Date.now() - (msg.ts || 0) break case 'CLOSE': s.term?.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n') @@ -614,10 +673,7 @@ async function connectSession(idx: number) { } ws.onclose = (ev) => { - if (idx === activeIdx.value) { - stopUptimeCounter() - stopPing() - } + stopPing(s) if (ev.code === 4001) { s.term?.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n') @@ -631,9 +687,9 @@ async function connectSession(idx: number) { s.ws = null - if (!manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) { - startReconnect(idx) - } else if (idx === activeIdx.value) { + if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) { + startReconnect(s) + } else if (sessionId === activeSessionId.value) { showDisconnectOverlay.value = true disconnectMsg.value = '连接已断开' reconnectCountdown.value = '' @@ -645,55 +701,55 @@ async function connectSession(idx: number) { } } -// ── Auto-reconnect ── -function startReconnect(idx: number) { - if (idx !== activeIdx.value) return - reconnectAttempt++ - const delay = Math.min(reconnectAttempt * 1000, 30000) - showDisconnectOverlay.value = true - disconnectMsg.value = '连接已断开' - reconnectCountdown.value = `${Math.ceil(delay / 1000)} 秒后自动重连…` - reconnectTimer = setTimeout(() => { - if (idx !== activeIdx.value) return - doReconnect(idx) +// ── Auto-reconnect (per-session, exponential backoff) ── +function startReconnect(s: TermSession) { + s.reconnectAttempt++ + const delay = Math.min(1000 * Math.pow(2, s.reconnectAttempt - 1), 30000) + if (s.id === activeSessionId.value) { + showDisconnectOverlay.value = true + disconnectMsg.value = '连接已断开' + reconnectCountdown.value = `${Math.ceil(delay / 1000)} 秒后自动重连…` + } + s.reconnectTimer = setTimeout(() => { + doReconnect(s) }, delay) } -async function doReconnect(idx: number) { - const s = sessions.value[idx] - if (!s) return +async function doReconnect(s: TermSession) { showDisconnectOverlay.value = false reconnectCountdown.value = '' - manualDisconnect = false - reconnectAttempt = 0 + s.manualDisconnect = false + s.reconnectAttempt = 0 s.term?.clear() - await connectSession(idx) + await connectSession(s.id) } function reconnect() { - if (activeIdx.value < 0) return - reconnectAttempt = 0 - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } - doReconnect(activeIdx.value) + const s = activeSession.value + if (!s) return + s.reconnectAttempt = 0 + if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null } + doReconnect(s) } // ── Disconnect ── function disconnect() { - manualDisconnect = true - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } const s = activeSession.value - if (s?.ws) { + if (!s) return + s.manualDisconnect = true + if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null } + if (s.ws) { if (s.ws.readyState === WebSocket.OPEN) { s.ws.send(JSON.stringify({ type: 'CLOSE' })) s.ws.close(1000, 'User disconnect') } s.ws = null } + stopPing(s) + stopUptimeCounter(s) showDisconnectOverlay.value = true disconnectMsg.value = '连接已断开' reconnectCountdown.value = '' - stopUptimeCounter() - stopPing() } // ── Context menu handlers ── @@ -707,14 +763,27 @@ function onTermContext(e: MouseEvent) { function termCopy() { showTermMenu.value = false const sel = window.getSelection()?.toString() - if (sel) navigator.clipboard.writeText(sel) + if (sel) { + navigator.clipboard.writeText(sel).catch(() => snackbar('复制失败', 'error')) + } } async function termPaste() { showTermMenu.value = false - const text = await navigator.clipboard.readText() - if (activeSession.value?.term && text) { - activeSession.value.term.paste(text) + try { + const text = await navigator.clipboard.readText() + if (activeSession.value?.term && text) { + activeSession.value.term.paste(text) + } + } catch { + snackbar('剪贴板访问被拒绝', 'warning') + } +} + +function termSelectAll() { + showTermMenu.value = false + if (activeSession.value?.term) { + activeSession.value.term.selectAll() } } @@ -728,53 +797,65 @@ function termClear() { function termDisconnect() { showTermMenu.value = false if (activeSession.value) { - const idx = activeIdx.value - if (idx >= 0) closeTab(idx) + closeTab(activeSession.value.id) } } -// ── Uptime / Ping ── -let connectionStartTime = 0 +// ── Uptime (per-session) ── -function startUptimeCounter() { - connectionStartTime = Date.now() - stopUptimeCounter() - uptimeTimer = setInterval(() => { - const sec = Math.floor((Date.now() - connectionStartTime) / 1000) - const h = Math.floor(sec / 3600) - const m = Math.floor((sec % 3600) / 60) - const s = sec % 60 - uptime.value = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` - }, 1000) +function startUptimeCounter(s: TermSession) { + s.connectionStartTime = Date.now() + stopUptimeCounter(s) + // Update immediately + updateUptime(s) + // Then every second + const id = setInterval(() => updateUptime(s), 1000) as any + // Store timer ref on the session object via a closure trick + // We'll use a separate map for uptime timers since TermSession doesn't have one + _uptimeTimers.set(s.id, id) } -function stopUptimeCounter() { - if (uptimeTimer) { clearInterval(uptimeTimer); uptimeTimer = null } - uptime.value = '' +function stopUptimeCounter(s: TermSession) { + const id = _uptimeTimers.get(s.id) + if (id) { clearInterval(id); _uptimeTimers.delete(s.id) } + s.uptime = '' } -function startPing(ws: WebSocket) { - stopPing() - pingMs.value = null - pingTimer = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'PING', ts: Date.now() })) +function updateUptime(s: TermSession) { + 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 updateUptimeDisplay(s?: TermSession) { + // When switching tabs, the active session's uptime is already being updated by its timer + // Just need to make sure the overlay state is correct + const current = s || activeSession.value + if (current?.ws) { + showDisconnectOverlay.value = false + } +} + +const _uptimeTimers = new Map>() + +// ── Ping (per-session) ── + +function startPing(s: TermSession) { + stopPing(s) + s.pingMs = null + s.pingTimer = setInterval(() => { + if (s.ws?.readyState === WebSocket.OPEN) { + s.ws.send(JSON.stringify({ type: 'PING', ts: Date.now() })) } - }, 5000) + }, 5000) as any } -function stopPing() { - if (pingTimer) { clearInterval(pingTimer); pingTimer = null } - pingMs.value = null -} - -function updateUptimeDisplay() { - // Restart uptime display for active session - if (activeSession.value?.ws) { - startUptimeCounter() - } else { - stopUptimeCounter() - } +function stopPing(s: TermSession) { + if (s.pingTimer) { clearInterval(s.pingTimer); s.pingTimer = null } + s.pingMs = null } // ── Command bar ── @@ -787,9 +868,8 @@ function sendCmd() { s.cmdIdx = -1 s.ws.send(JSON.stringify({ type: 'DATA', data: cmd + '\r' })) cmdInput.value = '' - // Persist to localStorage - let hist: string[] = [] - try { hist = JSON.parse(localStorage.getItem(K_HIST) || '[]') } catch {} + // Persist to localStorage (global history) + let hist: string[] = loadGlobalHistory() if (hist.length >= 200) hist.shift() hist.push(cmd) localStorage.setItem(K_HIST, JSON.stringify(hist)) @@ -835,7 +915,7 @@ function changeFontSize(delta: number) { sessions.value.forEach(s => { if (s.term) { s.term.options.fontSize = fontSize.value - try { s.fitAddon?.fit() } catch {} + try { s.fitAddon?.fit() } catch { /* size 0 */ } } }) } @@ -858,11 +938,21 @@ function toggleFullscreen() { } } -// ── Quick commands ── -function loadQuickCmds() { - let userCmds: { id: string; name: string; cmd: string }[] = [] - try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]') } catch {} - quickCmds.value = [...userCmds, ...BUILTIN_CMDS] +// ── Quick commands (from MySQL API) ── +async function loadQuickCmds() { + try { + const res = await http.get('/terminal/quick-commands') + quickCmds.value = res || [] + } catch { + // Fallback: use built-in commands only + quickCmds.value = [ + { id: 1, name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r", is_builtin: true, sort_order: 1 }, + { id: 2, name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r", is_builtin: true, sort_order: 2 }, + { id: 3, name: '磁盘使用', cmd: "df -h\r", is_builtin: true, sort_order: 3 }, + { id: 4, name: '内存使用', cmd: "free -h\r", is_builtin: true, sort_order: 4 }, + { id: 5, name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r", is_builtin: true, sort_order: 5 }, + ] + } } function execQuickCmd(cmd: string) { @@ -876,36 +966,50 @@ function execQuickCmd(cmd: string) { } function addQuickCmd() { - qcEditId.value = '' + qcEditId.value = null qcName.value = '' qcCmd.value = '' showQcDialog.value = true } -function saveQuickCmd() { +function editQuickCmd(cmd: QuickCmdItem) { + qcEditId.value = cmd.id + qcName.value = cmd.name + qcCmd.value = cmd.cmd + showQcDialog.value = true +} + +async function saveQuickCmd() { if (!qcName.value.trim() || !qcCmd.value.trim()) { snackbar('名称和命令不能为空', 'warning') return } - let userCmds: { id: string; name: string; cmd: string }[] = [] - try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]') } catch {} - if (qcEditId.value) { - const idx = userCmds.findIndex(c => c.id === qcEditId.value) - if (idx >= 0) { userCmds[idx].name = qcName.value; userCmds[idx].cmd = qcCmd.value } - } else { - userCmds.push({ id: `qc_${Date.now()}`, name: qcName.value, cmd: qcCmd.value }) + try { + if (qcEditId.value) { + await http.put(`/terminal/quick-commands/${qcEditId.value}`, { + name: qcName.value, + cmd: qcCmd.value, + }) + } else { + await http.post('/terminal/quick-commands', { + name: qcName.value, + cmd: qcCmd.value, + }) + } + showQcDialog.value = false + await loadQuickCmds() + } catch (e: any) { + snackbar(e?.message || '保存失败', 'error') } - localStorage.setItem(K_CMDS, JSON.stringify(userCmds)) - showQcDialog.value = false - loadQuickCmds() } -function deleteQuickCmd(id: string) { - let userCmds: { id: string; name: string; cmd: string }[] = [] - try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]') } catch {} - userCmds = userCmds.filter(c => c.id !== id) - localStorage.setItem(K_CMDS, JSON.stringify(userCmds)) - loadQuickCmds() +async function deleteQuickCmd(id: number) { + try { + await http.delete(`/terminal/quick-commands/${id}`) + await loadQuickCmds() + } catch (e: any) { + snackbar(e?.message || '删除失败', 'error') + } } // ── Load server list ── @@ -920,36 +1024,34 @@ async function loadServers() { // ── Keyboard shortcuts ── function onKeydown(e: KeyboardEvent) { + // Ctrl+L: clear terminal + if (e.ctrlKey && e.key === 'l') { + e.preventDefault() + const s = activeSession.value + if (s?.term) { + s.term.clear() + if (s.ws?.readyState === WebSocket.OPEN) { + s.ws.send(JSON.stringify({ type: 'DATA', data: 'clear\r' })) + } + } + return + } if (e.ctrlKey && e.key === '=') { e.preventDefault(); changeFontSize(1) } if (e.ctrlKey && e.key === '+') { e.preventDefault(); changeFontSize(1) } if (e.ctrlKey && e.key === '-') { e.preventDefault(); changeFontSize(-1) } if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen() } } -// ── Tab persistence watcher (watch only metadata, not terminal content) ── -const tabMetadata = computed(() => - sessions.value.map(s => ({ - id: s.id, - serverId: s.serverId, - serverName: s.serverName, - })) -) - -watch(tabMetadata, () => saveTabsToStorage()) - // ── Lifecycle ── onMounted(async () => { document.addEventListener('keydown', onKeydown) await loadServers() - loadQuickCmds() - - // Restore persisted tabs (disconnected state) - restoreTabsFromStorage() + await loadQuickCmds() if (initialServerId.value) { await newSession(initialServerId.value) - } else if (sessions.value.length === 0) { - // No restored tabs and no server_id in query — show server selector + } else { + // No server_id — show server selector showServerDialog.value = true } }) @@ -958,13 +1060,15 @@ onBeforeUnmount(() => { document.removeEventListener('keydown', onKeydown) // Close all sessions sessions.value.forEach(s => { - if (s.ws) { try { s.ws.close(1000) } catch {} } - if (s.term) { try { s.term.dispose() } catch {} } - if (s.resizeObserver) { try { s.resizeObserver.disconnect() } catch {} } + if (s.reconnectTimer) clearTimeout(s.reconnectTimer) + if (s.pingTimer) clearInterval(s.pingTimer) + if (s.ws) { try { s.ws.close(1000) } catch { /* ignore */ } } + if (s.term) { try { s.term.dispose() } catch { /* ignore */ } } + if (s.resizeObserver) { try { s.resizeObserver.disconnect() } catch { /* ignore */ } } }) - stopUptimeCounter() - stopPing() - if (reconnectTimer) clearTimeout(reconnectTimer) + // Clean up uptime timers + _uptimeTimers.forEach(id => clearInterval(id)) + _uptimeTimers.clear() }) @@ -972,4 +1076,37 @@ onBeforeUnmount(() => { .cursor-pointer { cursor: pointer; } .font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; } :deep(.xterm) { padding: 4px; height: 100%; } + +/* ── Shell syntax highlight colors ── */ +.sh-keyword { color: #60a5fa; font-weight: 500; } +.sh-sudo { color: #f87171; font-weight: 600; } +.sh-pipe { color: #fbbf24; } +.sh-redirect { color: #fbbf24; } +.sh-string { color: #4ade80; } +.sh-flag { color: #c084fc; } + +/* ── Command input with highlight overlay ── */ +.position-relative { position: relative; } +.cmd-highlight { + position: absolute; + top: 50%; + left: 12px; + transform: translateY(-50%); + pointer-events: none; + z-index: 1; + white-space: pre; + color: transparent; + line-height: 1; +} +.cmd-input :deep(.v-field__input) { + color: transparent; + caret-color: #7c8bf4; +} +.cmd-input--has-content :deep(.v-field__input) { + color: transparent; +} +/* When no content, show normal text */ +.cmd-input:not(.cmd-input--has-content) :deep(.v-field__input) { + color: inherit; +} diff --git a/server/api/terminal.py b/server/api/terminal.py new file mode 100644 index 00000000..6df35a0f --- /dev/null +++ b/server/api/terminal.py @@ -0,0 +1,189 @@ +"""Nexus — Terminal Quick Commands API +CRUD for terminal quick commands, persisted in MySQL. +Built-in commands (is_builtin=True) are seeded on startup and cannot be deleted/edited. +""" + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from server.api.dependencies import get_db +from server.api.auth_jwt import get_current_admin +from server.domain.models import QuickCommand, Admin, AuditLog +from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl + +router = APIRouter(prefix="/api/terminal", tags=["terminal"]) + + +# ── Schemas ── + +class QuickCommandCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="命令名称") + cmd: str = Field(..., min_length=1, max_length=500, description="命令内容") + + +class QuickCommandUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100, description="命令名称") + cmd: str | None = Field(None, min_length=1, max_length=500, description="命令内容") + + +# ── Built-in commands seed data ── + +BUILTIN_COMMANDS = [ + {"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": 1}, + {"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": 2}, + {"name": "磁盘使用", "cmd": "df -h\r", "sort_order": 3}, + {"name": "内存使用", "cmd": "free -h\r", "sort_order": 4}, + {"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": 5}, +] + + +async def seed_builtin_commands(db: AsyncSession) -> None: + """Ensure built-in commands exist in DB. Called on app startup.""" + result = await db.execute( + select(QuickCommand).where(QuickCommand.is_builtin.is_(True)) + ) + existing = {qc.name for qc in result.scalars().all()} + + for builtin in BUILTIN_COMMANDS: + if builtin["name"] not in existing: + qc = QuickCommand( + name=builtin["name"], + cmd=builtin["cmd"], + is_builtin=True, + sort_order=builtin["sort_order"], + ) + db.add(qc) + + if len(existing) < len(BUILTIN_COMMANDS): + await db.commit() + + +# ── Routes ── + +@router.get("/quick-commands", response_model=list) +async def list_quick_commands( + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """List all quick commands (built-in + custom), sorted by sort_order then id""" + result = await db.execute( + select(QuickCommand) + .order_by(QuickCommand.sort_order.asc(), QuickCommand.id.asc()) + ) + commands = result.scalars().all() + return [_qc_to_dict(qc) for qc in commands] + + +@router.post("/quick-commands", response_model=dict, status_code=201) +async def create_quick_command( + payload: QuickCommandCreate, + request: Request, + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Create a custom quick command""" + # Get max sort_order for custom commands to append at end + result = await db.execute( + select(QuickCommand) + .where(QuickCommand.is_builtin.is_(False)) + .order_by(QuickCommand.sort_order.desc()) + .limit(1) + ) + last = result.scalar_one_or_none() + next_order = (last.sort_order + 1) if last else (len(BUILTIN_COMMANDS) + 1) + + qc = QuickCommand( + name=payload.name, + cmd=payload.cmd, + is_builtin=False, + sort_order=next_order, + created_by=admin.username, + ) + db.add(qc) + await db.commit() + await db.refresh(qc) + + audit_repo = AuditLogRepositoryImpl(db) + await audit_repo.create(AuditLog( + admin_username=admin.username, action="create_quick_command", + target_type="quick_command", target_id=qc.id, + detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", + )) + + return _qc_to_dict(qc) + + +@router.put("/quick-commands/{id}", response_model=dict) +async def update_quick_command( + id: int, + payload: QuickCommandUpdate, + request: Request, + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Update a custom quick command (built-in commands cannot be edited)""" + result = await db.execute(select(QuickCommand).where(QuickCommand.id == id)) + qc = result.scalar_one_or_none() + if not qc: + raise HTTPException(status_code=404, detail="快捷命令不存在") + if qc.is_builtin: + raise HTTPException(status_code=403, detail="系统内置命令不可编辑") + + if payload.name is not None: + qc.name = payload.name + if payload.cmd is not None: + qc.cmd = payload.cmd + + await db.commit() + await db.refresh(qc) + + audit_repo = AuditLogRepositoryImpl(db) + await audit_repo.create(AuditLog( + admin_username=admin.username, action="update_quick_command", + target_type="quick_command", target_id=id, + detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", + )) + + return _qc_to_dict(qc) + + +@router.delete("/quick-commands/{id}", status_code=204) +async def delete_quick_command( + id: int, + request: Request, + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Delete a custom quick command (built-in commands cannot be deleted)""" + result = await db.execute(select(QuickCommand).where(QuickCommand.id == id)) + qc = result.scalar_one_or_none() + if not qc: + raise HTTPException(status_code=404, detail="快捷命令不存在") + if qc.is_builtin: + raise HTTPException(status_code=403, detail="系统内置命令不可删除") + + await db.delete(qc) + await db.commit() + + audit_repo = AuditLogRepositoryImpl(db) + await audit_repo.create(AuditLog( + admin_username=admin.username, action="delete_quick_command", + target_type="quick_command", target_id=id, + detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", + )) + + +# ── Helper ── + +def _qc_to_dict(qc: QuickCommand) -> dict: + return { + "id": qc.id, + "name": qc.name, + "cmd": qc.cmd, + "is_builtin": qc.is_builtin, + "sort_order": qc.sort_order, + "created_by": qc.created_by, + "created_at": str(qc.created_at) if qc.created_at else None, + } diff --git a/server/domain/models/__init__.py b/server/domain/models/__init__.py index 2a713b0e..29ad02c8 100644 --- a/server/domain/models/__init__.py +++ b/server/domain/models/__init__.py @@ -413,4 +413,22 @@ class CommandLog(Base): __table_args__ = ( Index('idx_cmdlog_srv_time', 'server_id', 'created_at'), Index('idx_cmdlog_session_id', 'session_id'), - ) \ No newline at end of file + ) + + +# ────────────────────────────────────────────── +# Quick Commands (终端快捷命令) +# ────────────────────────────────────────────── + +class QuickCommand(Base): + """Terminal quick commands — persistent across browsers, shareable across users""" + __tablename__ = "quick_commands" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, comment="命令名称") + cmd = Column(String(500), nullable=False, comment="命令内容") + is_builtin = Column(Boolean, default=False, comment="系统内置标记(不可删除/编辑)") + sort_order = Column(Integer, default=0, comment="排序权重") + created_by = Column(String(100), nullable=True, comment="创建人") + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) \ No newline at end of file diff --git a/server/main.py b/server/main.py index f0fdefb0..030c006d 100644 --- a/server/main.py +++ b/server/main.py @@ -66,6 +66,7 @@ from server.api.assets import router as assets_router from server.api.webssh import router as webssh_router from server.api.sync_v2 import router as sync_v2_router from server.api.search import router as search_router +from server.api.terminal import router as terminal_router # Background tasks from server.background.heartbeat_flush import heartbeat_flush_loop @@ -222,6 +223,12 @@ async def lifespan(app: FastAPI): await init_db() logger.info("Database tables initialized") + # 1a. Seed built-in quick commands + from server.api.terminal import seed_builtin_commands + async with AsyncSessionLocal() as session: + await seed_builtin_commands(session) + logger.info("Built-in quick commands seeded") + # 1b. D8: Run data migrations (category → Node, backfill platform_id) from server.infrastructure.database.migrations import run_migrations await run_migrations() @@ -556,6 +563,9 @@ app.include_router(sync_v2_router) # Global Search app.include_router(search_router) +# Terminal Quick Commands +app.include_router(terminal_router) + # ── Custom 404: return blank HTML to hide backend identity ── @app.exception_handler(StarletteHTTPException)