chore: sync local updates

This commit is contained in:
2026-05-16 19:03:04 +08:00
parent b86ce4a73a
commit 14cd94c88d
45 changed files with 2983 additions and 4127 deletions

11
.gitignore vendored
View File

@@ -1,8 +1,17 @@
# Node / Frontend
node_modules/
dist/
mengyaconnect-frontend/dist/
.vite/
# Backend Linux binary本地/CI 构建后放入 dist不提交
mengyaconnect-backend/dist/*
!mengyaconnect-backend/dist/.gitkeep
# SQLite 数据库文件(运行时生成,不提交)
mengyaconnect-backend/data/*.db
mengyaconnect-backend/data/*.db-wal
mengyaconnect-backend/data/*.db-shm
# Logs
debug-logs/
*.log

3
build.bat Normal file
View File

@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0mengyaconnect-frontend"
call npm run build

4
build.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/mengyaconnect-frontend"
exec npm run build

4
dev.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
set "ROOT=%~dp0"
start "mengyaconnect-backend" cmd /k "cd /d ""%ROOT%mengyaconnect-backend"" && go run ."
start "mengyaconnect-frontend" cmd /k "cd /d ""%ROOT%mengyaconnect-frontend"" && npm run dev"

8
dev.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
(cd "$ROOT/mengyaconnect-backend" && go run .) &
BE_PID=$!
trap 'kill $BE_PID 2>/dev/null' EXIT INT TERM
cd "$ROOT/mengyaconnect-frontend"
npm run dev

View File

@@ -0,0 +1,5 @@
data/
docker-compose.yml
.git
.gitignore
dist/

View File

@@ -1,33 +1,23 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
RUN apk add --no-cache ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mengyaconnect-backend .
FROM alpine:3.19
WORKDIR /app
RUN apk add --no-cache ca-certificates && \
adduser -D -H appuser
COPY --from=builder /app/mengyaconnect-backend /app/mengyaconnect-backend
ENV DATA_DIR=/app/data
ENV PORT=8080
RUN mkdir -p "$DATA_DIR" && chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
CMD ["/app/mengyaconnect-backend"]
# 与 go.mod 中 go 版本一致modernc.org/sqlite 等依赖会抬高 toolchain 要求)
FROM golang:1.25-alpine AS builder
WORKDIR /app
RUN apk add --no-cache ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
ARG TARGETOS=linux
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /mengyaconnect-backend .
FROM alpine:3.19
WORKDIR /app
RUN apk add --no-cache ca-certificates && \
adduser -D -H appuser
COPY --from=builder /mengyaconnect-backend /app/mengyaconnect-backend
ENV DATA_DIR=/app/data
ENV PORT=8080
RUN chmod +x /app/mengyaconnect-backend && \
mkdir -p "$DATA_DIR" && chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
CMD ["/app/mengyaconnect-backend"]

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-linux.ps1"
exit /b %ERRORLEVEL%

View File

@@ -0,0 +1,12 @@
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
New-Item -ItemType Directory -Force -Path dist | Out-Null
$env:CGO_ENABLED = "0"
$env:GOOS = "linux"
$env:GOARCH = "amd64"
$out = Join-Path $PSScriptRoot "dist\mengyaconnect-backend"
go build -trimpath -ldflags="-s -w" -o $out .
$b = [IO.File]::ReadAllBytes($out)
if ($b.Length -ge 4 -and $b[0] -eq 0x7F -and $b[1] -eq 0x45) { Write-Host "OK: Linux ELF amd64 -> $out" }
elseif ($b[0] -eq 0x4D -and $b[1] -eq 0x5A) { throw "Got Windows PE — check go env" }
else { throw "Unknown output format" }

View File

@@ -1,78 +1,80 @@
package main
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// 数据目录辅助
func dataBasePath() string { return getEnv("DATA_DIR", "data") }
func sshDir() string { return filepath.Join(dataBasePath(), "ssh") }
func cmdFilePath() string { return filepath.Join(dataBasePath(), "command", "command.json") }
func scriptDir() string { return filepath.Join(dataBasePath(), "script") }
// sanitizeName 防止路径穿越攻击
func sanitizeName(name string) (string, error) {
base := filepath.Base(name)
if base == "" || base == "." || base == ".." {
return "", errors.New("invalid name")
}
return base, nil
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func isOriginAllowed(origin string, allowed []string) bool {
if origin == "" {
return true
}
if len(allowed) == 0 {
return true
}
for _, item := range allowed {
if item == "*" || strings.EqualFold(strings.TrimSpace(item), origin) {
return true
}
}
return false
}
func parseListEnv(name string) []string {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func getEnv(key, fallback string) string {
if val := strings.TrimSpace(os.Getenv(key)); val != "" {
return val
}
return fallback
}
package main
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
func dataBasePath() string { return getEnv("DATA_DIR", "data") }
func dbPath() string { return filepath.Join(dataBasePath(), "mengyaconnect.db") }
// accessPassword 返回 Web 访问密码,可通过 ACCESS_PASSWORD 环境变量覆盖
func accessPassword() string { return getEnv("ACCESS_PASSWORD", "shumengya520") }
// apiVersion 根路径 JSON 中的版本号,可由 API_VERSION 覆盖
func apiVersion() string { return getEnv("API_VERSION", "1.0.0-go") }
// validateName 检查名称合法性(防止空/路径穿越等),不再依赖 filepath.Base
func validateName(name string) error {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
return errors.New("invalid name")
}
return nil
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func isOriginAllowed(origin string, allowed []string) bool {
if origin == "" {
return true
}
if len(allowed) == 0 {
return true
}
for _, item := range allowed {
if item == "*" || strings.EqualFold(strings.TrimSpace(item), origin) {
return true
}
}
return false
}
func parseListEnv(name string) []string {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func getEnv(key, fallback string) string {
if val := strings.TrimSpace(os.Getenv(key)); val != "" {
return val
}
return fallback
}

View File

@@ -1,22 +0,0 @@
[
{
"alias": "安全关机",
"command": "shutdown -h now"
},
{
"alias": "定时十分钟后关机",
"command": "shutdown -h 10"
},
{
"alias": "重启",
"command": "reboot"
},
{
"alias": "输出当前目录",
"command": "pwd"
},
{
"alias": "列出当前目录文件",
"command": "ls -al"
}
]

View File

@@ -1,253 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Docker Info Collector - Single Column Edition
# =============================================================================
set -euo pipefail
# =============================================================================
# Colors
# =============================================================================
readonly NC='\033[0m'
readonly GRAY='\033[0;90m'
readonly CYAN='\033[0;36m'
readonly GREEN='\033[1;32m'
readonly YELLOW='\033[1;33m'
readonly WHITE='\033[1;37m'
readonly DIM='\033[2m'
readonly C_HEADER="${CYAN}"
readonly C_SECTION="${YELLOW}"
readonly C_KEY="${WHITE}"
readonly C_VALUE="${CYAN}"
readonly C_OK="${GREEN}"
readonly C_WARN="${YELLOW}"
readonly C_ERR="\033[1;31m"
readonly C_DIM="${GRAY}"
# Separator line (thick line style)
SEP_LINE="${C_DIM}══════════════════════════════════════════════════════════════════════════════${NC}"
# =============================================================================
# Utils
# =============================================================================
has_cmd() { command -v "$1" &>/dev/null; }
print_header() {
local title="$1"
local len=${#title}
local pad=$(( (76 - len) / 2 ))
echo -e "\n${C_HEADER}╔══════════════════════════════════════════════════════════════════════════════╗"
printf "${C_HEADER}║%${pad}s${WHITE} %s %${pad}s║${NC}\n" "" "$title" ""
echo -e "${C_HEADER}╚══════════════════════════════════════════════════════════════════════════════╝${NC}"
}
print_section() {
echo -e "\n${C_SECTION}$1${NC}"
echo -e "$SEP_LINE"
}
print_kv() {
printf " ${C_KEY}%-14s${NC} ${C_VALUE}%s${NC}\n" "$1:" "$2"
}
print_ok() { echo -e " ${C_OK}${NC} $1"; }
print_warn() { echo -e " ${C_WARN}${NC} $1"; }
print_err() { echo -e " ${C_ERR}${NC} $1"; }
# =============================================================================
# System Info
# =============================================================================
collect_server() {
print_header "服务器信息"
# OS Information
print_section "操作系统"
local os_name kernel arch
os_name="$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
kernel="$(uname -r)"
arch="$(uname -m)"
print_kv "系统" "$os_name"
print_kv "内核版本" "$kernel"
print_kv "系统架构" "$arch"
# Host Info
print_section "主机信息"
local hostname uptime_str
hostname="$(hostname)"
uptime_str="$(uptime -p 2>/dev/null | sed 's/up //' || uptime | sed 's/.*up //; s/,.*//')"
print_kv "主机名" "$hostname"
print_kv "运行时长" "$uptime_str"
print_kv "当前时间" "$(date '+%Y-%m-%d %H:%M:%S')"
# CPU
if [[ -f /proc/cpuinfo ]]; then
print_section "处理器"
local cpus cpu_model
cpus=$(grep -c '^processor' /proc/cpuinfo)
cpu_model=$(grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)
print_kv "核心数量" "${cpus}"
print_kv "型号" "$cpu_model"
fi
# Memory
if has_cmd free; then
print_section "内存"
local mem_total mem_used mem_free mem_pct
mem_total=$(free -h | awk '/^Mem:/ {print $2}')
mem_used=$(free -h | awk '/^Mem:/ {print $3}')
mem_free=$(free -h | awk '/^Mem:/ {print $7}')
mem_pct=$(free | awk '/^Mem:/ {printf "%.1f", $3/$2 * 100}')
print_kv "总容量" "$mem_total"
print_kv "已使用" "$mem_used (${mem_pct}%)"
print_kv "可用" "$mem_free"
fi
# Disk
if has_cmd df; then
print_section "磁盘使用"
df -h --output=source,fstype,size,used,pcent,target 2>/dev/null | tail -n +2 | while read -r fs type size used pct target; do
[[ "$fs" == "tmpfs" || "$fs" == "devtmpfs" || "$fs" == "overlay" ]] && continue
[[ -z "$fs" ]] && continue
# Escape % for printf
local pct_clean="${pct%%%}"
printf " ${C_DIM}[${NC}${C_VALUE}%-12s${NC}${C_DIM}]${NC} ${C_KEY}%-8s${NC} ${C_VALUE}%-7s${NC} ${C_DIM}used:${NC} ${C_VALUE}%-7s${NC} ${C_DIM}(%s%%)${NC}\n" \
"$target" "$type" "$size" "$used" "$pct_clean"
done
fi
# Network
if has_cmd ip; then
print_section "网络接口"
ip -o addr show 2>/dev/null | awk '{print $2, $4}' | while read -r iface addr; do
[[ "$iface" == "lo" ]] && continue
print_kv "$iface" "$addr"
done
fi
}
# =============================================================================
# Docker Info
# =============================================================================
collect_docker() {
print_header "Docker 信息"
if ! has_cmd docker; then
print_err "Docker 未安装"
return 1
fi
# Version
print_section "版本信息"
local client_ver server_ver
client_ver=$(docker version --format '{{.Client.Version}}' 2>/dev/null || echo "N/A")
server_ver=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "N/A")
print_kv "客户端" "$client_ver"
print_kv "服务端" "$server_ver"
if has_cmd docker-compose; then
print_kv "Docker Compose" "$(docker-compose version --short 2>/dev/null)"
fi
# Status
if docker info &>/dev/null; then
print_ok "守护进程运行中"
else
print_warn "守护进程未运行"
return 1
fi
# Stats
print_section "资源统计"
local containers running images networks volumes
containers=$(docker ps -aq 2>/dev/null | wc -l)
running=$(docker ps -q 2>/dev/null | wc -l)
images=$(docker images -q 2>/dev/null | wc -l)
networks=$(docker network ls -q 2>/dev/null | wc -l)
volumes=$(docker volume ls -q 2>/dev/null | wc -l)
print_kv "容器" "${running} 运行 / ${containers} 总计"
print_kv "镜像" "$images"
print_kv "网络" "$networks"
print_kv "存储卷" "$volumes"
# Running containers
if [[ $running -gt 0 ]]; then
print_section "运行中的容器"
docker ps --format "{{.Names}}|{{.Image}}|{{.Status}}" 2>/dev/null | while IFS='|' read -r name image status; do
printf " ${C_OK}${NC} ${C_VALUE}%-20s${NC} ${C_DIM}%-30s${NC} %s\n" "$name" "${image:0:30}" "$status"
done
fi
# All containers
if [[ $containers -gt 0 ]]; then
print_section "所有容器"
docker ps -a --format "{{.Names}}|{{.Image}}|{{.Status}}" 2>/dev/null | head -20 | while IFS='|' read -r name image status; do
local icon="${C_DIM}${NC}"
local color="$C_VALUE"
[[ "$status" == Up* ]] && icon="${C_OK}${NC}" && color="$C_OK"
[[ "$status" == Exited* ]] && color="$C_DIM"
printf " ${icon} ${color}%-20s${NC} ${C_DIM}%-25s${NC} %s\n" "$name" "${image:0:25}" "$status"
done
[[ $containers -gt 20 ]] && echo -e " ${C_DIM}... 还有 $((containers - 20)) 个容器${NC}"
fi
# Images
if [[ $images -gt 0 ]]; then
print_section "镜像列表"
docker images --format "{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}" 2>/dev/null | grep -v "<none>" | head -25 | while IFS='|' read -r repo tag size created; do
printf " ${C_DIM}${NC} ${C_VALUE}%-35s${NC} ${C_DIM}%-10s %s${NC}\n" "${repo}:${tag}" "$size" "$created"
done
[[ $images -gt 25 ]] && echo -e " ${C_DIM}... 还有 $((images - 25)) 个镜像${NC}"
fi
# Networks
if [[ $networks -gt 0 ]]; then
print_section "网络"
docker network ls --format "{{.Name}}|{{.Driver}}|{{.Scope}}" 2>/dev/null | head -15 | while IFS='|' read -r name driver scope; do
printf " ${C_DIM}${NC} ${C_VALUE}%-20s${NC} ${C_DIM}[%s/%s]${NC}\n" "$name" "$driver" "$scope"
done
fi
# Volumes
if [[ $volumes -gt 0 ]]; then
print_section "存储卷"
docker volume ls --format "{{.Name}}|{{.Driver}}" 2>/dev/null | head -20 | while IFS='|' read -r name driver; do
printf " ${C_DIM}${NC} ${C_VALUE}%s${NC} ${C_DIM}(%s)${NC}\n" "$name" "$driver"
done
[[ $volumes -gt 20 ]] && echo -e " ${C_DIM}... 还有 $((volumes - 20)) 个存储卷${NC}"
fi
}
# =============================================================================
# Main
# =============================================================================
main() {
collect_server
collect_docker
echo -e "\n${C_HEADER}╔══════════════════════════════════════════════════════════════════════════════╗"
echo -e "${C_HEADER}${C_OK} ✓ 信息收集完成 ${C_HEADER}${NC}"
echo -e "${C_HEADER}╚══════════════════════════════════════════════════════════════════════════════╝${NC}\n"
}
main "$@"

View File

@@ -1,802 +0,0 @@
#!/bin/bash
# systemctl-info - 详细的systemctl信息查看脚本
# 作者: iFlow CLI
# 日期: 2026-02-13
# 版本: 2.0 (模块化版本)
# ═══════════════════════════════════════════════════════════════════════════════
# 颜色定义
# ═══════════════════════════════════════════════════════════════════════════════
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
WHITE='\033[0;37m'
BRIGHT_RED='\033[1;31m'
BRIGHT_GREEN='\033[1;32m'
BRIGHT_YELLOW='\033[1;33m'
BRIGHT_BLUE='\033[1;34m'
BRIGHT_MAGENTA='\033[1;35m'
BRIGHT_CYAN='\033[1;36m'
BRIGHT_WHITE='\033[1;37m'
ORANGE='\033[38;5;208m'
PINK='\033[38;5;205m'
PURPLE='\033[38;5;141m'
LIME='\033[38;5;154m'
RESET='\033[0m'
# ═══════════════════════════════════════════════════════════════════════════════
# 分割线样式
# ═══════════════════════════════════════════════════════════════════════════════
SEPARATOR="${BRIGHT_CYAN}═══════════════════════════════════════════════════════════════════════════════${RESET}"
THIN_SEPARATOR="${CYAN}──────────────────────────────────────────────────────────────────────────────────────${RESET}"
DASH_SEPARATOR="${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
LINE="${BRIGHT_CYAN}═══════════════════════════════════════════════════════════════════════════════${RESET}"
# ═══════════════════════════════════════════════════════════════════════════════
# 通用工具函数
# ═══════════════════════════════════════════════════════════════════════════════
# 打印标题
print_header() {
echo -e "${BRIGHT_CYAN}[Systemctl Info Viewer v2.0]${RESET}"
echo -e "${BRIGHT_CYAN}模块化 Systemd 信息查看脚本${RESET}"
echo ""
}
# 打印带颜色的小节标题
print_section() {
echo -e "${THIN_SEPARATOR}"
echo -e "${BRIGHT_BLUE}[ $1 ]${RESET}"
echo -e "${THIN_SEPARATOR}"
}
# 打印带图标的信息行
print_info() {
local icon="$1"
local label="$2"
local value="$3"
local color="$4"
echo -e "${icon} ${BRIGHT_WHITE}${label}:${RESET} ${color}${value}${RESET}"
}
# 打印子项
print_subitem() {
local label="$1"
local value="$2"
local color="$3"
echo -e " ${BRIGHT_CYAN}${RESET} ${BRIGHT_WHITE}${label}:${RESET} ${color}${value}${RESET}"
}
# 打印带颜色的列表项
print_list_item() {
local icon="$1"
local name="$2"
local status="$3"
local status_color="$4"
local extra="$5"
printf "${icon} ${BRIGHT_WHITE}%-45s${RESET} ${status_color}%s${RESET}%s\n" "$name" "$status" "$extra"
}
# 获取状态图标和颜色
get_state_icon_color() {
local state="$1"
case "$state" in
active|running|listening)
echo -e "✅|${BRIGHT_GREEN}"
;;
inactive)
echo -e "⭕|${BRIGHT_YELLOW}"
;;
failed)
echo -e "❌|${BRIGHT_RED}"
;;
activating|deactivating)
echo -e "🔄|${BRIGHT_CYAN}"
;;
*)
echo -e "❓|${BRIGHT_WHITE}"
;;
esac
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块1: Systemd 版本信息
# ═══════════════════════════════════════════════════════════════════════════════
module_systemd_version() {
print_section "📋 Systemd 版本信息"
SYSTEMD_VERSION=$(systemctl --version | head -n 1 | awk '{print $2}')
FEATURE_COUNT=$(systemctl --version | grep -c "features")
print_info "🔧" "Systemd 版本" "$SYSTEMD_VERSION" "${BRIGHT_GREEN}"
print_info "✨" "支持功能特性" "$FEATURE_COUNT" "${LIME}"
echo -e "${BRIGHT_CYAN}详细版本信息:${RESET}"
systemctl --version | while IFS= read -r line; do
echo -e " ${CYAN}${line}${RESET}"
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块2: 系统基础信息
# ═══════════════════════════════════════════════════════════════════════════════
module_system_info() {
print_section "🖥️ 系统基础信息"
HOSTNAME=$(hostname)
KERNEL_VERSION=$(uname -r)
OS_ID=$(grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"')
OS_NAME=$(grep '^PRETTY_NAME=' /etc/os-release | cut -d'=' -f2 | tr -d '"')
ARCH=$(uname -m)
UPTIME=$(uptime -p)
print_info "🖥️" "主机名" "$HOSTNAME" "${BRIGHT_YELLOW}"
print_info "🐧" "内核版本" "$KERNEL_VERSION" "${ORANGE}"
print_info "📦" "操作系统ID" "$OS_ID" "${PINK}"
print_info "💻" "系统名称" "$OS_NAME" "${PURPLE}"
print_info "🏗️" "系统架构" "$ARCH" "${BRIGHT_CYAN}"
print_info "⏱️" "系统运行时间" "$UPTIME" "${LIME}"
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块3: Systemd 系统状态
# ═══════════════════════════════════════════════════════════════════════════════
module_systemd_status() {
print_section "⚙️ Systemd 系统状态"
SYSTEM_STATE=$(systemctl is-system-running)
DEFAULT_TARGET=$(systemctl get-default)
INIT_PID=$(systemctl show --property=MainPID --value)
BOOT_TIME=$(systemctl show --property=UserspaceTimestamp --value | cut -d' ' -f1)
case $SYSTEM_STATE in
running)
STATE_COLOR="${BRIGHT_GREEN}"
STATE_ICON="✅"
;;
degraded)
STATE_COLOR="${BRIGHT_YELLOW}"
STATE_ICON="⚠️"
;;
maintenance)
STATE_COLOR="${BRIGHT_RED}"
STATE_ICON="🔧"
;;
*)
STATE_COLOR="${BRIGHT_WHITE}"
STATE_ICON="❓"
;;
esac
print_info "$STATE_ICON" "系统状态" "$SYSTEM_STATE" "$STATE_COLOR"
print_info "🎯" "默认运行级别" "$DEFAULT_TARGET" "${BRIGHT_CYAN}"
print_info "🔄" "Init 进程 PID" "$INIT_PID" "${ORANGE}"
print_info "🚀" "启动时间" "$BOOT_TIME" "${PURPLE}"
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块4: 服务(Service)统计与状态
# ═══════════════════════════════════════════════════════════════════════════════
module_service_stats() {
print_section "🔌 服务(Service)统计与状态"
TOTAL_UNITS=$(systemctl list-unit-files --type=service --no-legend | wc -l)
ENABLED_SERVICES=$(systemctl list-unit-files --type=service --state=enabled --no-legend | wc -l)
DISABLED_SERVICES=$(systemctl list-unit-files --type=service --state=disabled --no-legend | wc -l)
STATIC_SERVICES=$(systemctl list-unit-files --type=service --state=static --no-legend | wc -l)
MASKED_SERVICES=$(systemctl list-unit-files --type=service --state=masked --no-legend | wc -l)
RUNNING_SERVICES=$(systemctl list-units --type=service --state=running --no-legend | wc -l)
FAILED_SERVICES=$(systemctl list-units --type=service --state=failed --no-legend | wc -l)
echo -e "${BRIGHT_CYAN}服务文件统计:${RESET}"
print_subitem "总服务数" "$TOTAL_UNITS" "${BRIGHT_WHITE}"
print_subitem "已启用(enabled)" "$ENABLED_SERVICES" "${BRIGHT_GREEN}"
print_subitem "已禁用(disabled)" "$DISABLED_SERVICES" "${BRIGHT_RED}"
print_subitem "静态服务(static)" "$STATIC_SERVICES" "${BRIGHT_YELLOW}"
print_subitem "已屏蔽(masked)" "$MASKED_SERVICES" "${PURPLE}"
echo -e "${BRIGHT_CYAN}服务运行状态:${RESET}"
print_subitem "运行中" "$RUNNING_SERVICES" "${LIME}"
print_subitem "失败" "$FAILED_SERVICES" "${BRIGHT_RED}"
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块5: 失败的服务详情
# ═══════════════════════════════════════════════════════════════════════════════
module_failed_services() {
FAILED_SERVICES=$(systemctl list-units --type=service --state=failed --no-legend | wc -l)
if [ "$FAILED_SERVICES" -gt 0 ]; then
print_section "❌ 失败的服务详情 (共 $FAILED_SERVICES 个)"
systemctl list-units --type=service --state=failed --no-pager | sed '1,1d' | while IFS= read -r line; do
if [ -n "$line" ]; then
SERVICE_NAME=$(echo "$line" | awk '{print $1}')
LOAD_STATE=$(echo "$line" | awk '{print $2}')
ACTIVE_STATE=$(echo "$line" | awk '{print $3}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
DESCRIPTION=$(echo "$line" | awk '{for(i=5;i<=NF;i++)print $i}' | tr '\n' ' ' | sed 's/ $//')
echo -e "${BRIGHT_RED}${RESET} ${BRIGHT_WHITE}${SERVICE_NAME}${RESET}"
echo -e " ${CYAN}描述:${RESET} ${WHITE}${DESCRIPTION}${RESET}"
echo -e " ${CYAN}状态:${RESET} ${RED}${LOAD_STATE}${RESET}|${RED}${ACTIVE_STATE}${RESET}|${RED}${SUB_STATE}${RESET}"
fi
done
else
print_section "✅ 失败的服务详情"
echo -e "${BRIGHT_GREEN} 没有失败的服务${RESET}"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块6: 已屏蔽(Masked)的服务
# ═══════════════════════════════════════════════════════════════════════════════
module_masked_services() {
MASKED_COUNT=$(systemctl list-unit-files --type=service --state=masked --no-legend | wc -l)
print_section "🚫 已屏蔽(Masked)的服务"
print_subitem "已屏蔽服务数" "$MASKED_COUNT" "${PURPLE}"
if [ "$MASKED_COUNT" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}已屏蔽的服务列表:${RESET}"
systemctl list-unit-files --type=service --state=masked --no-legend | while IFS= read -r line; do
if [ -n "$line" ]; then
echo -e " ${PURPLE}${RESET} ${BRIGHT_WHITE}${line}${RESET}"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块7: 运行中的服务
# ═══════════════════════════════════════════════════════════════════════════════
module_running_services() {
print_section "🟢 运行中的服务 (Top 20)"
RUNNING_COUNT=$(systemctl list-units --type=service --state=running --no-legend | wc -l)
print_subitem "运行中服务总数" "$RUNNING_COUNT" "${LIME}"
echo -e "${BRIGHT_CYAN}运行中的服务列表:${RESET}"
systemctl list-units --type=service --state=running --no-pager --no-legend | head -20 | while IFS= read -r line; do
if [ -n "$line" ]; then
SERVICE_NAME=$(echo "$line" | awk '{print $1}')
LOAD_STATE=$(echo "$line" | awk '{print $2}')
ACTIVE_STATE=$(echo "$line" | awk '{print $3}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
printf " ${BRIGHT_GREEN}${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}%-8s${RESET} ${BRIGHT_GREEN}%-10s${RESET} ${BRIGHT_CYAN}%s${RESET}\n" "$SERVICE_NAME" "$LOAD_STATE" "$ACTIVE_STATE" "$SUB_STATE"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块8: Timer 定时任务
# ═══════════════════════════════════════════════════════════════════════════════
module_timer() {
print_section "⏰ Timer 定时任务"
TOTAL_TIMERS=$(systemctl list-units --type=timer --all --no-legend | wc -l)
ACTIVE_TIMERS=$(systemctl list-units --type=timer --state=active --no-legend | wc -l)
print_subitem "总 Timer 数" "$TOTAL_TIMERS" "${BRIGHT_WHITE}"
print_subitem "活跃 Timer" "$ACTIVE_TIMERS" "${LIME}"
if [ "$ACTIVE_TIMERS" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}活跃的定时任务 (Top 15):${RESET}"
systemctl list-units --type=timer --state=active --no-pager --no-legend | head -15 | while IFS= read -r line; do
if [ -n "$line" ]; then
TIMER_NAME=$(echo "$line" | awk '{print $1}')
NEXT_RUN=$(systemctl show "$TIMER_NAME" --property=NextElapseUSec --value 2>/dev/null)
printf " ${BRIGHT_YELLOW}${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}下次执行:${RESET} ${LIME}%s${RESET}\n" "$TIMER_NAME" "$NEXT_RUN"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块9: Socket 单元
# ═══════════════════════════════════════════════════════════════════════════════
module_socket() {
print_section "🔌 Socket 监听"
TOTAL_SOCKETS=$(systemctl list-units --type=socket --all --no-legend | wc -l)
LISTENING_SOCKETS=$(systemctl list-units --type=socket --state=listening --no-legend | wc -l)
print_subitem "总 Socket 数" "$TOTAL_SOCKETS" "${BRIGHT_WHITE}"
print_subitem "监听中" "$LISTENING_SOCKETS" "${LIME}"
if [ "$LISTENING_SOCKETS" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}正在监听的 Socket (Top 15):${RESET}"
systemctl list-units --type=socket --state=listening --no-pager --no-legend | head -15 | while IFS= read -r line; do
if [ -n "$line" ]; then
SOCKET_NAME=$(echo "$line" | awk '{print $1}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
printf " ${BRIGHT_MAGENTA}🔌${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}%s${RESET}\n" "$SOCKET_NAME" "$SUB_STATE"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块10: Target 目标单元
# ═══════════════════════════════════════════════════════════════════════════════
module_target() {
print_section "🎯 Target 目标单元"
ACTIVE_TARGETS=$(systemctl list-units --type=target --state=active --no-legend | wc -l)
print_subitem "当前激活的 Target" "$ACTIVE_TARGETS" "${LIME}"
echo -e "${BRIGHT_CYAN}重要的 Target 单元:${RESET}"
IMPORTANT_TARGETS=("default.target" "multi-user.target" "graphical.target" "basic.target" "rescue.target" "emergency.target" "network.target" "sysinit.target")
for target in "${IMPORTANT_TARGETS[@]}"; do
TARGET_STATE=$(systemctl is-active "$target" 2>/dev/null)
TARGET_ENABLED=$(systemctl is-enabled "$target" 2>/dev/null)
if [ -n "$TARGET_STATE" ]; then
case $TARGET_STATE in
active) STATE_ICON="✅"; STATE_COLOR="${BRIGHT_GREEN}" ;;
inactive) STATE_ICON="⭕"; STATE_COLOR="${BRIGHT_YELLOW}" ;;
*) STATE_ICON="❓"; STATE_COLOR="${BRIGHT_WHITE}" ;;
esac
printf " ${STATE_ICON} ${BRIGHT_WHITE}%-25s${RESET} ${STATE_COLOR}状态:%-10s${RESET} ${CYAN}启用:%s${RESET}\n" "$target" "$TARGET_STATE" "$TARGET_ENABLED"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块11: Mount 和 Automount 单元
# ═══════════════════════════════════════════════════════════════════════════════
module_mount() {
print_section "📁 挂载点(Mount)信息"
TOTAL_MOUNTS=$(systemctl list-units --type=mount --all --no-legend | wc -l)
ACTIVE_MOUNTS=$(systemctl list-units --type=mount --state=active --no-legend | wc -l)
TOTAL_AUTOMOUNTS=$(systemctl list-units --type=automount --all --no-legend | wc -l)
ACTIVE_AUTOMOUNTS=$(systemctl list-units --type=automount --state=active --no-legend | wc -l)
print_subitem "挂载点总数" "$TOTAL_MOUNTS" "${BRIGHT_WHITE}"
print_subitem "活跃挂载点" "$ACTIVE_MOUNTS" "${LIME}"
print_subitem "自动挂载总数" "$TOTAL_AUTOMOUNTS" "${BRIGHT_WHITE}"
print_subitem "活跃自动挂载" "$ACTIVE_AUTOMOUNTS" "${LIME}"
if [ "$ACTIVE_MOUNTS" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}挂载点详情 (Top 10):${RESET}"
systemctl list-units --type=mount --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do
if [ -n "$line" ]; then
MOUNT_NAME=$(echo "$line" | awk '{print $1}')
MOUNT_POINT=$(systemctl show "$MOUNT_NAME" --property=Where --value 2>/dev/null)
SUB_STATE=$(echo "$line" | awk '{print $4}')
printf " ${BRIGHT_CYAN}📂${RESET} ${BRIGHT_WHITE}%-35s${RESET} ${PURPLE}%s${RESET}\n" "$MOUNT_POINT" "$SUB_STATE"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块12: Path 单元
# ═══════════════════════════════════════════════════════════════════════════════
module_path() {
print_section "📍 Path 路径监控单元"
TOTAL_PATHS=$(systemctl list-units --type=path --all --no-legend | wc -l)
ACTIVE_PATHS=$(systemctl list-units --type=path --state=active --no-legend | wc -l)
print_subitem "总 Path 数" "$TOTAL_PATHS" "${BRIGHT_WHITE}"
print_subitem "活跃 Path" "$ACTIVE_PATHS" "${LIME}"
if [ "$ACTIVE_PATHS" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}活跃的 Path 监控 (Top 10):${RESET}"
systemctl list-units --type=path --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do
if [ -n "$line" ]; then
PATH_NAME=$(echo "$line" | awk '{print $1}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
PATH_PATH=$(systemctl show "$PATH_NAME" --property=PathExists --value 2>/dev/null)
printf " ${BRIGHT_CYAN}📍${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}监控:${RESET} ${LIME}%s${RESET} ${CYAN}状态:%s${RESET}\n" "$PATH_NAME" "$PATH_PATH" "$SUB_STATE"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块13: Device 单元
# ═══════════════════════════════════════════════════════════════════════════════
module_device() {
print_section "🔧 Device 设备单元"
TOTAL_DEVICES=$(systemctl list-units --type=device --all --no-legend | wc -l)
ACTIVE_DEVICES=$(systemctl list-units --type=device --state=active --no-legend | wc -l)
print_subitem "总设备数" "$TOTAL_DEVICES" "${BRIGHT_WHITE}"
print_subitem "活跃设备" "$ACTIVE_DEVICES" "${LIME}"
if [ "$ACTIVE_DEVICES" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}活跃的设备 (Top 10):${RESET}"
systemctl list-units --type=device --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do
if [ -n "$line" ]; then
DEVICE_NAME=$(echo "$line" | awk '{print $1}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
printf " ${BRIGHT_YELLOW}🔧${RESET} ${BRIGHT_WHITE}%-45s${RESET} ${LIME}%s${RESET}\n" "$DEVICE_NAME" "$SUB_STATE"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块14: Scope 和 Slice 单元
# ═══════════════════════════════════════════════════════════════════════════════
module_scope_slice() {
print_section "📊 Scope 和 Slice 资源控制单元"
# Scope 单元
TOTAL_SCOPES=$(systemctl list-units --type=scope --all --no-legend | wc -l)
ACTIVE_SCOPES=$(systemctl list-units --type=scope --state=running --no-legend | wc -l)
print_subitem "Scope 总数" "$TOTAL_SCOPES" "${BRIGHT_WHITE}"
print_subitem "运行中 Scope" "$ACTIVE_SCOPES" "${LIME}"
if [ "$ACTIVE_SCOPES" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}运行中的 Scope (Top 10):${RESET}"
systemctl list-units --type=scope --state=running --no-pager --no-legend | head -10 | while IFS= read -r line; do
if [ -n "$line" ]; then
SCOPE_NAME=$(echo "$line" | awk '{print $1}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
printf " ${BRIGHT_CYAN}📊${RESET} ${BRIGHT_WHITE}%-45s${RESET} ${LIME}%s${RESET}\n" "$SCOPE_NAME" "$SUB_STATE"
fi
done
fi
# Slice 单元
echo ""
TOTAL_SLICES=$(systemctl list-units --type=slice --all --no-legend | wc -l)
ACTIVE_SLICES=$(systemctl list-units --type=slice --state=active --no-legend | wc -l)
print_subitem "Slice 总数" "$TOTAL_SLICES" "${BRIGHT_WHITE}"
print_subitem "活跃 Slice" "$ACTIVE_SLICES" "${LIME}"
if [ "$ACTIVE_SLICES" -gt 0 ]; then
echo -e "${BRIGHT_CYAN}活跃的 Slice:${RESET}"
systemctl list-units --type=slice --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do
if [ -n "$line" ]; then
SLICE_NAME=$(echo "$line" | awk '{print $1}')
SUB_STATE=$(echo "$line" | awk '{print $4}')
MEMORY=$(systemctl show "$SLICE_NAME" --property=MemoryCurrent --value 2>/dev/null)
if [ -n "$MEMORY" ] && [ "$MEMORY" != "[not set]" ]; then
MEMORY_DISPLAY="内存: $(numfmt --to=iec $MEMORY 2>/dev/null || echo $MEMORY)"
else
MEMORY_DISPLAY=""
fi
printf " ${BRIGHT_MAGENTA}📦${RESET} ${BRIGHT_WHITE}%-30s${RESET} ${LIME}%s${RESET} %s\n" "$SLICE_NAME" "$SUB_STATE" "$MEMORY_DISPLAY"
fi
done
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块15: 依赖关系
# ═══════════════════════════════════════════════════════════════════════════════
module_dependencies() {
print_section "🔗 系统依赖关系"
# 显示默认.target的依赖树
DEFAULT_TARGET=$(systemctl get-default)
echo -e "${BRIGHT_CYAN}默认目标 '$DEFAULT_TARGET' 的依赖 (前15个):${RESET}"
systemctl list-dependencies "$DEFAULT_TARGET" --no-pager --no-legend | head -15 | while IFS= read -r line; do
if [ -n "$line" ]; then
UNIT_TYPE=$(echo "$line" | grep -o '\.[a-z]*$' | tr -d '.')
case "$UNIT_TYPE" in
service) ICON="🔌" ;;
target) ICON="🎯" ;;
timer) ICON="⏰" ;;
socket) ICON="🔌" ;;
mount) ICON="📁" ;;
path) ICON="📍" ;;
*) ICON="📄" ;;
esac
printf " ${ICON} ${BRIGHT_WHITE}%s${RESET}\n" "$line"
fi
done
# 显示被依赖最多的服务
echo ""
echo -e "${BRIGHT_CYAN}系统关键.target的依赖数量:${RESET}"
for target in "multi-user.target" "graphical.target" "basic.target" "network.target"; do
DEP_COUNT=$(systemctl list-dependencies "$target" --no-legend 2>/dev/null | wc -l)
if [ -n "$DEP_COUNT" ]; then
print_subitem "$target" "$DEP_COUNT 个依赖" "${BRIGHT_CYAN}"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块16: Systemd 日志信息
# ═══════════════════════════════════════════════════════════════════════════════
module_journal() {
print_section "📝 Systemd Journal 日志摘要"
JOURNAL_SIZE=$(journalctl --disk-usage | grep "Journals use" | awk '{print $3,$4}')
JOURNAL_ENTRIES=$(journalctl --no-pager -n 0 2>/dev/null | wc -l)
print_subitem "日志磁盘占用" "$JOURNAL_SIZE" "${ORANGE}"
print_subitem "日志总条目" "$JOURNAL_ENTRIES" "${BRIGHT_CYAN}"
echo -e "${BRIGHT_CYAN}最近的错误日志 (最近5条):${RESET}"
journalctl -p err -n 5 --no-pager 2>/dev/null | while IFS= read -r line; do
if [ -n "$line" ]; then
echo -e " ${RED}${RESET} ${WHITE}${line}${RESET}"
fi
done
echo -e "${BRIGHT_CYAN}最近的警告日志 (最近3条):${RESET}"
journalctl -p warning -n 3 --no-pager 2>/dev/null | while IFS= read -r line; do
if [ -n "$line" ]; then
echo -e " ${YELLOW}${RESET} ${WHITE}${line}${RESET}"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块17: Systemd 环境变量
# ═══════════════════════════════════════════════════════════════════════════════
module_environment() {
print_section "🔧 Systemd 环境变量"
ENV_COUNT=$(systemctl show-environment 2>/dev/null | wc -l)
print_subitem "环境变量数量" "$ENV_COUNT" "${BRIGHT_CYAN}"
echo -e "${BRIGHT_CYAN}系统环境变量:${RESET}"
systemctl show-environment 2>/dev/null | head -15 | while IFS= read -r line; do
if [ -n "$line" ]; then
KEY=$(echo "$line" | cut -d'=' -f1)
VALUE=$(echo "$line" | cut -d'=' -f2-)
echo -e " ${BRIGHT_YELLOW}${RESET} ${BRIGHT_CYAN}${KEY}${RESET}=${BRIGHT_WHITE}${VALUE}${RESET}"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块18: Cgroup 信息
# ═══════════════════════════════════════════════════════════════════════════════
module_cgroup() {
print_section "🧊 Cgroup 信息"
# 获取 cgroup 版本
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
CGROUP_VERSION="v2 (unified)"
else
CGROUP_VERSION="v1 (legacy)"
fi
print_subitem "Cgroup 版本" "$CGROUP_VERSION" "${BRIGHT_CYAN}"
# 获取控制器信息
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
CONTROLLERS=$(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null | tr ' ' ', ')
else
CONTROLLERS=$(cat /sys/fs/cgroup/devices.list 2>/dev/null | head -1 | cut -d' ' -f1 || echo "N/A")
fi
print_subitem "可用控制器" "$CONTROLLERS" "${LIME}"
# Slice 资源统计
echo -e "${BRIGHT_CYAN}Slice 资源使用 (Top 5):${RESET}"
for slice in $(systemctl list-units --type=slice --state=active --no-legend | awk '{print $1}' | head -5); do
MEM_CURRENT=$(systemctl show "$slice" --property=MemoryCurrent --value 2>/dev/null)
MEM_MAX=$(systemctl show "$slice" --property=MemoryMax --value 2>/dev/null)
CPU_WEIGHT=$(systemctl show "$slice" --property=CPUWeight --value 2>/dev/null)
MEM_DISP=""
if [ -n "$MEM_CURRENT" ] && [ "$MEM_CURRENT" != "[not set]" ]; then
MEM_DISP="内存: $(numfmt --to=iec $MEM_CURRENT 2>/dev/null || echo $MEM_CURRENT)"
fi
if [ -n "$CPU_WEIGHT" ] && [ "$CPU_WEIGHT" != "[not set]" ]; then
MEM_DISP="$MEM_DISP CPU权重: $CPU_WEIGHT"
fi
if [ -n "$MEM_DISP" ]; then
printf " ${BRIGHT_MAGENTA}📦${RESET} ${BRIGHT_WHITE}%-25s${RESET} ${LIME}%s${RESET}\n" "$slice" "$MEM_DISP"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块19: 系统性能信息
# ═══════════════════════════════════════════════════════════════════════════════
module_performance() {
print_section "📊 系统性能信息"
# 获取 CPU 使用率
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)
# 获取内存信息
MEM_INFO=$(free -h | grep "Mem:")
MEM_TOTAL=$(echo "$MEM_INFO" | awk '{print $2}')
MEM_USED=$(echo "$MEM_INFO" | awk '{print $3}')
MEM_FREE=$(echo "$MEM_INFO" | awk '{print $4}')
MEM_PERCENT=$(free | grep "Mem:" | awk '{printf "%.1f", $3/$2*100}')
# 获取启动时间
BOOT_TIME_SEC=$(systemctl show --property=UserspaceTimestampMonotonic --value | cut -d' ' -f1)
BOOT_TIME_SEC=${BOOT_TIME_SEC:-0}
BOOT_TIME_SEC=$((BOOT_TIME_SEC / 1000000))
print_subitem "CPU 使用率" "${CPU_USAGE}%" "${BRIGHT_YELLOW}"
print_subitem "内存总量" "$MEM_TOTAL" "${BRIGHT_CYAN}"
print_subitem "已用内存" "$MEM_USED (${MEM_PERCENT}%)" "${ORANGE}"
print_subitem "可用内存" "$MEM_FREE" "${LIME}"
print_subitem "启动耗时" "${BOOT_TIME_SEC}" "${PURPLE}"
# Swap 信息
SWAP_TOTAL=$(free -h | grep "Swap:" | awk '{print $2}')
SWAP_USED=$(free -h | grep "Swap:" | awk '{print $3}')
SWAP_FREE=$(free -h | grep "Swap:" | awk '{print $4}')
if [ "$SWAP_TOTAL" != "0" ]; then
print_subitem "Swap总量" "$SWAP_TOTAL" "${PINK}"
print_subitem "Swap已用" "$SWAP_USED" "${PINK}"
print_subitem "Swap可用" "$SWAP_FREE" "${PINK}"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块20: 电源管理状态
# ═══════════════════════════════════════════════════════════════════════════════
module_power_management() {
print_section "🔋 电源管理状态"
# 检查 systemd-suspend 服务
if systemctl list-unit-files | grep -q "systemd-suspend.service"; then
SUSPEND_STATE=$(systemctl is-enabled systemd-suspend.service 2>/dev/null || echo "N/A")
print_subitem "Suspend 服务" "$SUSPEND_STATE" "${BRIGHT_CYAN}"
fi
# 检查 systemd-hibernate 服务
if systemctl list-unit-files | grep -q "systemd-hibernate.service"; then
HIBERNATE_STATE=$(systemctl is-enabled systemd-hibernate.service 2>/dev/null || echo "N/A")
print_subitem "Hibernate 服务" "$HIBERNATE_STATE" "${BRIGHT_CYAN}"
fi
# 检查 logind 状态
if systemctl list-unit-files | grep -q "systemd-logind.service"; then
LOGIND_STATE=$(systemctl is-active systemd-logind.service 2>/dev/null || echo "N/A")
print_subitem "Logind 状态" "$LOGIND_STATE" "${LIME}"
fi
# 显示电源相关事件
echo -e "${BRIGHT_CYAN}最近的电源相关日志:${RESET}"
journalctl -u systemd-logind -u upower -n 3 --no-pager 2>/dev/null | while IFS= read -r line; do
if [ -n "$line" ]; then
echo -e " ${CYAN}${RESET} ${WHITE}${line}${RESET}"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块21: 关键服务状态
# ═══════════════════════════════════════════════════════════════════════════════
module_critical_services() {
print_section "🔑 关键系统服务状态"
KEY_SERVICES=(
"sshd.service"
"NetworkManager.service"
"cron.service"
"rsyslog.service"
"dbus.service"
"systemd-logind.service"
"systemd-journald.service"
"systemd-udevd.service"
"polkit.service"
)
for service in "${KEY_SERVICES[@]}"; do
if systemctl list-unit-files 2>/dev/null | grep -q "$service"; then
SERVICE_STATE=$(systemctl is-active "$service" 2>/dev/null)
SERVICE_ENABLED=$(systemctl is-enabled "$service" 2>/dev/null)
case $SERVICE_STATE in
active) STATE_ICON="✅"; STATE_COLOR="${BRIGHT_GREEN}" ;;
inactive) STATE_ICON="⭕"; STATE_COLOR="${BRIGHT_YELLOW}" ;;
failed) STATE_ICON="❌"; STATE_COLOR="${BRIGHT_RED}" ;;
*) STATE_ICON="❓"; STATE_COLOR="${BRIGHT_WHITE}" ;;
esac
printf " ${STATE_ICON} ${BRIGHT_WHITE}%-30s${RESET} ${STATE_COLOR}%-10s${RESET} ${CYAN}启用:%s${RESET}\n" "$service" "$SERVICE_STATE" "$SERVICE_ENABLED"
fi
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# 模块22: 常用命令提示
# ═══════════════════════════════════════════════════════════════════════════════
module_help() {
print_section "💡 常用 Systemctl 命令"
echo -e "${BRIGHT_YELLOW}=== 服务管理 ===${RESET}"
echo -e "${BRIGHT_WHITE}systemctl status <service>${RESET} - 查看服务状态"
echo -e "${BRIGHT_WHITE}systemctl start <service>${RESET} - 启动服务"
echo -e "${BRIGHT_WHITE}systemctl stop <service>${RESET} - 停止服务"
echo -e "${BRIGHT_WHITE}systemctl restart <service>${RESET} - 重启服务"
echo -e "${BRIGHT_WHITE}systemctl enable <service>${RESET} - 启用开机自启"
echo -e "${BRIGHT_WHITE}systemctl disable <service>${RESET} - 禁用开机自启"
echo -e "${BRIGHT_WHITE}systemctl mask <service>${RESET} - 屏蔽服务"
echo -e "${BRIGHT_WHITE}systemctl unmask <service>${RESET} - 取消屏蔽"
echo -e "${BRIGHT_YELLOW}=== 状态查看 ===${RESET}"
echo -e "${BRIGHT_WHITE}systemctl is-active <service>${RESET} - 检查服务是否活跃"
echo -e "${BRIGHT_WHITE}systemctl is-enabled <service>${RESET} - 检查服务是否启用"
echo -e "${BRIGHT_WHITE}systemctl --failed${RESET} - 查看失败的服务"
echo -e "${BRIGHT_WHITE}systemctl list-dependencies <unit>${RESET} - 查看依赖"
echo -e "${BRIGHT_YELLOW}=== 日志查看 ===${RESET}"
echo -e "${BRIGHT_WHITE}journalctl -u <service>${RESET} - 查看服务日志"
echo -e "${BRIGHT_WHITE}journalctl -xe${RESET} - 查看最近日志"
echo -e "${BRIGHT_WHITE}journalctl -p err${RESET} - 查看错误日志"
echo -e "${BRIGHT_YELLOW}=== 电源管理 ===${RESET}"
echo -e "${BRIGHT_WHITE}systemctl suspend${RESET} - 挂起"
echo -e "${BRIGHT_WHITE}systemctl hibernate${RESET} - 休眠"
echo -e "${BRIGHT_WHITE}systemctl reboot${RESET} - 重启"
echo -e "${BRIGHT_WHITE}systemctl poweroff${RESET} - 关机"
}
# ═══════════════════════════════════════════════════════════════════════════════
# 主函数 - 模块调度
# ═══════════════════════════════════════════════════════════════════════════════
main() {
print_header
# 基础信息模块
module_systemd_version
module_system_info
module_systemd_status
# 单元统计模块
module_service_stats
module_running_services
module_failed_services
module_masked_services
# 各类单元模块
module_timer
module_socket
module_target
module_mount
module_path
module_device
module_scope_slice
# 系统信息模块
module_dependencies
module_journal
module_environment
module_cgroup
module_performance
module_power_management
# 服务状态模块
module_critical_services
# 帮助信息
module_help
# 结束
echo -e "${DASH_SEPARATOR}"
echo -e "${BRIGHT_MAGENTA}✨ 信息收集完成!时间: $(date '+%Y-%m-%d %H:%M:%S')${RESET}"
echo -e "${DASH_SEPARATOR}"
}
# 执行主函数
main "@"

186
mengyaconnect-backend/db.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"database/sql"
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
// DB is the global SQLite connection shared by all handlers.
var DB *sql.DB
const schema = `
CREATE TABLE IF NOT EXISTS ssh_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
alias TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL,
password TEXT NOT NULL DEFAULT '',
private_key TEXT NOT NULL DEFAULT '',
passphrase TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alias TEXT NOT NULL,
command TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
// initDB opens the SQLite database, applies PRAGMAs and creates tables.
func initDB() error {
if err := os.MkdirAll(dataBasePath(), 0o750); err != nil {
return err
}
db, err := sql.Open("sqlite", dbPath())
if err != nil {
return err
}
// Single writer; multiple readers are fine via WAL.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`); err != nil {
return err
}
if _, err := db.Exec(schema); err != nil {
return err
}
DB = db
log.Printf("SQLite database ready: %s", dbPath())
return nil
}
// ─── One-time migration from legacy file storage ─────────────────────────────
// migrateFromFiles reads legacy JSON/text files and inserts their data into
// SQLite (INSERT OR IGNORE so duplicates are skipped). After migration each
// source file is renamed to *.migrated so the routine is not repeated.
func migrateFromFiles() {
migrateSSHProfiles()
migrateCommands()
migrateScripts()
}
func migrateSSHProfiles() {
dir := filepath.Join(dataBasePath(), "ssh")
entries, err := os.ReadDir(dir)
if err != nil {
return // directory doesn't exist — nothing to migrate
}
migrated := 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
raw, err := os.ReadFile(path)
if err != nil {
log.Printf("migrate ssh: read %s: %v", path, err)
continue
}
var p SSHProfile
if err := json.Unmarshal(raw, &p); err != nil {
log.Printf("migrate ssh: parse %s: %v", path, err)
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
if p.Port == 0 {
p.Port = 22
}
_, err = DB.Exec(
`INSERT OR IGNORE INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase)
VALUES (?,?,?,?,?,?,?,?)`,
name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase,
)
if err != nil {
log.Printf("migrate ssh: insert %s: %v", name, err)
continue
}
_ = os.Rename(path, path+".migrated")
migrated++
}
if migrated > 0 {
log.Printf("migrate: imported %d SSH profile(s) from %s", migrated, dir)
}
}
func migrateCommands() {
path := filepath.Join(dataBasePath(), "command", "command.json")
raw, err := os.ReadFile(path)
if err != nil {
return
}
var cmds []Command
if err := json.Unmarshal(raw, &cmds); err != nil {
log.Printf("migrate commands: parse %s: %v", path, err)
return
}
// Check if commands table is already populated to avoid duplicating on restart.
var count int
_ = DB.QueryRow(`SELECT COUNT(*) FROM commands`).Scan(&count)
if count > 0 {
log.Printf("migrate commands: table not empty, skipping file migration")
_ = os.Rename(path, path+".migrated")
return
}
for i, cmd := range cmds {
if _, err := DB.Exec(
`INSERT INTO commands (alias, command, sort_order) VALUES (?,?,?)`,
cmd.Alias, cmd.Command, i,
); err != nil {
log.Printf("migrate commands: insert [%d]: %v", i, err)
}
}
_ = os.Rename(path, path+".migrated")
log.Printf("migrate: imported %d command(s) from %s", len(cmds), path)
}
func migrateScripts() {
dir := filepath.Join(dataBasePath(), "script")
entries, err := os.ReadDir(dir)
if err != nil {
return
}
migrated := 0
for _, e := range entries {
if e.IsDir() {
continue
}
path := filepath.Join(dir, e.Name())
content, err := os.ReadFile(path)
if err != nil {
log.Printf("migrate scripts: read %s: %v", path, err)
continue
}
if _, err := DB.Exec(
`INSERT OR IGNORE INTO scripts (name, content) VALUES (?,?)`,
e.Name(), string(content),
); err != nil {
log.Printf("migrate scripts: insert %s: %v", e.Name(), err)
continue
}
_ = os.Rename(path, path+".migrated")
migrated++
}
if migrated > 0 {
log.Printf("migrate: imported %d script(s) from %s", migrated, dir)
}
}

View File

@@ -2,6 +2,7 @@ version: "3.9"
services:
backend:
# 镜像在构建阶段按宿主机架构编译;勿写死 platform: linux/amd64否则 ARM 云主机无 QEMU 时会 exec format error
build:
context: .
dockerfile: Dockerfile

View File

@@ -1,6 +1,6 @@
module mengyaconnect-backend
go 1.21
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
@@ -13,25 +13,33 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)

View File

@@ -9,6 +9,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -28,6 +30,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -45,10 +49,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -76,6 +84,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
@@ -89,5 +99,13 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -2,14 +2,15 @@ package main
import (
"context"
"encoding/json"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -23,12 +24,12 @@ import (
// ─── 持久化数据类型 ───────────────────────────────────────────────
type SSHProfile struct {
Name string `json:"name,omitempty"` // 文件名(不含 .json
Alias string `json:"alias"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
Name string `json:"name,omitempty"`
Alias string `json:"alias"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
}
@@ -43,7 +44,7 @@ type ScriptInfo struct {
Content string `json:"content,omitempty"`
}
// 配置与数据目录辅助函数见 config.go
// 配置与数据辅助函数见 config.go / db.go
type wsMessage struct {
Type string `json:"type"`
@@ -71,7 +72,84 @@ func (w *wsWriter) send(msg wsMessage) {
_ = w.conn.WriteJSON(msg)
}
// ─── 会话令牌(服务启动时随机生成一次)────────────────────────────────
var sessionToken string
func initSessionToken() {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("failed to generate session token: %v", err)
}
sessionToken = hex.EncodeToString(b)
}
// authMiddleware 校验请求中携带的令牌。
// WebSocket 不支持自定义 Header因此同时接受 ?token= 查询参数。
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := ""
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
}
if token == "" {
token = c.Query("token")
}
if token != sessionToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.Next()
}
}
// POST /api/auth/login — 用密码换取令牌
func handleLogin(c *gin.Context) {
var body struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if body.Password != accessPassword() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": sessionToken}})
}
// GET /api/auth/verify — 校验令牌是否有效(受 authMiddleware 保护)
func handleVerify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": true}})
}
// GET / — 服务说明(无需鉴权,便于网关 / 探活)
func handleAPIRoot(c *gin.Context) {
loc := time.FixedZone("CST", 8*3600)
c.JSON(http.StatusOK, gin.H{
"description": "提供 Web SSH 终端与跳板连接SSH 由服务端发起);浏览器访问需先 POST /api/auth/login 换取令牌,后续 API 与 WebSocket 携带 Bearer 或 ?token=",
"endpoints": gin.H{
"health": "/health",
"auth": "/api/auth/login (POST公开), /api/auth/verify (GET需令牌)",
"websocket_ssh": "/api/ws/ssh (GET 升级 WebSocket需令牌查询参数 token= 或 Header)",
"ssh_profiles": "/api/ssh, /api/ssh/:name (CRUD需令牌)",
"commands": "/api/commands, /api/commands/:index (CRUD需令牌)",
"scripts": "/api/scripts, /api/scripts/:name (CRUD需令牌)",
},
"message": "萌芽 SSH MengyaConnect 后端 API 服务运行中",
"timestamp": time.Now().In(loc).Format(time.RFC3339),
"version": apiVersion(),
})
}
func main() {
initSessionToken()
if err := initDB(); err != nil {
log.Fatalf("db init: %v", err)
}
migrateFromFiles()
if mode := os.Getenv("GIN_MODE"); mode != "" {
gin.SetMode(mode)
}
@@ -88,36 +166,43 @@ func main() {
},
}
// ─── 基本配置 CRUD ──────────────────────────────────────────
// ─── 不需要鉴权的路由 ────────────────────────────────────────
router.GET("/", handleAPIRoot)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
router.POST("/api/auth/login", handleLogin)
router.GET("/api/ws/ssh", func(c *gin.Context) {
// ─── 需要鉴权的路由 ──────────────────────────────────────────
auth := router.Group("/api", authMiddleware())
auth.GET("/auth/verify", handleVerify)
auth.GET("/ws/ssh", func(c *gin.Context) {
handleSSHWebSocket(c, upgrader)
})
// ─── SSH 配置 CRUD ──────────────────────────────────────────
router.GET("/api/ssh", handleListSSH)
router.POST("/api/ssh", handleCreateSSH)
router.PUT("/api/ssh/:name", handleUpdateSSH)
router.DELETE("/api/ssh/:name", handleDeleteSSH)
// SSH 配置 CRUD
auth.GET("/ssh", handleListSSH)
auth.POST("/ssh", handleCreateSSH)
auth.PUT("/ssh/:name", handleUpdateSSH)
auth.DELETE("/ssh/:name", handleDeleteSSH)
// ─── 快捷命令 CRUD ─────────────────────────────────────────
router.GET("/api/commands", handleListCommands)
router.POST("/api/commands", handleCreateCommand)
router.PUT("/api/commands/:index", handleUpdateCommand)
router.DELETE("/api/commands/:index", handleDeleteCommand)
// 快捷命令 CRUD
auth.GET("/commands", handleListCommands)
auth.POST("/commands", handleCreateCommand)
auth.PUT("/commands/:index", handleUpdateCommand)
auth.DELETE("/commands/:index", handleDeleteCommand)
// ─── 脚本 CRUD ─────────────────────────────────────────────
router.GET("/api/scripts", handleListScripts)
router.GET("/api/scripts/:name", handleGetScript)
router.POST("/api/scripts", handleCreateScript)
router.PUT("/api/scripts/:name", handleUpdateScript)
router.DELETE("/api/scripts/:name", handleDeleteScript)
// 脚本 CRUD
auth.GET("/scripts", handleListScripts)
auth.GET("/scripts/:name", handleGetScript)
auth.POST("/scripts", handleCreateScript)
auth.PUT("/scripts/:name", handleUpdateScript)
auth.DELETE("/scripts/:name", handleDeleteScript)
addr := getEnv("ADDR", ":"+getEnv("PORT", "8080"))
server := &http.Server{
@@ -127,6 +212,7 @@ func main() {
}
log.Printf("SSH WebSocket server listening on %s", addr)
log.Printf("Access password configured (use ACCESS_PASSWORD env to override)")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
@@ -365,41 +451,33 @@ func streamToWebSocket(ctx context.Context, writer *wsWriter, reader io.Reader)
}
}
// CORS、中间件与环境变量工具函数见 config.go
// ═══════════════════════════════════════════════════════════════════
// SSH 配置 CRUD
// ═══════════════════════════════════════════════════════════════════
// GET /api/ssh — 列出所有 SSH 配置
// GET /api/ssh
func handleListSSH(c *gin.Context) {
entries, err := os.ReadDir(sshDir())
rows, err := DB.Query(
`SELECT name,alias,host,port,username,password,private_key,passphrase
FROM ssh_profiles ORDER BY id`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []SSHProfile{}})
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
var profiles []SSHProfile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := os.ReadFile(filepath.Join(sshDir(), e.Name()))
if err != nil {
continue
}
defer rows.Close()
profiles := []SSHProfile{}
for rows.Next() {
var p SSHProfile
if err := json.Unmarshal(raw, &p); err != nil {
if err := rows.Scan(&p.Name, &p.Alias, &p.Host, &p.Port,
&p.Username, &p.Password, &p.PrivateKey, &p.Passphrase); err != nil {
continue
}
p.Name = strings.TrimSuffix(e.Name(), ".json")
profiles = append(profiles, p)
}
if profiles == nil {
profiles = []SSHProfile{}
}
c.JSON(http.StatusOK, gin.H{"data": profiles})
}
// POST /api/ssh — 新建 SSH 配置
// POST /api/ssh
func handleCreateSSH(c *gin.Context) {
var p SSHProfile
if err := c.ShouldBindJSON(&p); err != nil {
@@ -410,33 +488,39 @@ func handleCreateSSH(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
return
}
name := p.Name
name := strings.TrimSpace(p.Name)
if name == "" {
name = p.Alias
}
safe, err := sanitizeName(strings.ReplaceAll(name, " ", "-"))
if err != nil {
name = strings.ReplaceAll(name, " ", "-")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
p.Name = ""
raw, _ := json.MarshalIndent(p, "", " ")
if err := os.MkdirAll(sshDir(), 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
if p.Port == 0 {
p.Port = 22
}
_, err := DB.Exec(
`INSERT INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase)
VALUES (?,?,?,?,?,?,?,?)`,
name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
}
return
}
if err := os.WriteFile(filepath.Join(sshDir(), safe+".json"), raw, 0o600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
return
}
p.Name = safe
p.Name = name
c.JSON(http.StatusOK, gin.H{"data": p})
}
// PUT /api/ssh/:name — 更新 SSH 配置
// PUT /api/ssh/:name
func handleUpdateSSH(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
@@ -449,34 +533,42 @@ func handleUpdateSSH(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
return
}
p.Name = ""
raw, _ := json.MarshalIndent(p, "", " ")
filePath := filepath.Join(sshDir(), name+".json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
if p.Port == 0 {
p.Port = 22
}
res, err := DB.Exec(
`UPDATE ssh_profiles
SET alias=?,host=?,port=?,username=?,password=?,private_key=?,passphrase=?,
updated_at=CURRENT_TIMESTAMP
WHERE name=?`,
p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, name,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
if err := os.WriteFile(filePath, raw, 0o600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
p.Name = name
c.JSON(http.StatusOK, gin.H{"data": p})
}
// DELETE /api/ssh/:name — 删除 SSH 配置
// DELETE /api/ssh/:name
func handleDeleteSSH(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.Remove(filepath.Join(sshDir(), name+".json")); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
}
res, err := DB.Exec(`DELETE FROM ssh_profiles WHERE name=?`, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
@@ -486,37 +578,49 @@ func handleDeleteSSH(c *gin.Context) {
// 快捷命令 CRUD
// ═══════════════════════════════════════════════════════════════════
func readCommands() ([]Command, error) {
raw, err := os.ReadFile(cmdFilePath())
func queryAllCommands() ([]Command, error) {
rows, err := DB.Query(`SELECT alias,command FROM commands ORDER BY sort_order, id`)
if err != nil {
if os.IsNotExist(err) {
return []Command{}, nil
}
return nil, err
}
var cmds []Command
if err := json.Unmarshal(raw, &cmds); err != nil {
return nil, err
defer rows.Close()
cmds := []Command{}
for rows.Next() {
var cmd Command
if err := rows.Scan(&cmd.Alias, &cmd.Command); err != nil {
return nil, err
}
cmds = append(cmds, cmd)
}
return cmds, nil
}
func writeCommands(cmds []Command) error {
raw, err := json.MarshalIndent(cmds, "", " ")
// commandIDAtIndex returns the primary-key id of the command at 0-based position idx.
func commandIDAtIndex(idx int) (int64, error) {
rows, err := DB.Query(`SELECT id FROM commands ORDER BY sort_order, id`)
if err != nil {
return err
return 0, err
}
if err := os.MkdirAll(filepath.Dir(cmdFilePath()), 0o750); err != nil {
return err
defer rows.Close()
pos := 0
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return 0, err
}
if pos == idx {
return id, nil
}
pos++
}
return os.WriteFile(cmdFilePath(), raw, 0o600)
return 0, sql.ErrNoRows
}
// GET /api/commands
func handleListCommands(c *gin.Context) {
cmds, err := readCommands()
cmds, err := queryAllCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"data": cmds})
@@ -533,16 +637,16 @@ func handleCreateCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
return
}
cmds, err := readCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
cmds = append(cmds, cmd)
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
var maxOrder int
_ = DB.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM commands`).Scan(&maxOrder)
if _, err := DB.Exec(
`INSERT INTO commands (alias,command,sort_order) VALUES (?,?,?)`,
cmd.Alias, cmd.Command, maxOrder+1,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -562,20 +666,16 @@ func handleUpdateCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
return
}
cmds, err := readCommands()
id, err := commandIDAtIndex(idx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
if idx >= len(cmds) {
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
return
}
cmds[idx] = cmd
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
if _, err := DB.Exec(`UPDATE commands SET alias=?,command=? WHERE id=?`, cmd.Alias, cmd.Command, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -586,20 +686,16 @@ func handleDeleteCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"})
return
}
cmds, err := readCommands()
id, err := commandIDAtIndex(idx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
if idx >= len(cmds) {
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
return
}
cmds = append(cmds[:idx], cmds[idx+1:]...)
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
if _, err := DB.Exec(`DELETE FROM commands WHERE id=?`, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -607,45 +703,46 @@ func handleDeleteCommand(c *gin.Context) {
// 脚本 CRUD
// ═══════════════════════════════════════════════════════════════════
// GET /api/scripts — 列出所有脚本名称
// GET /api/scripts
func handleListScripts(c *gin.Context) {
entries, err := os.ReadDir(scriptDir())
rows, err := DB.Query(`SELECT name FROM scripts ORDER BY name`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}})
return
}
var scripts []ScriptInfo
for _, e := range entries {
if !e.IsDir() {
scripts = append(scripts, ScriptInfo{Name: e.Name()})
defer rows.Close()
scripts := []ScriptInfo{}
for rows.Next() {
var s ScriptInfo
if err := rows.Scan(&s.Name); err != nil {
continue
}
}
if scripts == nil {
scripts = []ScriptInfo{}
scripts = append(scripts, s)
}
c.JSON(http.StatusOK, gin.H{"data": scripts})
}
// GET /api/scripts/:name — 获取脚本内容
// GET /api/scripts/:name
func handleGetScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
raw, err := os.ReadFile(filepath.Join(scriptDir(), name))
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read"})
}
var s ScriptInfo
err := DB.QueryRow(`SELECT name,content FROM scripts WHERE name=?`, name).
Scan(&s.Name, &s.Content)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: string(raw)}})
c.JSON(http.StatusOK, gin.H{"data": s})
}
// POST /api/scripts — 新建脚本
// POST /api/scripts
func handleCreateScript(c *gin.Context) {
var s ScriptInfo
if err := c.ShouldBindJSON(&s); err != nil {
@@ -656,59 +753,63 @@ func handleCreateScript(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 为必填项"})
return
}
name, err := sanitizeName(s.Name)
if err != nil {
if err := validateName(s.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.MkdirAll(scriptDir(), 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
_, err := DB.Exec(`INSERT INTO scripts (name,content) VALUES (?,?)`, s.Name, s.Content)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
}
return
}
if err := os.WriteFile(filepath.Join(scriptDir(), name), []byte(s.Content), 0o640); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
c.JSON(http.StatusOK, gin.H{"data": s})
}
// PUT /api/scripts/:name — 更新脚本内容
// PUT /api/scripts/:name
func handleUpdateScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
filePath := filepath.Join(scriptDir(), name)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var s ScriptInfo
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if err := os.WriteFile(filePath, []byte(s.Content), 0o640); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
res, err := DB.Exec(
`UPDATE scripts SET content=?,updated_at=CURRENT_TIMESTAMP WHERE name=?`,
s.Content, name,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
}
// DELETE /api/scripts/:name — 删除脚本
// DELETE /api/scripts/:name
func handleDeleteScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.Remove(filepath.Join(scriptDir(), name)); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
}
res, err := DB.Exec(`DELETE FROM scripts WHERE name=?`, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<title>萌芽SSH</title>
<meta name="description" content="柔和渐变风格的 Web SSH 连接面板,支持多窗口终端。" />
<meta name="theme-color" content="#0f172a" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,47 @@
export function getApiBase() {
const envBase = import.meta.env.VITE_API_BASE;
if (envBase) {
return String(envBase).replace(/\/+$/, "");
}
// 默认走同源 /api更适合反向代理 + HTTPS
if (typeof window === "undefined") return "http://localhost:8080/api";
return `${window.location.origin}/api`;
}
export async function apiRequest(path, options = {}) {
const base = getApiBase();
const res = await fetch(`${base}${path}`, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
let body = null;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
(body && body.error) || `请求失败 (${res.status} ${res.statusText})`;
throw new Error(message);
}
return body;
}
const TOKEN_KEY = "mc_auth_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setToken(t) {
localStorage.setItem(TOKEN_KEY, t);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function getApiBase() {
const envBase = import.meta.env.VITE_API_BASE;
if (envBase) {
return String(envBase).replace(/\/+$/, "");
}
if (typeof window === "undefined") return "http://localhost:8080/api";
return `${window.location.origin}/api`;
}
export async function apiRequest(path, options = {}) {
const base = getApiBase();
const token = getToken();
const res = await fetch(`${base}${path}`, {
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
...options,
});
let body = null;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
(body && body.error) || `请求失败 (${res.status} ${res.statusText})`;
throw new Error(message);
}
return body;
}

View File

@@ -0,0 +1,143 @@
<template>
<header class="app-header">
<div class="brand">
<button class="sidebar-toggle" type="button" @click="toggleSidebar"></button>
<h1>萌芽SSH</h1>
</div>
<nav class="app-nav desktop-only">
<button
v-for="item in navItems"
:key="item.panel"
class="nav-btn"
:class="{ active: activePanel === item.panel }"
@click="openPanel(item.panel)"
>
<span class="nav-dot"></span>{{ item.label }}
</button>
</nav>
<div class="header-right">
<button class="icon-btn focus-toggle desktop-only" type="button" @click="toggleFocus"></button>
</div>
</header>
</template>
<script setup>
import { useUI } from "../composables/useUI";
const { activePanel, openPanel, toggleSidebar, toggleFocus } = useUI();
const navItems = [
{ panel: "connect", label: "新建连接" },
{ panel: "ssh", label: "SSH 配置" },
{ panel: "commands",label: "快捷命令" },
{ panel: "scripts", label: "脚本管理" },
];
</script>
<style scoped>
.app-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-1);
position: sticky;
top: 0;
z-index: 30;
flex-shrink: 0;
}
.brand { display: flex; align-items: center; gap: 8px; }
.sidebar-toggle {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sidebar-toggle:hover { border-color: var(--accent); color: var(--accent); }
.brand h1 {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(120deg, var(--accent) 0%, var(--accent-blue) 100%);
-webkit-background-clip: text;
color: transparent;
margin: 0;
white-space: nowrap;
}
.header-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.app-nav {
display: flex;
gap: 6px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
margin-left: 4px;
}
.app-nav::-webkit-scrollbar { display: none; }
.nav-btn {
border-radius: 6px;
white-space: nowrap;
flex-shrink: 0;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
outline: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.nav-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.nav-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.nav-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
opacity: 0.7;
flex-shrink: 0;
}
.icon-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
@media (max-width: 768px) {
.app-header { height: 40px; padding: 0 10px; gap: 8px; }
.brand h1 { font-size: 15px; }
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="login-overlay">
<div class="login-card">
<div class="login-logo">
<span class="login-icon">🌱</span>
<h1 class="login-title">萌芽 SSH</h1>
<p class="login-sub">MengyaConnect</p>
</div>
<form class="login-form" @submit.prevent="submitLogin">
<label class="login-label">
访问密码
<input
v-model="loginPassword"
type="password"
class="login-input"
placeholder="请输入访问密码"
autofocus
autocomplete="current-password"
/>
</label>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
<button type="submit" class="login-btn" :disabled="loginLoading || !loginPassword">
{{ loginLoading ? "验证中…" : "进入" }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { useAuth } from "../composables/useAuth";
const { loginPassword, loginError, loginLoading, submitLogin } = useAuth();
</script>
<style>
.login-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-1);
z-index: 9999;
padding: 16px;
}
.login-card {
width: 100%;
max-width: 340px;
padding: 2.2rem 1.8rem;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-logo { text-align: center; }
.login-icon { font-size: 2.2rem; }
.login-title {
margin: 0.4rem 0 0.1rem;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-1);
}
.login-sub {
margin: 0;
font-size: 0.78rem;
color: var(--text-3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.login-form { display: flex; flex-direction: column; gap: 1rem; }
.login-label { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.83rem; color: var(--text-2); }
.login-input {
width: 100%;
padding: 0.65rem 0.9rem;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-1);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.login-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.12);
}
.login-error { margin: 0; font-size: 0.8rem; color: #ff7b72; text-align: center; }
.login-btn {
width: 100%;
padding: 0.7rem;
background: #238636;
border: 1px solid rgba(63, 185, 80, 0.5);
border-radius: 6px;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
font-family: inherit;
}
.login-btn:hover:not(:disabled) { background: #2ea043; }
.login-btn:active:not(:disabled) { transform: scale(0.98); }
.login-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="fab-wrap mobile-only">
<div v-show="fabOpen" class="fab-backdrop" @click="fabOpen = false"></div>
<div class="fab-actions" :class="{ open: fabOpen }">
<button class="fab-action" type="button" @click="openPanel('scripts')">
<span class="fab-action-label">脚本管理</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('commands')">
<span class="fab-action-label">快捷命令</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('ssh')">
<span class="fab-action-label">SSH 配置</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('connect')">
<span class="fab-action-label">新建连接</span>
<span class="fab-action-icon"></span>
</button>
</div>
<button class="fab-btn" :class="{ open: fabOpen }" type="button" @click="fabOpen = !fabOpen">
{{ fabOpen ? "" : "" }}
</button>
</div>
</template>
<script setup>
import { useUI } from "../composables/useUI";
const { fabOpen, openPanel } = useUI();
</script>
<style scoped>
.fab-wrap {
display: none;
position: fixed;
bottom: 16px;
right: 16px;
z-index: 90;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.fab-backdrop {
position: fixed;
inset: 0;
z-index: -1;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.fab-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fab-actions.open {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
.fab-action {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: none;
cursor: pointer;
padding: 0;
}
.fab-action-label {
background: var(--bg-2);
color: var(--text-1);
font-size: 13px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--border);
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
font-family: inherit;
}
.fab-action-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-2);
color: var(--accent);
border: 1px solid var(--border-hi);
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.fab-action:active .fab-action-icon { background: var(--accent-dim); }
.fab-btn {
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border-hi);
background: var(--bg-2);
color: var(--accent);
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(63, 185, 80, 0.2);
transition: transform 0.2s ease, background 0.2s ease;
flex-shrink: 0;
}
.fab-btn.open { background: var(--bg-3); transform: rotate(45deg); }
.fab-btn:active { transform: scale(0.94); }
@media (max-width: 768px) {
.fab-wrap { display: flex; }
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="overlay" @click.self="handleClose">
<div class="overlay-card">
<div class="drag-handle mobile-only"></div>
<header class="overlay-header">
<h2>{{ panelTitle }}</h2>
<button class="icon-btn" type="button" @click="handleClose"></button>
</header>
<ConnectPanel v-if="activePanel === 'connect'" />
<SshPanel v-else-if="activePanel === 'ssh'" />
<CommandsPanel v-else-if="activePanel === 'commands'" />
<ScriptsPanel v-else-if="activePanel === 'scripts'" />
</div>
</div>
</template>
<script setup>
import { computed, nextTick } from "vue";
import { useUI } from "../composables/useUI";
import { useSessions } from "../composables/useSessions";
import ConnectPanel from "./panels/ConnectPanel.vue";
import SshPanel from "./panels/SshPanel.vue";
import CommandsPanel from "./panels/CommandsPanel.vue";
import ScriptsPanel from "./panels/ScriptsPanel.vue";
const { activePanel, closeOverlay } = useUI();
const { sessions, activeId } = useSessions();
const titleMap = { connect: "新建连接", ssh: "SSH 配置", commands: "快捷命令", scripts: "脚本管理" };
const panelTitle = computed(() => titleMap[activePanel.value] ?? "");
function handleClose() {
closeOverlay();
nextTick(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
session?.term?.focus();
});
}
</script>
<style scoped>
.overlay {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
background: rgba(0, 0, 0, 0.65);
z-index: 50;
backdrop-filter: blur(4px);
}
.overlay-card {
width: min(900px, 100%);
max-height: 100%;
background: var(--bg-1);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
overflow: hidden;
}
.drag-handle {
display: none;
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--text-3);
margin: 10px auto 0;
flex-shrink: 0;
}
.overlay-header {
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.overlay-header h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text-1); }
.icon-btn {
border: none;
background: transparent;
color: var(--text-2);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.icon-btn:hover { color: var(--text-1); background: var(--bg-3); }
:deep(.overlay-body) {
padding: 12px 14px 16px;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@media (max-width: 768px) {
.overlay { align-items: flex-end; padding: 0; background: rgba(0, 0, 0, 0.7); }
.overlay-card {
border-radius: 16px 16px 0 0;
border-bottom: none;
width: 100%;
max-width: 100%;
max-height: min(88vh, var(--vvh, 88vh));
animation: slideUp 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.drag-handle { display: block; }
:deep(.overlay-body) { padding: 10px 12px 20px; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="quick-keys" @contextmenu.prevent>
<button class="quick-key ctrl-c" @click="send('ctrl_c')" @touchend.prevent="send('ctrl_c')">C-c</button>
<button class="quick-key ctrl-z" @click="send('ctrl_z')" @touchend.prevent="send('ctrl_z')">C-z</button>
<button class="quick-key ctrl-d" @click="send('ctrl_d')" @touchend.prevent="send('ctrl_d')">C-d</button>
<button class="quick-key ctrl-l" @click="send('ctrl_l')" @touchend.prevent="send('ctrl_l')">C-l</button>
<div class="sep"></div>
<button class="quick-key" @click="send('esc')" @touchend.prevent="send('esc')">ESC</button>
<button class="quick-key" :class="{ active: shiftPending }" @click="send('shift')" @touchend.prevent="send('shift')">SHF</button>
<button class="quick-key" :class="{ active: ctrlPending }" @click="send('ctrl')" @touchend.prevent="send('ctrl')">CTL</button>
<button class="quick-key" @click="send('tab')" @touchend.prevent="send('tab')">TAB</button>
<div class="sep"></div>
<button class="quick-key" @click="send('up')" @touchend.prevent="send('up')"></button>
<button class="quick-key" @click="send('down')" @touchend.prevent="send('down')"></button>
<button class="quick-key" @click="send('left')" @touchend.prevent="send('left')"></button>
<button class="quick-key" @click="send('right')" @touchend.prevent="send('right')"></button>
<div class="sep"></div>
<button class="quick-key" @click="send('slash')" @touchend.prevent="send('slash')">/</button>
<button class="quick-key" @click="send('minus')" @touchend.prevent="send('minus')">-</button>
<button class="quick-key" @click="send('dot')" @touchend.prevent="send('dot')">.</button>
<button class="quick-key" @click="send('hash')" @touchend.prevent="send('hash')">#</button>
<button class="quick-key" @click="send('amp')" @touchend.prevent="send('amp')">&amp;</button>
<button class="quick-key enter-key" @click="send('enter')" @touchend.prevent="send('enter')"></button>
</div>
</template>
<script setup>
import { useQuickKeys } from "../composables/useQuickKeys";
const { shiftPending, ctrlPending, sendQuickKey } = useQuickKeys();
const send = sendQuickKey;
</script>
<style scoped>
.quick-keys {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
gap: 3px;
padding: 4px 6px;
background: var(--bg-1);
border-top: 1px solid var(--border);
scrollbar-width: none;
flex-shrink: 0;
}
.quick-keys::-webkit-scrollbar { display: none; }
.sep {
width: 1px;
background: var(--border);
margin: 4px 2px;
flex-shrink: 0;
align-self: stretch;
}
.quick-key {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 40px;
height: 32px;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 12px;
font-family: "JetBrains Mono", ui-monospace, monospace;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
transition: background 0.1s, color 0.1s;
}
.quick-key:hover, .quick-key:active { background: var(--bg-3); color: var(--text-1); }
.quick-key.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.quick-key.ctrl-c { color: var(--danger); border-color: rgba(248, 81, 73, 0.35); }
.quick-key.ctrl-c:hover { background: rgba(248, 81, 73, 0.12); }
.quick-key.ctrl-z { color: #e3b341; border-color: rgba(210, 153, 34, 0.35); }
.quick-key.ctrl-z:hover { background: rgba(210, 153, 34, 0.1); }
.quick-key.ctrl-d { color: var(--accent-blue); border-color: var(--border-blue); }
.quick-key.ctrl-d:hover { background: rgba(88, 166, 255, 0.1); }
.quick-key.enter-key {
min-width: 48px;
border-color: var(--border-blue);
background: rgba(88, 166, 255, 0.08);
color: var(--accent-blue);
}
.quick-key.enter-key:hover { background: rgba(88, 166, 255, 0.18); }
@media (max-width: 768px) {
.quick-keys { padding: 5px 6px; gap: 4px; }
.quick-key { min-width: 44px; height: 38px; font-size: 12px; border-radius: 6px; }
.quick-key.enter-key { min-width: 52px; }
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<aside class="session-sidebar">
<h3 class="sidebar-title">会话</h3>
<button
v-for="session in sessions"
:key="session.id"
class="sidebar-tab"
:class="{ active: session.id === activeId }"
@click="setActive(session.id)"
>
<span class="dot" :class="session.status"></span>
<span class="label">{{ session.title }}</span>
<span class="close" @click.stop="closeSession(session.id)">×</span>
</button>
</aside>
</template>
<script setup>
import { useSessions } from "../composables/useSessions";
const { sessions, activeId, setActive, closeSession } = useSessions();
</script>
<style scoped>
.session-sidebar {
position: absolute;
inset: 0 auto 0 0;
width: 200px;
padding: 8px;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
z-index: 30;
}
.sidebar-title {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
}
.sidebar-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-tab:hover { background: var(--bg-3); color: var(--text-1); }
.sidebar-tab.active { border-color: var(--border-hi); background: var(--accent-dim); color: var(--accent); }
.label { flex: 1; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.dot.ready { background: var(--accent); }
.dot.connecting { background: #e3b341; }
.dot.error { background: var(--danger); }
.dot.closed { background: var(--text-3); }
.close { font-size: 12px; color: var(--text-3); padding: 0 2px; }
.close:hover { color: var(--danger); }
</style>

View File

@@ -0,0 +1,171 @@
<template>
<section class="terminal-panel">
<div class="terminal-container">
<div v-if="sessions.length === 0" class="empty-terminal">
<div class="empty-card">
<div class="empty-icon"></div>
<h3>准备连接</h3>
<p>点击右下角 <strong></strong> 按钮<br />填写主机信息后打开终端</p>
</div>
</div>
<div
v-for="session in sessions"
:key="session.id"
class="terminal-wrapper"
v-show="session.id === activeId"
>
<div class="terminal-header">
<div class="terminal-tabs">
<button
v-for="s in sessions"
:key="s.id"
class="t-tab"
:class="{ active: s.id === activeId }"
@click="setActive(s.id)"
>
<span class="t-tab-dot" :class="s.status"></span>
<span class="t-tab-label">{{ s.title }}</span>
<span class="t-tab-close" @click.stop="closeSession(s.id)">×</span>
</button>
</div>
<span class="status-badge" :class="session.status">{{ statusLabel(session.status) }}</span>
</div>
<div class="terminal-body" :ref="(el) => setTerminalRef(session.id, el)"></div>
</div>
</div>
<QuickKeys v-if="sessions.length && !focusMode" />
</section>
</template>
<script setup>
import { useSessions } from "../composables/useSessions";
import { useUI } from "../composables/useUI";
import QuickKeys from "./QuickKeys.vue";
const { sessions, activeId, setTerminalRef, statusLabel, setActive, closeSession } = useSessions();
const { focusMode } = useUI();
</script>
<style scoped>
.terminal-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
min-height: 0;
padding-bottom: var(--keyboard-inset, 0px);
}
.terminal-container {
flex: 1;
position: relative;
background: var(--bg-0);
overflow: hidden;
min-height: 0;
}
.empty-terminal {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-0);
}
.empty-card {
padding: 28px 32px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-2);
text-align: center;
max-width: 260px;
}
.empty-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; }
.empty-card h3 { margin: 0 0 8px; font-size: 15px; color: var(--text-1); }
.empty-card p { margin: 0; font-size: 13px; color: var(--text-2); line-height: 1.6; }
.empty-card strong { color: var(--accent); }
.terminal-wrapper {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 4px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 30px;
gap: 6px;
}
.terminal-tabs {
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
min-width: 0;
}
.terminal-tabs::-webkit-scrollbar { display: none; }
.t-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 4px 4px 0 0;
border: 1px solid transparent;
border-bottom: none;
background: transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
outline: none;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.12s, color 0.12s;
font-family: inherit;
}
.t-tab:hover { background: var(--bg-2); color: var(--text-1); }
.t-tab.active { background: var(--bg-0); color: var(--text-1); border-color: var(--border); }
.t-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.t-tab-dot.ready { background: var(--accent); }
.t-tab-dot.connecting { background: #e3b341; }
.t-tab-dot.error { background: var(--danger); }
.t-tab-label { max-width: 140px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.t-tab-close { font-size: 11px; color: var(--text-3); padding: 0 1px; }
.t-tab-close:hover { color: var(--danger); }
.status-badge { font-size: 11px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.status-badge.ready { color: var(--accent); }
.status-badge.connecting { color: #e3b341; }
.status-badge.error { color: var(--danger); }
.terminal-body {
flex: 1;
padding: 4px;
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
background: var(--bg-0);
}
@media (max-width: 768px) {
.terminal-panel { border-radius: 0; border-left: none; border-right: none; }
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>快捷命令</h3>
<button class="btn-ghost tiny" @click="loadCommands" :disabled="commandLoading">刷新</button>
</header>
<p v-if="commandError" class="form-error small">{{ commandError }}</p>
<div class="item-list" v-if="commandList.length">
<div class="item-row" v-for="(item, index) in commandList" :key="index">
<div class="item-meta">
<strong>{{ item.alias }}</strong>
<code class="cmd-code">{{ item.command }}</code>
</div>
<div class="item-actions">
<button class="btn-accent tiny" @click="applyCommandToTerminal(item)">发送</button>
<button class="btn-ghost tiny" @click="editCommand(item, index)">编辑</button>
<button class="btn-danger tiny" @click="deleteCommand(index)">删除</button>
</div>
</div>
</div>
<p v-else class="empty-hint">暂无快捷命令可在下方新建</p>
<form class="sub-form" @submit.prevent="saveCommand">
<h4>{{ commandEditingIndex >= 0 ? "编辑快捷命令" : "新建快捷命令" }}</h4>
<div class="form-grid">
<label>
别名
<input v-model.trim="commandForm.alias" placeholder="例如 查看磁盘" />
</label>
<label>
命令
<input v-model.trim="commandForm.command" placeholder="例如 df -h" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary small" type="submit" :disabled="commandSaving">
{{ commandSaving ? "保存中..." : "保存" }}
</button>
<button class="btn-secondary small" type="button" @click="resetCommandForm">重置</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { useCommands } from "../../composables/useCommands";
const {
commandList, commandLoading, commandSaving, commandError,
commandEditingIndex, commandForm,
loadCommands, resetCommandForm, editCommand, saveCommand,
deleteCommand, applyCommandToTerminal,
} = useCommands();
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="overlay-body">
<div class="form-grid">
<label>
主机
<input v-model.trim="form.host" placeholder="例如 192.168.1.10" />
</label>
<label>
端口
<input v-model.number="form.port" type="number" min="1" max="65535" />
</label>
<label>
用户名
<input v-model.trim="form.username" placeholder="root / ubuntu" />
</label>
<label>
认证方式
<select v-model="form.authType">
<option value="password">密码</option>
<option value="key">私钥</option>
</select>
</label>
<label v-if="form.authType === 'password'">
密码
<input v-model="form.password" type="password" placeholder="仅用于本次连接" />
</label>
<label v-else>
私钥
<textarea v-model="form.privateKey" rows="4" placeholder="粘贴 OpenSSH 私钥"></textarea>
</label>
<label v-if="form.authType === 'key'">
私钥口令
<input v-model="form.passphrase" type="password" placeholder="可留空" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary" @click="createSession">连接</button>
<button class="btn-secondary" @click="resetForm">清空</button>
</div>
<p v-if="formError" class="form-error">{{ formError }}</p>
<div class="tips">
<span>{{ wsUrl }}</span>
</div>
</div>
</template>
<script setup>
import { useSessions } from "../../composables/useSessions";
const { form, formError, wsUrl, createSession, resetForm } = useSessions();
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>脚本管理</h3>
<button class="btn-ghost tiny" @click="loadScripts" :disabled="scriptLoading">刷新</button>
</header>
<p v-if="scriptError" class="form-error small">{{ scriptError }}</p>
<div class="script-layout">
<div class="script-list" v-if="scriptList.length">
<button
v-for="item in scriptList"
:key="item.name"
class="script-item"
:class="{ active: item.name === scriptSelected }"
type="button"
@click="selectScript(item.name)"
>
{{ item.name }}
</button>
</div>
<p v-else class="empty-hint">暂无脚本可直接在右侧新建</p>
<div class="script-editor">
<div class="form-grid">
<label>
名称
<input
v-model.trim="scriptForm.name"
placeholder="例如 docker-info.sh"
:disabled="!!scriptSelected"
/>
</label>
</div>
<label class="script-content-label">
内容
<textarea v-model="scriptForm.content" rows="6" placeholder="#!/bin/bash"></textarea>
</label>
<div class="form-actions">
<button class="btn-primary small" type="button" @click="saveScript" :disabled="scriptSaving">
{{ scriptSaving ? "保存中..." : scriptSelected ? "更新" : "新建" }}
</button>
<button v-if="scriptSelected" class="btn-danger small" type="button" @click="deleteScript">删除</button>
<button class="btn-secondary small" type="button" @click="resetScriptForm">重置</button>
<button class="btn-secondary small" type="button" @click="applyScriptToTerminal" :disabled="!scriptForm.content">
发送
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useScripts } from "../../composables/useScripts";
const {
scriptList, scriptLoading, scriptSaving, scriptError, scriptSelected, scriptForm,
loadScripts, selectScript, resetScriptForm, saveScript, deleteScript, applyScriptToTerminal,
} = useScripts();
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>SSH 配置</h3>
<button class="btn-ghost tiny" @click="loadSSH" :disabled="sshLoading">刷新</button>
</header>
<p v-if="sshError" class="form-error small">{{ sshError }}</p>
<div class="item-list" v-if="sshList.length">
<div class="item-row" v-for="item in sshList" :key="item.name">
<div class="item-meta">
<div class="item-title">
<strong>{{ item.alias }}</strong>
<span class="muted">({{ item.name }})</span>
</div>
<div class="item-sub">{{ item.username }}@{{ item.host }}:{{ item.port || 22 }}</div>
</div>
<div class="item-actions">
<button class="btn-accent tiny" @click="connectWithSSH(item)">连接</button>
<button class="btn-ghost tiny" @click="editSSH(item)">编辑</button>
<button class="btn-danger tiny" @click="deleteSSH(item)">删除</button>
</div>
</div>
</div>
<p v-else class="empty-hint">暂无 SSH 配置可在下方新建</p>
<form class="sub-form" @submit.prevent="saveSSH">
<h4>{{ sshEditingName ? "编辑 SSH 配置" : "新建 SSH 配置" }}</h4>
<div class="form-grid">
<label>
标识名 (name)
<input v-model.trim="sshForm.name" :disabled="!!sshEditingName" placeholder="文件名,不含 .json" />
</label>
<label>
别名 (alias)
<input v-model.trim="sshForm.alias" placeholder="例如 生产环境" />
</label>
<label>
主机 (host)
<input v-model.trim="sshForm.host" placeholder="例如 192.168.1.10" />
</label>
<label>
端口
<input v-model.number="sshForm.port" type="number" min="1" max="65535" />
</label>
<label>
用户名
<input v-model.trim="sshForm.username" placeholder="root / ubuntu" />
</label>
<label>
密码 (可选)
<input v-model="sshForm.password" type="password" />
</label>
<label>
私钥 (可选)
<textarea v-model="sshForm.privateKey" rows="4" placeholder="粘贴 OpenSSH 私钥"></textarea>
</label>
<label>
私钥口令 (可选)
<input v-model="sshForm.passphrase" type="password" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary small" type="submit" :disabled="sshSaving">
{{ sshSaving ? "保存中..." : "保存" }}
</button>
<button class="btn-secondary small" type="button" @click="resetSSHForm">重置</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { useSSH } from "../../composables/useSSH";
import { useSessions } from "../../composables/useSessions";
const {
sshList, sshLoading, sshSaving, sshError, sshEditingName, sshForm,
loadSSH, resetSSHForm, editSSH, saveSSH, deleteSSH,
} = useSSH();
const { connectWithSSH } = useSessions();
</script>

View File

@@ -0,0 +1,49 @@
import { ref } from "vue";
import { apiRequest, setToken, clearToken, getToken } from "../api";
const isAuthenticated = ref(false);
const loginPassword = ref("");
const loginError = ref("");
const loginLoading = ref(false);
async function submitLogin() {
if (!loginPassword.value) return;
loginLoading.value = true;
loginError.value = "";
try {
const res = await apiRequest("/auth/login", {
method: "POST",
body: JSON.stringify({ password: loginPassword.value }),
});
setToken(res.data.token);
isAuthenticated.value = true;
loginPassword.value = "";
} catch (e) {
loginError.value = e.message || "密码错误,请重试";
} finally {
loginLoading.value = false;
}
}
async function checkAuth() {
const storedToken = getToken();
if (!storedToken) return;
try {
await apiRequest("/auth/verify");
isAuthenticated.value = true;
} catch {
clearToken();
isAuthenticated.value = false;
}
}
export function useAuth() {
return {
isAuthenticated,
loginPassword,
loginError,
loginLoading,
submitLogin,
checkAuth,
};
}

View File

@@ -0,0 +1,85 @@
import { useSessions } from "./useSessions";
import { useNotice } from "./useNotice";
function bytesToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
function chunkBase64(text, size = 76) {
const chunks = [];
for (let i = 0; i < text.length; i += size) chunks.push(text.slice(i, i + size));
return chunks.join("\n");
}
function safeFilename(name) { return name.replace(/[^a-zA-Z0-9._-]/g, "-"); }
function formatTimestamp(date = new Date()) {
const pad = (v) => String(v).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function isEditableTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = target.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return !!target.isContentEditable;
}
function sendImageToTerminal(file) {
const { sessions, activeId } = useSessions();
const { showNotice } = useNotice();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) {
showNotice("已识别剪贴板图片,但当前没有可用终端连接", "warning");
return;
}
const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) { showNotice("图片过大,已取消粘贴(限制 2MB", "warning"); return; }
const ext = (file.type || "image/png").split("/")[1] || "png";
const filename = safeFilename(`clipboard-${formatTimestamp()}.${ext}`);
const reader = new FileReader();
reader.onload = () => {
try {
const base64 = bytesToBase64(reader.result);
const marker = `__MENGYA_CLIP_${Date.now()}__`;
const script = `cat <<'${marker}' | base64 -d > ${filename}\n${chunkBase64(base64)}\n${marker}\n`;
session.ws.send(JSON.stringify({ type: "input", data: script }));
session.term?.writeln(`\r\n\x1b[90m[已接收剪贴板图片,保存为 ${filename}]\x1b[0m`);
showNotice(`已发送图片到终端:${filename}`, "success");
} catch {
showNotice("图片粘贴失败,请重试", "error");
}
};
reader.onerror = () => showNotice("读取剪贴板图片失败", "error");
reader.readAsArrayBuffer(file);
}
function handlePaste(event) {
if (!event?.clipboardData) return;
if (isEditableTarget(event.target)) return;
const text = event.clipboardData.getData("text/plain");
if (text) return;
const items = Array.from(event.clipboardData.items || []);
const imageItem = items.find((item) => item.type?.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (!file) return;
event.preventDefault();
event.stopPropagation();
sendImageToTerminal(file);
}
function setupClipboard() { document.addEventListener("paste", handlePaste); }
function teardownClipboard() { document.removeEventListener("paste", handlePaste); }
export function useClipboard() {
return { setupClipboard, teardownClipboard };
}

View File

@@ -0,0 +1,95 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
import { useSessions } from "./useSessions";
import { useUI } from "./useUI";
const commandList = ref([]);
const commandLoading = ref(false);
const commandSaving = ref(false);
const commandError = ref("");
const commandEditingIndex = ref(-1);
const commandForm = reactive({ alias: "", command: "" });
async function loadCommands() {
commandError.value = "";
commandLoading.value = true;
try {
const res = await apiRequest("/commands", { method: "GET" });
commandList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
commandError.value = err.message || "加载快捷命令失败";
} finally {
commandLoading.value = false;
}
}
function resetCommandForm() {
commandEditingIndex.value = -1;
commandForm.alias = "";
commandForm.command = "";
}
function editCommand(item, index) {
commandEditingIndex.value = index;
commandForm.alias = item.alias || "";
commandForm.command = item.command || "";
}
async function saveCommand() {
commandError.value = "";
if (!commandForm.alias || !commandForm.command) {
commandError.value = "alias 和 command 为必填项";
return;
}
commandSaving.value = true;
try {
const payload = { alias: commandForm.alias, command: commandForm.command };
if (commandEditingIndex.value >= 0) {
await apiRequest(`/commands/${commandEditingIndex.value}`, {
method: "PUT", body: JSON.stringify(payload),
});
} else {
await apiRequest("/commands", { method: "POST", body: JSON.stringify(payload) });
}
await loadCommands();
resetCommandForm();
} catch (err) {
commandError.value = err.message || "保存快捷命令失败";
} finally {
commandSaving.value = false;
}
}
async function deleteCommand(index) {
commandError.value = "";
try {
await apiRequest(`/commands/${index}`, { method: "DELETE" });
if (commandEditingIndex.value === index) resetCommandForm();
await loadCommands();
} catch (err) {
commandError.value = err.message || "删除快捷命令失败";
}
}
function applyCommandToTerminal(item) {
const { sessions, activeId } = useSessions();
const { closeOverlay } = useUI();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) {
commandError.value = "当前没有可用终端连接";
return;
}
const cmd = item.command || "";
if (!cmd) return;
session.ws.send(JSON.stringify({ type: "input", data: `${cmd}\n` }));
closeOverlay();
}
export function useCommands() {
return {
commandList, commandLoading, commandSaving, commandError,
commandEditingIndex, commandForm,
loadCommands, resetCommandForm, editCommand, saveCommand,
deleteCommand, applyCommandToTerminal,
};
}

View File

@@ -0,0 +1,18 @@
import { reactive } from "vue";
const notice = reactive({ text: "", type: "info" });
let noticeTimer = null;
function showNotice(text, type = "info", duration = 3200) {
notice.text = text;
notice.type = type;
if (noticeTimer) clearTimeout(noticeTimer);
noticeTimer = setTimeout(() => {
notice.text = "";
noticeTimer = null;
}, duration);
}
export function useNotice() {
return { notice, showNotice };
}

View File

@@ -0,0 +1,67 @@
import { ref } from "vue";
import { useSessions } from "./useSessions";
const shiftPending = ref(false);
const ctrlPending = ref(false);
function sendQuickKey(type) {
const { sessions, activeId } = useSessions();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) return;
if (type === "shift") { shiftPending.value = !shiftPending.value; return; }
if (type === "ctrl") { ctrlPending.value = !ctrlPending.value; return; }
let data = "";
switch (type) {
case "ctrl_c": data = "\x03"; break;
case "ctrl_z": data = "\x1a"; break;
case "ctrl_d": data = "\x04"; break;
case "ctrl_l": data = "\x0c"; break;
case "esc": data = "\x1b"; break;
case "tab": data = "\t"; break;
case "slash": data = "/"; break;
case "minus": data = "-"; break;
case "dot": data = "."; break;
case "hash": data = "#"; break;
case "amp": data = "&"; break;
case "up": data = "\x1b[A"; break;
case "down": data = "\x1b[B"; break;
case "right": data = "\x1b[C"; break;
case "left": data = "\x1b[D"; break;
case "enter": data = "\r"; break;
default: return;
}
if (shiftPending.value) {
if (data === "\t") data = "\x1b[Z";
else if (data === "/") data = "?";
else if (data === "-") data = "_";
else if (data === ".") data = ">";
else if (data === "\x1b[A") data = "\x1b[1;2A";
else if (data === "\x1b[B") data = "\x1b[1;2B";
else if (data === "\x1b[C") data = "\x1b[1;2C";
else if (data === "\x1b[D") data = "\x1b[1;2D";
else if (data.length === 1) data = data.toUpperCase();
}
if (ctrlPending.value) {
if (data === "\x1b[A") data = "\x1b[1;5A";
else if (data === "\x1b[B") data = "\x1b[1;5B";
else if (data === "\x1b[C") data = "\x1b[1;5C";
else if (data === "\x1b[D") data = "\x1b[1;5D";
else if (data.length === 1) {
const ch = data.toLowerCase();
if (ch >= "a" && ch <= "z") data = String.fromCharCode(ch.charCodeAt(0) - 96);
else if (ch === "/") data = "\x1f";
}
}
session.ws.send(JSON.stringify({ type: "input", data }));
shiftPending.value = false;
ctrlPending.value = false;
}
export function useQuickKeys() {
return { shiftPending, ctrlPending, sendQuickKey };
}

View File

@@ -0,0 +1,95 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
const sshList = ref([]);
const sshLoading = ref(false);
const sshSaving = ref(false);
const sshError = ref("");
const sshEditingName = ref("");
const sshForm = reactive({
name: "", alias: "", host: "", port: 22,
username: "", password: "", privateKey: "", passphrase: "",
});
async function loadSSH() {
sshError.value = "";
sshLoading.value = true;
try {
const res = await apiRequest("/ssh", { method: "GET" });
sshList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
sshError.value = err.message || "加载 SSH 配置失败";
} finally {
sshLoading.value = false;
}
}
function resetSSHForm() {
sshEditingName.value = "";
Object.assign(sshForm, {
name: "", alias: "", host: "", port: 22,
username: "", password: "", privateKey: "", passphrase: "",
});
}
function editSSH(item) {
sshEditingName.value = item.name || "";
Object.assign(sshForm, {
name: item.name || "", alias: item.alias || "", host: item.host || "",
port: item.port || 22, username: item.username || "", password: item.password || "",
privateKey: item.privateKey || "", passphrase: item.passphrase || "",
});
}
async function saveSSH() {
sshError.value = "";
if (!sshForm.alias || !sshForm.host || !sshForm.username) {
sshError.value = "alias、host 和 username 为必填项";
return;
}
if (!sshEditingName.value && !sshForm.name) {
sshError.value = "新建配置时 name 为必填项";
return;
}
sshSaving.value = true;
try {
const payload = {
alias: sshForm.alias, host: sshForm.host, port: sshForm.port || 22,
username: sshForm.username, password: sshForm.password,
privateKey: sshForm.privateKey, passphrase: sshForm.passphrase,
};
if (sshEditingName.value) {
await apiRequest(`/ssh/${encodeURIComponent(sshEditingName.value)}`, {
method: "PUT", body: JSON.stringify(payload),
});
} else {
payload.name = sshForm.name;
await apiRequest("/ssh", { method: "POST", body: JSON.stringify(payload) });
}
await loadSSH();
if (!sshEditingName.value) resetSSHForm();
} catch (err) {
sshError.value = err.message || "保存 SSH 配置失败";
} finally {
sshSaving.value = false;
}
}
async function deleteSSH(item) {
sshError.value = "";
if (!item.name) return;
try {
await apiRequest(`/ssh/${encodeURIComponent(item.name)}`, { method: "DELETE" });
if (sshEditingName.value === item.name) resetSSHForm();
await loadSSH();
} catch (err) {
sshError.value = err.message || "删除 SSH 配置失败";
}
}
export function useSSH() {
return {
sshList, sshLoading, sshSaving, sshError, sshEditingName, sshForm,
loadSSH, resetSSHForm, editSSH, saveSSH, deleteSSH,
};
}

View File

@@ -0,0 +1,101 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
import { useSessions } from "./useSessions";
import { useUI } from "./useUI";
const scriptList = ref([]);
const scriptLoading = ref(false);
const scriptSaving = ref(false);
const scriptError = ref("");
const scriptSelected = ref("");
const scriptForm = reactive({ name: "", content: "" });
async function loadScripts() {
scriptError.value = "";
scriptLoading.value = true;
try {
const res = await apiRequest("/scripts", { method: "GET" });
scriptList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
scriptError.value = err.message || "加载脚本列表失败";
} finally {
scriptLoading.value = false;
}
}
async function selectScript(name) {
scriptError.value = "";
scriptSelected.value = name;
try {
const res = await apiRequest(`/scripts/${encodeURIComponent(name)}`, { method: "GET" });
scriptForm.name = res.data?.name || name;
scriptForm.content = res.data?.content || "";
} catch (err) {
scriptError.value = err.message || "加载脚本内容失败";
}
}
function resetScriptForm() {
scriptSelected.value = "";
scriptForm.name = "";
scriptForm.content = "";
}
async function saveScript() {
scriptError.value = "";
if (!scriptSelected.value && !scriptForm.name) {
scriptError.value = "新建脚本时 name 为必填项";
return;
}
scriptSaving.value = true;
try {
if (scriptSelected.value) {
await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, {
method: "PUT", body: JSON.stringify({ content: scriptForm.content || "" }),
});
} else {
await apiRequest("/scripts", {
method: "POST",
body: JSON.stringify({ name: scriptForm.name, content: scriptForm.content || "" }),
});
scriptSelected.value = scriptForm.name;
}
await loadScripts();
} catch (err) {
scriptError.value = err.message || "保存脚本失败";
} finally {
scriptSaving.value = false;
}
}
async function deleteScript() {
scriptError.value = "";
if (!scriptSelected.value) return;
try {
await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, { method: "DELETE" });
await loadScripts();
resetScriptForm();
} catch (err) {
scriptError.value = err.message || "删除脚本失败";
}
}
function applyScriptToTerminal() {
const { sessions, activeId } = useSessions();
const { closeOverlay } = useUI();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) {
scriptError.value = "当前没有可用终端连接";
return;
}
if (!scriptForm.content) return;
session.ws.send(JSON.stringify({ type: "input", data: `${scriptForm.content}\n` }));
closeOverlay();
}
export function useScripts() {
return {
scriptList, scriptLoading, scriptSaving, scriptError, scriptSelected, scriptForm,
loadScripts, selectScript, resetScriptForm, saveScript, deleteScript, applyScriptToTerminal,
};
}

View File

@@ -0,0 +1,225 @@
import { computed, markRaw, nextTick, reactive, ref, watch } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { getToken } from "../api";
import { useUI } from "./useUI";
const sessions = ref([]);
const activeId = ref("");
const terminalRefs = new Map();
const form = reactive({
host: "",
port: 22,
username: "",
authType: "password",
password: "",
privateKey: "",
passphrase: "",
});
const formError = ref("");
const wsUrl = computed(() => {
const token = getToken();
const envUrl = import.meta.env.VITE_WS_URL;
if (envUrl) return token ? `${envUrl}?token=${token}` : envUrl;
if (typeof window === "undefined") {
const base = "ws://localhost:8080/api/ws/ssh";
return token ? `${base}?token=${token}` : base;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const base = `${protocol}//${window.location.host}/api/ws/ssh`;
return token ? `${base}?token=${token}` : base;
});
function setTerminalRef(id, el) {
if (el) terminalRefs.set(id, el);
else terminalRefs.delete(id);
}
function statusLabel(status) {
const map = {
ready: "已连接",
connecting: "连接中",
connected: "已建立",
closing: "关闭中",
closed: "已关闭",
error: "错误",
};
return map[status] ?? "准备中";
}
function resetForm() {
Object.assign(form, {
host: "", port: 22, username: "", authType: "password",
password: "", privateKey: "", passphrase: "",
});
formError.value = "";
}
function connectWithSSH(item) {
form.host = item.host || "";
form.port = item.port || 22;
form.username = item.username || "";
if (item.privateKey) {
form.authType = "key";
form.privateKey = item.privateKey;
form.passphrase = item.passphrase || "";
form.password = "";
} else {
form.authType = "password";
form.password = item.password || "";
form.privateKey = "";
form.passphrase = "";
}
createSession();
}
function createSession() {
const { closeOverlay } = useUI();
formError.value = "";
if (!form.host || !form.username) { formError.value = "请填写主机与用户名。"; return; }
if (form.authType === "password" && !form.password) { formError.value = "请选择密码认证时请输入密码。"; return; }
if (form.authType === "key" && !form.privateKey) { formError.value = "请选择私钥认证时请输入私钥。"; return; }
const id = crypto.randomUUID();
const session = {
id,
title: `${form.username}@${form.host}:${form.port}`,
status: "connecting",
ws: null,
term: null,
fit: null,
};
sessions.value.push(session);
activeId.value = id;
const payload = {
type: "connect",
host: form.host, port: form.port, username: form.username,
password: form.authType === "password" ? form.password : "",
privateKey: form.authType === "key" ? form.privateKey : "",
passphrase: form.authType === "key" ? form.passphrase : "",
};
nextTick(() => initSession(session, payload));
closeOverlay();
}
function initSession(session, payload) {
const container = terminalRefs.get(session.id);
if (!container) return;
const term = markRaw(
new Terminal({
fontFamily: `"JetBrains Mono", "Fira Code", ui-monospace, monospace`,
fontSize: 14,
lineHeight: 1.35,
cursorBlink: true,
cursorStyle: "block",
scrollback: 8000,
smoothScrollDuration: 100,
theme: {
background: "#000000",
foreground: "#e6edf3",
cursor: "#3fb950",
cursorAccent: "#000000",
selectionBackground: "rgba(63,185,80,0.25)",
black: "#0d1117", red: "#f85149", green: "#3fb950", yellow: "#d29922",
blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4",
brightBlack: "#484f58", brightRed: "#ff7b72", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd", brightWhite: "#cdd9e5",
},
})
);
const fitAddon = markRaw(new FitAddon());
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
term.focus();
term.writeln("\x1b[90m[正在建立连接...]\x1b[0m");
const ws = markRaw(new WebSocket(wsUrl.value));
session.ws = ws;
session.term = term;
session.fit = fitAddon;
const sendResize = () => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
ws.onopen = () => {
session.status = "connected";
ws.send(JSON.stringify({ ...payload, cols: term.cols, rows: term.rows }));
};
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { term.write(event.data); return; }
if (msg.type === "output" && msg.data) term.write(msg.data);
else if (msg.type === "status") {
session.status = msg.status || session.status;
if (msg.message) term.writeln(`\r\x1b[90m[${msg.message}]\x1b[0m`);
} else if (msg.type === "error") {
session.status = "error";
if (msg.message) term.writeln(`\r\x1b[31m[${msg.message}]\x1b[0m`);
}
};
ws.onerror = () => { session.status = "error"; term.writeln("\r\n\x1b[31m[连接错误]\x1b[0m"); };
ws.onclose = () => {
if (session.status !== "closed") session.status = "closed";
term.writeln("\r\n\x1b[90m[连接已关闭]\x1b[0m");
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data }));
});
term.onResize(() => sendResize());
focusSession(session);
}
function focusSession(session) {
if (!session?.term || !session?.fit) return;
nextTick(() => {
session.fit.fit();
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
}
session.term.scrollToBottom();
session.term.focus();
});
}
function setActive(id) { activeId.value = id; }
function closeSession(id) {
const index = sessions.value.findIndex((s) => s.id === id);
if (index === -1) return;
const session = sessions.value[index];
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: "close" }));
session.ws.close();
}
session.term?.dispose();
sessions.value.splice(index, 1);
if (activeId.value === id) activeId.value = sessions.value[0]?.id || "";
}
function handleWindowResize() {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
}
watch(activeId, (id) => {
const session = sessions.value.find((s) => s.id === id);
if (session) focusSession(session);
});
export function useSessions() {
return {
sessions, activeId, terminalRefs, form, formError, wsUrl,
setTerminalRef, statusLabel, resetForm,
connectWithSSH, createSession, initSession, focusSession,
setActive, closeSession, handleWindowResize,
};
}

View File

@@ -0,0 +1,36 @@
import { ref } from "vue";
const activePanel = ref("");
const showSidebar = ref(false);
const focusMode = ref(false);
const fabOpen = ref(false);
function openPanel(name) {
activePanel.value = name;
fabOpen.value = false;
}
function closeOverlay() {
activePanel.value = "";
}
function toggleSidebar() {
showSidebar.value = !showSidebar.value;
}
function toggleFocus() {
focusMode.value = !focusMode.value;
}
export function useUI() {
return {
activePanel,
showSidebar,
focusMode,
fabOpen,
openPanel,
closeOverlay,
toggleSidebar,
toggleFocus,
};
}

View File

@@ -0,0 +1,61 @@
import { ref } from "vue";
import { useSessions } from "./useSessions";
const appRef = ref(null);
let viewportFitTimer = null;
function updateAppHeight() {
const { sessions, activeId, focusSession } = useSessions();
const vv = window.visualViewport;
const h = vv ? vv.height : window.innerHeight;
const keyboard = vv && typeof vv.height === "number"
? Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
: 0;
if (appRef.value) {
appRef.value.style.height = h + "px";
appRef.value.style.setProperty("--keyboard-inset", keyboard + "px");
appRef.value.style.setProperty("--vvh", h + "px");
}
requestAnimationFrame(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
});
if (viewportFitTimer) clearTimeout(viewportFitTimer);
viewportFitTimer = setTimeout(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
}, 140);
}
function hideSplashScreen() {
if (typeof window === "undefined") return;
const hide = window.__hideSplash;
if (typeof hide === "function") requestAnimationFrame(() => hide());
}
function setupViewport() {
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateAppHeight);
window.visualViewport.addEventListener("scroll", updateAppHeight);
} else {
window.addEventListener("resize", updateAppHeight);
}
updateAppHeight();
}
function teardownViewport() {
if (viewportFitTimer) { clearTimeout(viewportFitTimer); viewportFitTimer = null; }
if (window.visualViewport) {
window.visualViewport.removeEventListener("resize", updateAppHeight);
window.visualViewport.removeEventListener("scroll", updateAppHeight);
} else {
window.removeEventListener("resize", updateAppHeight);
}
}
export function useViewport() {
return { appRef, updateAppHeight, hideSplashScreen, setupViewport, teardownViewport };
}

View File

@@ -1,5 +1,7 @@
import { createApp } from "vue";
import "@xterm/xterm/css/xterm.css";
import "./styles/base.css";
import "./styles/utils.css";
import App from "./App.vue";
import { registerSW } from "virtual:pwa-register";

View File

@@ -0,0 +1,78 @@
/* ─── CSS 变量Termux/Terminus 深黑极简主题) ─────────────────── */
:root {
--bg-0: #000000;
--bg-1: #0d1117;
--bg-2: #161b22;
--bg-3: #21262d;
--border: rgba(48, 54, 61, 0.9);
--border-hi: rgba(63, 185, 80, 0.45);
--border-blue: rgba(88, 166, 255, 0.45);
--accent: #3fb950;
--accent-dim: rgba(63, 185, 80, 0.15);
--accent-blue: #58a6ff;
--danger: #f85149;
--text-1: #e6edf3;
--text-2: #8b949e;
--text-3: #484f58;
color-scheme: dark;
}
/* ─── 全局 reset ──────────────────────────────────────────────── */
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
background: var(--bg-1);
color: var(--text-1);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
}
/* ─── xterm 滚动条主题 ───────────────────────────────────────── */
.xterm-viewport {
overflow-y: auto !important;
overflow-x: hidden !important;
scrollbar-width: thin;
scrollbar-color: rgba(63, 185, 80, 0.4) rgba(0, 0, 0, 0.5);
-ms-overflow-style: auto;
overscroll-behavior: contain;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.xterm-viewport::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.4);
}
.xterm-viewport::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(63, 185, 80, 0.45);
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(63, 185, 80, 0.7);
}

View File

@@ -0,0 +1,196 @@
/* ─── 按钮系统 ──────────────────────────────────────────────────── */
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-accent,
.btn-danger {
border-radius: 6px;
border: 1px solid transparent;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
outline: none;
white-space: nowrap;
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
font-family: inherit;
}
.btn-primary {
background: #238636;
border-color: rgba(63, 185, 80, 0.6);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: #2ea043; }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-accent {
background: var(--accent-dim);
border-color: var(--border-hi);
color: var(--accent);
}
.btn-accent:hover { background: rgba(63, 185, 80, 0.25); }
.btn-secondary {
background: var(--bg-2);
border-color: var(--border);
color: var(--text-1);
}
.btn-secondary:hover { border-color: var(--text-2); }
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text-2);
}
.btn-ghost:hover { border-color: var(--text-2); color: var(--text-1); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: rgba(248, 81, 73, 0.1);
border-color: rgba(248, 81, 73, 0.4);
color: #ff7b72;
}
.btn-danger:hover { background: rgba(248, 81, 73, 0.2); }
.btn-primary.small,
.btn-secondary.small,
.btn-ghost.small,
.btn-accent.small,
.btn-danger.small { padding: 5px 10px; font-size: 12px; }
.tiny { padding: 4px 8px !important; font-size: 11px !important; border-radius: 5px !important; }
/* ─── 表单元素 ───────────────────────────────────────────────────── */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px 12px;
margin-bottom: 12px;
}
label {
font-size: 12px;
color: var(--text-2);
display: flex;
flex-direction: column;
gap: 4px;
}
input,
select,
textarea {
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-1);
padding: 7px 9px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15);
}
textarea { resize: vertical; }
.form-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.form-error {
color: #ff7b72;
font-size: 12px;
margin: 2px 0 4px;
}
.form-error.small { font-size: 11px; }
.tips {
font-size: 11px;
color: var(--text-3);
word-break: break-all;
margin-top: 4px;
}
/* ─── 列表通用样式 ──────────────────────────────────────────────── */
.item-list { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
gap: 8px;
}
.item-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.item-title { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.item-sub { font-size: 12px; color: var(--text-2); }
.muted { font-size: 11px; color: var(--text-3); }
.item-actions { display: flex; gap: 4px; flex-shrink: 0; flex-wrap: wrap; }
.cmd-code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-0);
border: 1px solid var(--border);
color: var(--text-2);
word-break: break-all;
}
.empty-hint { font-size: 12px; color: var(--text-3); margin: 4px 0 12px; }
/* ─── 子面板 ─────────────────────────────────────────────────────── */
.sub-panel { display: flex; flex-direction: column; gap: 8px; }
.sub-panel-header { display: flex; align-items: center; justify-content: space-between; }
.sub-panel-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-1); }
.sub-form h4 { margin: 4px 0 8px; font-size: 13px; color: var(--text-2); }
/* ─── 脚本管理布局 ──────────────────────────────────────────────── */
.script-layout {
display: grid;
grid-template-columns: minmax(110px, 180px) minmax(0, 1fr);
gap: 10px;
}
.script-list { display: flex; flex-direction: column; gap: 4px; }
.script-item {
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
padding: 6px 10px;
text-align: left;
font-size: 12px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
font-family: inherit;
}
.script-item:hover { background: var(--bg-3); color: var(--text-1); }
.script-item.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.script-editor { display: flex; flex-direction: column; gap: 8px; }
.script-content-label textarea { min-height: 140px; font-family: "JetBrains Mono", ui-monospace, monospace; }
/* ─── 移动端响应 ─────────────────────────────────────────────────── */
@media (max-width: 768px) {
.item-row { flex-wrap: wrap; }
.item-actions { width: 100%; justify-content: flex-end; margin-top: 4px; }
.script-layout { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}