chore: sync local updates
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
3
build.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0mengyaconnect-frontend"
|
||||
call npm run build
|
||||
4
build.sh
Normal file
4
build.sh
Normal 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
4
dev.bat
Normal 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
8
dev.sh
Normal 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
|
||||
5
mengyaconnect-backend/.dockerignore
Normal file
5
mengyaconnect-backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
docker-compose.yml
|
||||
.git
|
||||
.gitignore
|
||||
dist/
|
||||
@@ -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"]
|
||||
|
||||
5
mengyaconnect-backend/build-linux.bat
Normal file
5
mengyaconnect-backend/build-linux.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-linux.ps1"
|
||||
exit /b %ERRORLEVEL%
|
||||
12
mengyaconnect-backend/build-linux.ps1
Normal file
12
mengyaconnect-backend/build-linux.ps1
Normal 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" }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[
|
||||
{
|
||||
"alias": "安全关机",
|
||||
"command": "shutdown -h now"
|
||||
},
|
||||
{
|
||||
"alias": "定时十分钟后关机",
|
||||
"command": "shutdown -h 10"
|
||||
},
|
||||
{
|
||||
"alias": "重启",
|
||||
"command": "reboot"
|
||||
},
|
||||
{
|
||||
"alias": "输出当前目录",
|
||||
"command": "pwd"
|
||||
},
|
||||
{
|
||||
"alias": "列出当前目录文件",
|
||||
"command": "ls -al"
|
||||
}
|
||||
]
|
||||
@@ -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 "$@"
|
||||
@@ -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
186
mengyaconnect-backend/db.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
backend:
|
||||
# 镜像在构建阶段按宿主机架构编译;勿写死 platform: linux/amd64,否则 ARM 云主机无 QEMU 时会 exec format error
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
143
mengyaconnect-frontend/src/components/AppHeader.vue
Normal file
143
mengyaconnect-frontend/src/components/AppHeader.vue
Normal 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>
|
||||
114
mengyaconnect-frontend/src/components/LoginOverlay.vue
Normal file
114
mengyaconnect-frontend/src/components/LoginOverlay.vue
Normal 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>
|
||||
129
mengyaconnect-frontend/src/components/MobileFab.vue
Normal file
129
mengyaconnect-frontend/src/components/MobileFab.vue
Normal 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>
|
||||
122
mengyaconnect-frontend/src/components/PanelOverlay.vue
Normal file
122
mengyaconnect-frontend/src/components/PanelOverlay.vue
Normal 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>
|
||||
100
mengyaconnect-frontend/src/components/QuickKeys.vue
Normal file
100
mengyaconnect-frontend/src/components/QuickKeys.vue
Normal 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')">&</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>
|
||||
70
mengyaconnect-frontend/src/components/SessionSidebar.vue
Normal file
70
mengyaconnect-frontend/src/components/SessionSidebar.vue
Normal 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>
|
||||
171
mengyaconnect-frontend/src/components/TerminalPanel.vue
Normal file
171
mengyaconnect-frontend/src/components/TerminalPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
85
mengyaconnect-frontend/src/components/panels/SshPanel.vue
Normal file
85
mengyaconnect-frontend/src/components/panels/SshPanel.vue
Normal 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>
|
||||
49
mengyaconnect-frontend/src/composables/useAuth.js
Normal file
49
mengyaconnect-frontend/src/composables/useAuth.js
Normal 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,
|
||||
};
|
||||
}
|
||||
85
mengyaconnect-frontend/src/composables/useClipboard.js
Normal file
85
mengyaconnect-frontend/src/composables/useClipboard.js
Normal 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 };
|
||||
}
|
||||
95
mengyaconnect-frontend/src/composables/useCommands.js
Normal file
95
mengyaconnect-frontend/src/composables/useCommands.js
Normal 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,
|
||||
};
|
||||
}
|
||||
18
mengyaconnect-frontend/src/composables/useNotice.js
Normal file
18
mengyaconnect-frontend/src/composables/useNotice.js
Normal 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 };
|
||||
}
|
||||
67
mengyaconnect-frontend/src/composables/useQuickKeys.js
Normal file
67
mengyaconnect-frontend/src/composables/useQuickKeys.js
Normal 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 };
|
||||
}
|
||||
95
mengyaconnect-frontend/src/composables/useSSH.js
Normal file
95
mengyaconnect-frontend/src/composables/useSSH.js
Normal 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,
|
||||
};
|
||||
}
|
||||
101
mengyaconnect-frontend/src/composables/useScripts.js
Normal file
101
mengyaconnect-frontend/src/composables/useScripts.js
Normal 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,
|
||||
};
|
||||
}
|
||||
225
mengyaconnect-frontend/src/composables/useSessions.js
Normal file
225
mengyaconnect-frontend/src/composables/useSessions.js
Normal 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,
|
||||
};
|
||||
}
|
||||
36
mengyaconnect-frontend/src/composables/useUI.js
Normal file
36
mengyaconnect-frontend/src/composables/useUI.js
Normal 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,
|
||||
};
|
||||
}
|
||||
61
mengyaconnect-frontend/src/composables/useViewport.js
Normal file
61
mengyaconnect-frontend/src/composables/useViewport.js
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
78
mengyaconnect-frontend/src/styles/base.css
Normal file
78
mengyaconnect-frontend/src/styles/base.css
Normal 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);
|
||||
}
|
||||
196
mengyaconnect-frontend/src/styles/utils.css
Normal file
196
mengyaconnect-frontend/src/styles/utils.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user