diff --git a/internal/app/app.go b/internal/app/app.go index bbf0a5d5..b6ea16b2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -325,6 +325,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger) fofaHandler := handler.NewFofaHandler(cfg, log.Logger) + terminalHandler := handler.NewTerminalHandler(log.Logger) if db != nil { skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计 } @@ -431,6 +432,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { roleHandler, skillsHandler, fofaHandler, + terminalHandler, mcpServer, authManager, openAPIHandler, @@ -542,6 +544,7 @@ func setupRoutes( roleHandler *handler.RoleHandler, skillsHandler *handler.SkillsHandler, fofaHandler *handler.FofaHandler, + terminalHandler *handler.TerminalHandler, mcpServer *mcp.Server, authManager *security.AuthManager, openAPIHandler *handler.OpenAPIHandler, @@ -628,6 +631,10 @@ func setupRoutes( protected.PUT("/config", configHandler.UpdateConfig) protected.POST("/config/apply", configHandler.ApplyConfig) + // 系统设置 - 终端(执行命令,提高运维效率) + protected.POST("/terminal/run", terminalHandler.RunCommand) + protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream) + // 外部MCP管理 protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs) protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats) diff --git a/internal/handler/terminal.go b/internal/handler/terminal.go new file mode 100644 index 00000000..f043bff3 --- /dev/null +++ b/internal/handler/terminal.go @@ -0,0 +1,244 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const ( + terminalMaxCommandLen = 4096 + terminalMaxOutputLen = 256 * 1024 // 256KB + terminalTimeout = 120 * time.Second +) + +// TerminalHandler 处理系统设置中的终端命令执行 +type TerminalHandler struct { + logger *zap.Logger +} + +// NewTerminalHandler 创建终端处理器 +func NewTerminalHandler(logger *zap.Logger) *TerminalHandler { + return &TerminalHandler{logger: logger} +} + +// RunCommandRequest 执行命令请求 +type RunCommandRequest struct { + Command string `json:"command"` + Shell string `json:"shell,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +// RunCommandResponse 执行命令响应 +type RunCommandResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` +} + +// RunCommand 执行终端命令(需登录) +func (h *TerminalHandler) RunCommand(c *gin.Context) { + var req RunCommandRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"}) + return + } + + cmdStr := strings.TrimSpace(req.Command) + if cmdStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"}) + return + } + if len(cmdStr) > terminalMaxCommandLen { + c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"}) + return + } + + shell := req.Shell + if shell == "" { + if runtime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "sh" + } + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout) + defer cancel() + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr) + } else { + cmd = exec.CommandContext(ctx, shell, "-c", cmdStr) + // 无 TTY 时设置 COLUMNS/TERM,使 ping 等工具的 usage 排版与真实终端一致 + cmd.Env = append(os.Environ(), "COLUMNS=120", "LINES=40", "TERM=xterm-256color") + } + + if req.Cwd != "" { + absCwd, err := filepath.Abs(req.Cwd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"}) + return + } + cur, _ := os.Getwd() + curAbs, _ := filepath.Abs(cur) + rel, err := filepath.Rel(curAbs, absCwd) + if err != nil || strings.HasPrefix(rel, "..") || rel == ".." { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"}) + return + } + cmd.Dir = absCwd + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + stdoutBytes := stdout.Bytes() + stderrBytes := stderr.Bytes() + + // 限制输出长度,防止内存占用过大(复制后截断,避免修改原 buffer) + truncSuffix := []byte("\n...(输出已截断)\n") + if len(stdoutBytes) > terminalMaxOutputLen { + tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix)) + n := copy(tmp, stdoutBytes[:terminalMaxOutputLen]) + copy(tmp[n:], truncSuffix) + stdoutBytes = tmp + } + if len(stderrBytes) > terminalMaxOutputLen { + tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix)) + n := copy(tmp, stderrBytes[:terminalMaxOutputLen]) + copy(tmp[n:], truncSuffix) + stderrBytes = tmp + } + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + if ctx.Err() == context.DeadlineExceeded { + so := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n") + so = strings.ReplaceAll(so, "\r", "\n") + se := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n") + se = strings.ReplaceAll(se, "\r", "\n") + resp := RunCommandResponse{ + Stdout: so, + Stderr: se, + ExitCode: -1, + Error: "命令执行超时(" + terminalTimeout.String() + ")", + } + c.JSON(http.StatusOK, resp) + return + } + h.logger.Debug("终端命令执行异常", zap.String("command", cmdStr), zap.Error(err)) + } + + // 统一为 \n,避免前端因 \r 出现错位/对角线排版 + stdoutStr := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n") + stdoutStr = strings.ReplaceAll(stdoutStr, "\r", "\n") + stderrStr := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n") + stderrStr = strings.ReplaceAll(stderrStr, "\r", "\n") + + resp := RunCommandResponse{ + Stdout: stdoutStr, + Stderr: stderrStr, + ExitCode: exitCode, + } + if err != nil && exitCode != 0 { + resp.Error = err.Error() + } + c.JSON(http.StatusOK, resp) +} + +// streamEvent SSE 事件 +type streamEvent struct { + T string `json:"t"` // "out" | "err" | "exit" + D string `json:"d,omitempty"` + C int `json:"c"` // exit code(不用 omitempty,否则 0 不序列化导致前端显示 [exit undefined]) +} + +// RunCommandStream 流式执行命令,输出实时推送到前端(SSE) +func (h *TerminalHandler) RunCommandStream(c *gin.Context) { + var req RunCommandRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"}) + return + } + cmdStr := strings.TrimSpace(req.Command) + if cmdStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"}) + return + } + if len(cmdStr) > terminalMaxCommandLen { + c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"}) + return + } + shell := req.Shell + if shell == "" { + if runtime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "sh" + } + } + ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout) + defer cancel() + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr) + } else { + cmd = exec.CommandContext(ctx, shell, "-c", cmdStr) + cmd.Env = append(os.Environ(), "COLUMNS=120", "LINES=40", "TERM=xterm-256color") + } + if req.Cwd != "" { + absCwd, err := filepath.Abs(req.Cwd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"}) + return + } + cur, _ := os.Getwd() + curAbs, _ := filepath.Abs(cur) + rel, err := filepath.Rel(curAbs, absCwd) + if err != nil || strings.HasPrefix(rel, "..") || rel == ".." { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"}) + return + } + cmd.Dir = absCwd + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + flusher, ok := c.Writer.(http.Flusher) + if !ok { + cancel() + return + } + + sendEvent := func(ev streamEvent) { + body, _ := json.Marshal(ev) + c.SSEvent("", string(body)) + flusher.Flush() + } + + runCommandStreamImpl(cmd, sendEvent, ctx) +} diff --git a/internal/handler/terminal_stream_unix.go b/internal/handler/terminal_stream_unix.go new file mode 100644 index 00000000..79439c98 --- /dev/null +++ b/internal/handler/terminal_stream_unix.go @@ -0,0 +1,46 @@ +//go:build !windows + +package handler + +import ( + "bufio" + "context" + "os/exec" + "strings" + + "github.com/creack/pty" +) + +const ptyCols = 120 +const ptyRows = 40 + +// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真) +func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) { + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows}) + if err != nil { + sendEvent(streamEvent{T: "exit", C: -1}) + return + } + defer ptmx.Close() + + normalize := func(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") + } + sc := bufio.NewScanner(ptmx) + for sc.Scan() { + sendEvent(streamEvent{T: "out", D: normalize(sc.Text())}) + } + exitCode := 0 + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + if ctx.Err() == context.DeadlineExceeded { + exitCode = -1 + } + sendEvent(streamEvent{T: "exit", C: exitCode}) +} diff --git a/internal/handler/terminal_stream_windows.go b/internal/handler/terminal_stream_windows.go new file mode 100644 index 00000000..9f69303c --- /dev/null +++ b/internal/handler/terminal_stream_windows.go @@ -0,0 +1,65 @@ +//go:build windows + +package handler + +import ( + "bufio" + "context" + "os/exec" + "strings" + "sync" +) + +// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行 +func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) { + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + sendEvent(streamEvent{T: "exit", C: -1}) + return + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + sendEvent(streamEvent{T: "exit", C: -1}) + return + } + if err := cmd.Start(); err != nil { + sendEvent(streamEvent{T: "exit", C: -1}) + return + } + + normalize := func(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + sc := bufio.NewScanner(stdoutPipe) + for sc.Scan() { + sendEvent(streamEvent{T: "out", D: normalize(sc.Text())}) + } + }() + go func() { + defer wg.Done() + sc := bufio.NewScanner(stderrPipe) + for sc.Scan() { + sendEvent(streamEvent{T: "err", D: normalize(sc.Text())}) + } + }() + + wg.Wait() + exitCode := 0 + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + if ctx.Err() == context.DeadlineExceeded { + exitCode = -1 + } + sendEvent(streamEvent{T: "exit", C: exitCode}) +} diff --git a/web/static/css/style.css b/web/static/css/style.css index f0406a3d..ad0aa81d 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3132,6 +3132,164 @@ header { line-height: 1.6; } +/* 系统设置 - 终端 */ +.terminal-wrapper { + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + background: #0d1117; +} + +.terminal-tabs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + padding: 6px 10px 0; + background: #161b22; + border-bottom: 1px solid var(--border-color); + min-height: 36px; +} + +.terminal-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px 5px 12px; + font-size: 0.8125rem; + color: #8b949e; + background: transparent; + border: none; + border-radius: 4px 4px 0 0; + cursor: pointer; + margin-right: 2px; +} + +.terminal-tab-label { + cursor: pointer; +} + +.terminal-tab-close { + padding: 0; + width: 18px; + height: 18px; + font-size: 1.1rem; + line-height: 1; + color: #8b949e; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.terminal-tab-close:hover { + color: #ff7b72; + background: rgba(255, 123, 114, 0.15); +} + +.terminal-tab:hover { + color: #e6edf3; + background: rgba(255, 255, 255, 0.06); +} + +.terminal-tab.active { + color: #e6edf3; + background: #0d1117; + font-weight: 500; +} + +.terminal-tab-new { + margin-left: 4px; + width: 28px; + height: 28px; + font-size: 1.125rem; + line-height: 1; + color: #8b949e; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.terminal-tab-new:hover { + color: #58a6ff; + background: rgba(88, 166, 255, 0.1); + border-color: rgba(88, 166, 255, 0.3); +} + +.terminal-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: #161b22; + border-bottom: 1px solid var(--border-color); +} + +.terminal-toolbar-title { + font-size: 0.8125rem; + font-weight: 600; + color: #8b949e; + letter-spacing: 0.02em; +} + +.terminal-btn { + padding: 6px 12px; + font-size: 0.8125rem; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; +} + +.terminal-btn:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); + border-color: #8b949e; +} + +.terminal-panes { + position: relative; + min-height: 400px; +} + +.terminal-pane { + display: none; + min-height: 400px; +} + +.terminal-pane.active { + display: block; +} + +.terminal-container { + min-height: 400px; + padding: 8px; + box-sizing: border-box; +} + +.terminal-container .xterm { + padding: 0; +} + +.terminal-container .xterm-viewport { + border-radius: 0; +} + +.terminal-error { + color: #ff7b72; + padding: 16px; + font-size: 0.875rem; +} + .settings-section { margin-bottom: 32px; } diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 75ac4832..35011dc5 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -46,6 +46,9 @@ function switchSettingsSection(section) { if (activeContent) { activeContent.classList.add('active'); } + if (section === 'terminal' && typeof initTerminal === 'function') { + setTimeout(initTerminal, 0); + } } // 打开设置 diff --git a/web/static/js/terminal.js b/web/static/js/terminal.js new file mode 100644 index 00000000..ee55a66a --- /dev/null +++ b/web/static/js/terminal.js @@ -0,0 +1,525 @@ +/** + * 系统设置 - 终端:多标签、流式输出、命令历史、Ctrl+L 清屏、长时间可取消 + */ +(function () { + var getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, attrs) { + if (type === '2d') { + attrs = (attrs && typeof attrs === 'object') ? Object.assign({ willReadFrequently: true }, attrs) : { willReadFrequently: true }; + return getContext.call(this, type, attrs); + } + return getContext.apply(this, arguments); + }; + + var terminals = []; + var currentTabId = 1; + var inited = false; + var tabIdCounter = 1; + var PROMPT = '\x1b[32m$\x1b[0m '; + var HISTORY_MAX = 100; + var CANCEL_AFTER_MS = 125000; + + function getCurrent() { + for (var i = 0; i < terminals.length; i++) { + if (terminals[i].id === currentTabId) return terminals[i]; + } + return terminals[0] || null; + } + + var WELCOME_LINE = 'CyberStrikeAI 终端 - 直接输入命令,Enter 执行;↑↓ 历史;Ctrl+L 清屏\r\n'; + + function writePrompt(tab) { + var t = tab || getCurrent(); + if (t && t.term) t.term.write(PROMPT); + } + + function redrawTabDisplay(t) { + if (!t || !t.term) return; + t.term.clear(); + t.lineBuffer = ''; + if (t.cursorIndex !== undefined) t.cursorIndex = 0; + t.term.write(WELCOME_LINE); + t.term.write(PROMPT); + } + + function writeln(tabOrS, s) { + var t, text; + if (arguments.length === 1) { text = tabOrS; t = getCurrent(); } else { t = tabOrS; text = s; } + if (!t || !t.term) return; + if (text) t.term.writeln(text); + else t.term.writeln(''); + } + + function writeOutput(tab, text, isError) { + var t = tab || getCurrent(); + if (!t || !t.term || !text) return; + var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + var lines = s.split('\n'); + var prefix = isError ? '\x1b[31m' : ''; + var suffix = isError ? '\x1b[0m' : ''; + t.term.write(prefix); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].replace(/\r/g, ''); + t.term.writeln(line); + } + t.term.write(suffix); + } + + function getAuthHeaders() { + var h = new Headers(); + h.set('Content-Type', 'application/json'); + try { + var auth = localStorage.getItem('cyberstrike-auth'); + if (auth) { + var o = JSON.parse(auth); + if (o && o.token) h.set('Authorization', 'Bearer ' + o.token); + } + } catch (e) {} + return h; + } + + function runCommand(cmd, tab) { + var t = tab || getCurrent(); + if (!t) return; + if (t.running) return; + runCommandImpl(cmd, t); + } + + function runCommandImpl(cmd, t) { + t.running = true; + t.abortController = new AbortController(); + var cancelTimer = setTimeout(function () { + if (!t.running) return; + t.running = false; + writeln(t, '\x1b[2m(已取消 可继续输入)\x1b[0m'); + writePrompt(t); + }, CANCEL_AFTER_MS); + + var done = function () { + clearTimeout(cancelTimer); + t.running = false; + t.abortController = null; + writePrompt(t); + }; + + fetch('/api/terminal/run/stream', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ command: cmd }), + signal: t.abortController.signal + }).then(function (res) { + if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'HTTP ' + res.status); }); + var ct = res.headers.get('Content-Type') || ''; + if (ct.indexOf('text/event-stream') !== -1 && res.body) { + return readSSEStream(res.body, t).then(done).catch(function () { done(); }); + } + return res.json().then(function (data) { + if (data.stdout) writeOutput(t, data.stdout, false); + if (data.stderr) writeOutput(t, data.stderr, true); + done(); + }); + }).catch(function (err) { + if (err.name === 'AbortError') { + writeln(t, '\x1b[2m(已取消)\x1b[0m'); + } else { + writeln(t, '\x1b[31m错误: ' + (err.message || String(err)) + '\x1b[0m'); + } + done(); + }); + } + + function readSSEStream(body, t) { + return new Promise(function (resolve, reject) { + var reader = body.getReader(); + var decoder = new TextDecoder(); + var buf = ''; + function read() { + reader.read().then(function (result) { + if (result.done) { resolve(); return; } + buf += decoder.decode(result.value, { stream: true }); + var i; + while ((i = buf.indexOf('\n\n')) !== -1) { + var block = buf.slice(0, i); + buf = buf.slice(i + 2); + var dataLine = block.match(/data:\s*(.+)/); + if (dataLine) { + try { + var ev = JSON.parse(dataLine[1]); + if (ev.t === 'out' && ev.d !== undefined) t.term.writeln(ev.d); + else if (ev.t === 'err' && ev.d !== undefined) t.term.write('\x1b[31m' + ev.d + '\x1b[0m\n'); + else if (ev.t === 'exit') { + resolve(); + return; + } + } catch (e) {} + } + } + read(); + }).catch(reject); + } + read(); + }); + } + + function createTerminalInContainer(container, tab) { + if (typeof Terminal === 'undefined') return null; + if (!tab.history) tab.history = []; + if (tab.historyIndex === undefined) tab.historyIndex = -1; + if (tab.cursorIndex === undefined) tab.cursorIndex = 0; + + var term = new Terminal({ + cursorBlink: true, + cursorStyle: 'bar', + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + lineHeight: 1.2, + scrollback: 1000, + theme: { + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + cursorAccent: '#0d1117', + selection: 'rgba(88, 166, 255, 0.3)', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#e6edf3', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc' + } + }); + var fitAddon = null; + if (typeof FitAddon !== 'undefined') { + var FitCtor = (FitAddon.FitAddon || FitAddon); + fitAddon = new FitCtor(); + term.loadAddon(fitAddon); + } + term.open(container); + term.write(WELCOME_LINE); + term.write(PROMPT); + container.addEventListener('click', function () { + switchTerminalTab(tab.id); + if (term) term.focus(); + }); + container.setAttribute('tabindex', '0'); + container.title = '点击此处后输入命令'; + + function redrawLine(t) { + if (!t || !t.term) return; + var n = t.lineBuffer.length - t.cursorIndex; + t.term.write('\r\x1b[K' + PROMPT + t.lineBuffer); + if (n > 0) t.term.write('\x1b[' + n + 'D'); + } + + term.onData(function (data) { + if (data === '\x0c') { + term.clear(); + tab.lineBuffer = ''; + tab.cursorIndex = 0; + writePrompt(tab); + return; + } + if (data === '\x1b[A') { + if (tab.history.length === 0) return; + if (tab.historyIndex < 0) tab.historyIndex = tab.history.length; + tab.historyIndex--; + if (tab.historyIndex < 0) tab.historyIndex = 0; + tab.lineBuffer = tab.history[tab.historyIndex]; + tab.cursorIndex = tab.lineBuffer.length; + term.write('\r\x1b[K' + PROMPT + tab.lineBuffer); + return; + } + if (data === '\x1b[B') { + if (tab.history.length === 0) return; + tab.historyIndex++; + if (tab.historyIndex >= tab.history.length) { + tab.historyIndex = -1; + tab.lineBuffer = ''; + tab.cursorIndex = 0; + term.write('\r\x1b[K' + PROMPT); + } else { + tab.lineBuffer = tab.history[tab.historyIndex]; + tab.cursorIndex = tab.lineBuffer.length; + term.write('\r\x1b[K' + PROMPT + tab.lineBuffer); + } + return; + } + if (data === '\x1b[D') { + if (tab.cursorIndex > 0) { + tab.cursorIndex--; + term.write('\x1b[D'); + } + return; + } + if (data === '\x1b[C') { + if (tab.cursorIndex < tab.lineBuffer.length) { + tab.cursorIndex++; + term.write('\x1b[C'); + } + return; + } + var code = data.charCodeAt(0); + if (code === 13 || code === 10) { + var cmd = tab.lineBuffer.trim(); + tab.lineBuffer = ''; + tab.cursorIndex = 0; + tab.historyIndex = -1; + term.writeln(''); + if (cmd) { + if (tab.history.indexOf(cmd) === -1) { + tab.history.push(cmd); + if (tab.history.length > HISTORY_MAX) tab.history.shift(); + } + runCommand(cmd, tab); + } else { + writePrompt(tab); + } + return; + } + if (code === 127) { + if (tab.cursorIndex > 0) { + tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex - 1) + tab.lineBuffer.slice(tab.cursorIndex); + tab.cursorIndex--; + redrawLine(tab); + } + return; + } + if (code === 3) { + if (tab.running && tab.abortController) { + tab.abortController.abort(); + } + tab.lineBuffer = ''; + tab.cursorIndex = 0; + term.writeln('^C'); + writePrompt(tab); + return; + } + if (data.length === 1 && code >= 32) { + tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex) + data + tab.lineBuffer.slice(tab.cursorIndex); + tab.cursorIndex++; + redrawLine(tab); + return; + } + tab.lineBuffer += data; + tab.cursorIndex = tab.lineBuffer.length; + term.write(data); + }); + + tab.term = term; + tab.fitAddon = fitAddon; + return term; + } + + function switchTerminalTab(id) { + var prevId = currentTabId; + currentTabId = id; + document.querySelectorAll('.terminal-tab').forEach(function (el) { + el.classList.toggle('active', parseInt(el.getAttribute('data-tab-id'), 10) === id); + }); + document.querySelectorAll('.terminal-pane').forEach(function (el) { + var paneId = el.getAttribute('id'); + var match = paneId && paneId.match(/terminal-pane-(\d+)/); + var paneTabId = match ? parseInt(match[1], 10) : 0; + el.classList.toggle('active', paneTabId === id); + }); + var t = getCurrent(); + if (t && t.term) { + if (prevId !== id) { + requestAnimationFrame(function () { + if (currentTabId === id && t.term) t.term.focus(); + }); + } else { + t.term.focus(); + } + } + } + + function addTerminalTab() { + if (typeof Terminal === 'undefined') return; + tabIdCounter += 1; + var id = tabIdCounter; + var paneId = 'terminal-pane-' + id; + var containerId = 'terminal-container-' + id; + var tabsEl = document.querySelector('.terminal-tabs'); + var panesEl = document.querySelector('.terminal-panes'); + if (!tabsEl || !panesEl) return; + + var tabDiv = document.createElement('div'); + tabDiv.className = 'terminal-tab'; + tabDiv.setAttribute('data-tab-id', String(id)); + var label = document.createElement('span'); + label.className = 'terminal-tab-label'; + label.textContent = '终端 ' + id; + label.onclick = function () { switchTerminalTab(id); }; + var closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'terminal-tab-close'; + closeBtn.title = '关闭'; + closeBtn.textContent = '×'; + closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); }; + tabDiv.appendChild(label); + tabDiv.appendChild(closeBtn); + var plusBtn = tabsEl.querySelector('.terminal-tab-new'); + tabsEl.insertBefore(tabDiv, plusBtn); + + var paneDiv = document.createElement('div'); + paneDiv.id = paneId; + paneDiv.className = 'terminal-pane'; + var containerDiv = document.createElement('div'); + containerDiv.id = containerId; + containerDiv.className = 'terminal-container'; + paneDiv.appendChild(containerDiv); + panesEl.appendChild(paneDiv); + + var tab = { id: id, paneId: paneId, containerId: containerId, lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 }; + terminals.push(tab); + createTerminalInContainer(containerDiv, tab); + switchTerminalTab(id); + updateTerminalTabCloseVisibility(); + setTimeout(function () { + try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {} + }, 50); + } + + function updateTerminalTabCloseVisibility() { + var tabsEl = document.querySelector('.terminal-tabs'); + if (!tabsEl) return; + var tabDivs = tabsEl.querySelectorAll('.terminal-tab'); + var showClose = terminals.length > 1; + for (var i = 0; i < tabDivs.length; i++) { + var btn = tabDivs[i].querySelector('.terminal-tab-close'); + if (btn) btn.style.display = showClose ? '' : 'none'; + } + } + + function removeTerminalTab(id) { + if (terminals.length <= 1) return; + var idx = -1; + for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === id) { idx = i; break; } } + if (idx < 0) return; + + var deletingCurrent = (currentTabId === id); + var switchToIndex = deletingCurrent ? (idx > 0 ? idx - 1 : 0) : -1; + + var tab = terminals[idx]; + if (tab.term && tab.term.dispose) tab.term.dispose(); + tab.term = null; + tab.fitAddon = null; + terminals.splice(idx, 1); + + var tabDiv = document.querySelector('.terminal-tab[data-tab-id="' + id + '"]'); + var paneDiv = document.getElementById('terminal-pane-' + id); + if (tabDiv && tabDiv.parentNode) tabDiv.parentNode.removeChild(tabDiv); + if (paneDiv && paneDiv.parentNode) paneDiv.parentNode.removeChild(paneDiv); + + var curIdxBeforeRenumber = -1; + if (!deletingCurrent) { + for (var i = 0; i < terminals.length; i++) { + if (terminals[i].id === currentTabId) { curIdxBeforeRenumber = i; break; } + } + } + + for (var i = 0; i < terminals.length; i++) { + var t = terminals[i]; + t.id = i + 1; + t.paneId = 'terminal-pane-' + (i + 1); + t.containerId = 'terminal-container-' + (i + 1); + } + tabIdCounter = terminals.length; + if (curIdxBeforeRenumber >= 0) currentTabId = terminals[curIdxBeforeRenumber].id; + + var tabsEl = document.querySelector('.terminal-tabs'); + var panesEl = document.querySelector('.terminal-panes'); + if (tabsEl) { + var tabDivs = tabsEl.querySelectorAll('.terminal-tab'); + for (var i = 0; i < tabDivs.length; i++) { + var t = terminals[i]; + tabDivs[i].setAttribute('data-tab-id', String(t.id)); + var lbl = tabDivs[i].querySelector('.terminal-tab-label'); + if (lbl) lbl.textContent = '终端 ' + t.id; + if (lbl) lbl.onclick = (function (tid) { return function () { switchTerminalTab(tid); }; })(t.id); + var cb = tabDivs[i].querySelector('.terminal-tab-close'); + if (cb) cb.onclick = (function (tid) { return function (e) { e.stopPropagation(); removeTerminalTab(tid); }; })(t.id); + } + } + if (panesEl) { + var paneDivs = panesEl.querySelectorAll('.terminal-pane'); + for (var i = 0; i < paneDivs.length; i++) { + var t = terminals[i]; + paneDivs[i].id = t.paneId; + var cont = paneDivs[i].querySelector('.terminal-container'); + if (cont) cont.id = t.containerId; + } + } + + updateTerminalTabCloseVisibility(); + + if (deletingCurrent && terminals.length > 0) { + currentTabId = terminals[switchToIndex].id; + switchTerminalTab(currentTabId); + } + } + + function initTerminal() { + var pane1 = document.getElementById('terminal-pane-1'); + var container1 = document.getElementById('terminal-container-1'); + if (!pane1 || !container1) return; + if (inited) { + var t = getCurrent(); + if (t && t.term) t.term.focus(); + terminals.forEach(function (tab) { try { if (tab.fitAddon) tab.fitAddon.fit(); } catch (e) {} }); + return; + } + inited = true; + + if (typeof Terminal === 'undefined') { + container1.innerHTML = '

未加载 xterm.js,请刷新页面或检查网络。

'; + return; + } + + currentTabId = 1; + var tab = { id: 1, paneId: 'terminal-pane-1', containerId: 'terminal-container-1', lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 }; + terminals.push(tab); + createTerminalInContainer(container1, tab); + + updateTerminalTabCloseVisibility(); + + setTimeout(function () { + try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {} + }, 100); + + var resizeTimer; + window.addEventListener('resize', function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + terminals.forEach(function (t) { try { if (t.fitAddon) t.fitAddon.fit(); } catch (e) {} }); + }, 150); + }); + } + + function terminalClear() { + var t = getCurrent(); + if (!t || !t.term) return; + t.term.clear(); + t.lineBuffer = ''; + if (t.cursorIndex !== undefined) t.cursorIndex = 0; + writePrompt(t); + t.term.focus(); + } + + window.initTerminal = initTerminal; + window.terminalClear = terminalClear; + window.switchTerminalTab = switchTerminalTab; + window.addTerminalTab = addTerminalTab; + window.removeTerminalTab = removeTerminalTab; +})(); diff --git a/web/templates/index.html b/web/templates/index.html index 7cb84ecb..f39c4bf3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -7,6 +7,7 @@ + + +
+
+

终端

+

在服务器上执行命令,便于运维与调试。命令在服务端执行,请勿执行敏感或破坏性操作。

+
+
+
+
终端 1
+ +
+
+
+
+
+
+
+
+
@@ -2144,6 +2167,9 @@ version: 1.0.0
+ + +