9 Commits

Author SHA1 Message Date
6558d1acdb Refactor install command generation and update response structure
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-03-19 20:49:23 +08:00
Flik
8901581d0c Merge pull request #2 from Flikify/codex/build-github-workflow-for-new-changes
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
CI overhaul and major frontend UI refactor (new components, layouts, and release workflow)
2026-03-19 20:22:23 +08:00
Flik
c6bab83e24 Refactor web console UI 2026-03-19 20:21:05 +08:00
Flik
9cd74b43d0 Merge pull request #1 from Flikify/codex/build-github-workflow-for-new-changes
Add GitHub Actions CI and Release workflows
2026-03-19 20:12:13 +08:00
Flik
2d8cc8ebe5 Add GitHub CI and release workflows 2026-03-19 20:08:00 +08:00
58bb324d82 Merge remote-tracking branch 'refs/remotes/github-temp/main' 2026-03-19 19:47:35 +08:00
bed78a36d0 Remove plugin store config from server settings
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m9s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m24s
2026-03-19 19:42:03 +08:00
e4999abf47 Remove manual client ID and TLS CLI options
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 34s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
2026-03-19 19:32:57 +08:00
Flik
6496d56e0e 1 2026-01-22 14:11:56 +08:00
25 changed files with 1896 additions and 2693 deletions

117
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,117 @@
name: CI Build
on:
push:
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: web/dist
retention-days: 1
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
needs: frontend
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
goos: linux
goarch: amd64
- runner: ubuntu-latest
goos: linux
goarch: arm64
- runner: windows-latest
goos: windows
goarch: amd64
- runner: macos-latest
goos: darwin
goarch: amd64
- runner: macos-latest
goos: darwin
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- name: Build server and client
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build/${GOOS}_${GOARCH}
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
BUILD_TIME="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
GIT_COMMIT="${GITHUB_SHA::7}"
VERSION="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
VERSION="${GITHUB_SHA::7}"
fi
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/server${EXT}" ./cmd/server
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/client${EXT}" ./cmd/client
- name: Upload binaries artifact
uses: actions/upload-artifact@v4
with:
name: gotunnel-${{ matrix.goos }}-${{ matrix.goarch }}
path: build/${{ matrix.goos }}_${{ matrix.goarch }}/
retention-days: 7

214
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,214 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish, e.g. v1.2.3'
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: web/dist
retention-days: 1
build-assets:
name: Package ${{ matrix.component }} ${{ matrix.goos }}/${{ matrix.goarch }}
needs: frontend
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: server
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: client
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: server
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: client
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: server
goos: windows
goarch: amd64
archive_ext: zip
- component: client
goos: windows
goarch: amd64
archive_ext: zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- name: Resolve release metadata
id: meta
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Build binary
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
VERSION: ${{ steps.meta.outputs.tag }}
GIT_COMMIT: ${{ steps.meta.outputs.commit }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
run: |
mkdir -p dist/package
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
OUTPUT_NAME="${{ matrix.component }}${EXT}"
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "dist/package/${OUTPUT_NAME}" ./cmd/${{ matrix.component }}
- name: Create release archive
id: package
shell: bash
run: |
TAG="${{ steps.meta.outputs.tag }}"
ARCHIVE="gotunnel-${{ matrix.component }}-${TAG}-${{ matrix.goos }}-${{ matrix.goarch }}.${{ matrix.archive_ext }}"
mkdir -p dist/out
if [ "${{ matrix.archive_ext }}" = "zip" ]; then
(cd dist/package && zip -r "../out/${ARCHIVE}" .)
else
tar -C dist/package -czf "dist/out/${ARCHIVE}" .
fi
echo "archive=dist/out/${ARCHIVE}" >> "$GITHUB_OUTPUT"
- name: Upload release asset artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.component }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ steps.package.outputs.archive }}
retention-days: 1
publish:
name: Publish release
needs: build-assets
runs-on: ubuntu-latest
steps:
- name: Download packaged assets
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
merge-multiple: true
- name: Resolve release metadata
id: meta
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Generate checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt
- name: Create or update GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
release-artifacts/*

View File

@@ -14,8 +14,7 @@ go build -o client ./cmd/client
./server -c server.yaml # with config file ./server -c server.yaml # with config file
# Run client # Run client
./client -s <server>:7000 -t <token> -id <client-id> ./client -s <server>:7000 -t <token>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
# Web UI development (in web/ directory) # Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server cd web && npm install && npm run dev # development server
@@ -86,7 +85,7 @@ External User → Server Port → Yamux Stream → Client → Local Service
### Configuration ### Configuration
- Server: YAML config + SQLite database for client rules and JS plugins - Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID) - Client: Command-line flags only (server address, token)
- Default ports: 7000 (tunnel), 7500 (web console) - Default ports: 7000 (tunnel), 7500 (web console)
## API Documentation ## API Documentation

View File

@@ -14,8 +14,7 @@ go build -o client ./cmd/client
./server -c server.yaml # with config file ./server -c server.yaml # with config file
# Run client # Run client
./client -s <server>:7000 -t <token> -id <client-id> ./client -s <server>:7000 -t <token>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
# Web UI development (in web/ directory) # Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server cd web && npm install && npm run dev # development server
@@ -86,7 +85,7 @@ External User → Server Port → Yamux Stream → Client → Local Service
### Configuration ### Configuration
- Server: YAML config + SQLite database for client rules and JS plugins - Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID) - Client: Command-line flags only (server address, token)
- Default ports: 7000 (tunnel), 7500 (web console) - Default ports: 7000 (tunnel), 7500 (web console)
## API Documentation ## API Documentation

View File

@@ -17,7 +17,7 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
| TLS 证书 | 自动生成,零配置 | 需手动配置 | | TLS 证书 | 自动生成,零配置 | 需手动配置 |
| 管理界面 | 内置 Web 控制台 (naive-ui) | 需额外部署 Dashboard | | 管理界面 | 内置 Web 控制台 (naive-ui) | 需额外部署 Dashboard |
| 客户端部署 | 仅需 2 个参数 | 需配置文件 | | 客户端部署 | 仅需 2 个参数 | 需配置文件 |
| 客户端 ID | 可选,服务端自动分配 | 需手动配置 | | 客户端 ID | 自动根据设备标识计算 | 需手动配置 |
### 架构设计 ### 架构设计
@@ -111,14 +111,9 @@ go build -o client ./cmd/client
### 客户端启动 ### 客户端启动
```bash ```bash
# 最简启动ID 由服务端自动分配 # 最简启动ID 由客户端根据设备标识自动计算
./client -s <服务器IP>:7000 -t <Token> ./client -s <服务器IP>:7000 -t <Token>
# 指定客户端 ID
./client -s <服务器IP>:7000 -t <Token> -id <客户端ID>
# 禁用 TLS需服务端也禁用
./client -s <服务器IP>:7000 -t <Token> -no-tls
``` ```
**参数说明:** **参数说明:**
@@ -127,9 +122,6 @@ go build -o client ./cmd/client
|------|------|------| |------|------|------|
| `-s` | 服务器地址 (ip:port) | 是 | | `-s` | 服务器地址 (ip:port) | 是 |
| `-t` | 认证 Token | 是 | | `-t` | 认证 Token | 是 |
| `-id` | 客户端 ID | 否(服务端自动分配) |
| `-no-tls` | 禁用 TLS 加密 | 否 |
| `-skip-verify` | 跳过证书验证(不安全,仅测试用) | 否 |
## 配置系统 ## 配置系统
@@ -388,7 +380,7 @@ curl -X POST http://server:7500/api/clients \
-d '{"id":"home","rules":[{"name":"web","type":"tcp","local_ip":"127.0.0.1","local_port":80,"remote_port":8080}]}' -d '{"id":"home","rules":[{"name":"web","type":"tcp","local_ip":"127.0.0.1","local_port":80,"remote_port":8080}]}'
# 客户端连接 # 客户端连接
./client -s server:7000 -t <token> -id home ./client -s server:7000 -t <token>
# 访问http://server:8080 -> 内网 127.0.0.1:80 # 访问http://server:8080 -> 内网 127.0.0.1:80
``` ```
@@ -411,7 +403,7 @@ A: 在 Web 控制台点击客户端详情,进入编辑模式即可设置昵称
**Q: 如何禁用 TLS** **Q: 如何禁用 TLS**
A: 服务端配置 `tls_disabled: true`,客户端使用 `-no-tls` 参数 A: 客户端命令行默认使用 TLS如需兼容旧的非 TLS 部署,请改用客户端配置文件中的 `no_tls: true`
**Q: 端口被占用怎么办?** **Q: 端口被占用怎么办?**
@@ -419,7 +411,7 @@ A: 服务端会自动检测端口冲突,请检查日志并更换端口。
**Q: 客户端 ID 是如何分配的?** **Q: 客户端 ID 是如何分配的?**
A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机 ID。 A: 客户端会把系统机器 ID、全部可用 MAC、主机名和网卡名等稳定标识组合后再进行哈希得到固定客户端 ID服务端不再为客户端分配或修正 ID。
**Q: 如何更新服务端/客户端?** **Q: 如何更新服务端/客户端?**

View File

@@ -23,8 +23,6 @@ func init() {
func main() { func main() {
server := flag.String("s", "", "server address (ip:port)") server := flag.String("s", "", "server address (ip:port)")
token := flag.String("t", "", "auth token") token := flag.String("t", "", "auth token")
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
noTLS := flag.Bool("no-tls", false, "disable TLS")
configPath := flag.String("c", "", "config file path") configPath := flag.String("c", "", "config file path")
flag.Parse() flag.Parse()
@@ -47,18 +45,12 @@ func main() {
if *token != "" { if *token != "" {
cfg.Token = *token cfg.Token = *token
} }
if *id != "" {
cfg.ID = *id
}
if *noTLS {
cfg.NoTLS = *noTLS
}
if cfg.Server == "" || cfg.Token == "" { if cfg.Server == "" || cfg.Token == "" {
log.Fatal("Usage: client [-c config.yaml] | [-s <server:port> -t <token> [-id <client_id>] [-no-tls]]") log.Fatal("Usage: client [-c config.yaml] | [-s <server:port> -t <token>]")
} }
client := tunnel.NewClient(cfg.Server, cfg.Token, cfg.ID) client := tunnel.NewClient(cfg.Server, cfg.Token)
// TLS 默认启用,默认跳过证书验证(类似 frp // TLS 默认启用,默认跳过证书验证(类似 frp
if !cfg.NoTLS { if !cfg.NoTLS {

View File

@@ -10,7 +10,6 @@ import (
type ClientConfig struct { type ClientConfig struct {
Server string `yaml:"server"` // 服务器地址 Server string `yaml:"server"` // 服务器地址
Token string `yaml:"token"` // 认证 Token Token string `yaml:"token"` // 认证 Token
ID string `yaml:"id"` // 客户端 ID
NoTLS bool `yaml:"no_tls"` // 禁用 TLS NoTLS bool `yaml:"no_tls"` // 禁用 TLS
} }

View File

@@ -48,7 +48,7 @@ type Client struct {
} }
// NewClient 创建客户端 // NewClient 创建客户端
func NewClient(serverAddr, token, id string) *Client { func NewClient(serverAddr, token string) *Client {
// 默认数据目录:优先使用用户主目录,失败时回退到当前工作目录 // 默认数据目录:优先使用用户主目录,失败时回退到当前工作目录
var dataDir string var dataDir string
if home, err := os.UserHomeDir(); err == nil && home != "" { if home, err := os.UserHomeDir(); err == nil && home != "" {
@@ -71,9 +71,7 @@ func NewClient(serverAddr, token, id string) *Client {
} }
// ID 优先级:命令行参数 > 机器ID // ID 优先级:命令行参数 > 机器ID
if id == "" { id := getMachineID()
id = getMachineID()
}
// 获取主机名作为客户端名称 // 获取主机名作为客户端名称
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
@@ -99,7 +97,6 @@ func (c *Client) InitVersionStore() error {
return nil return nil
} }
// logf 安全地记录日志(同时输出到标准日志和日志收集器) // logf 安全地记录日志(同时输出到标准日志和日志收集器)
func (c *Client) logf(format string, args ...interface{}) { func (c *Client) logf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
@@ -190,8 +187,8 @@ func (c *Client) connect() error {
// 如果服务端分配了新 ID则更新 // 如果服务端分配了新 ID则更新
if authResp.ClientID != "" && authResp.ClientID != c.ID { if authResp.ClientID != "" && authResp.ClientID != c.ID {
c.ID = authResp.ClientID conn.Close()
c.logf("ID updated to: %s", c.ID) return fmt.Errorf("server returned unexpected client id: %s", authResp.ClientID)
} }
c.logf("Authenticated as %s", c.ID) c.logf("Authenticated as %s", c.ID)
@@ -416,15 +413,6 @@ func (c *Client) findRuleByPort(port int) *protocol.ProxyRule {
return nil return nil
} }
// handleClientRestart 处理客户端重启请求 // handleClientRestart 处理客户端重启请求
func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) {
defer stream.Close() defer stream.Close()
@@ -449,8 +437,6 @@ func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) {
} }
} }
// handleUpdateDownload 处理更新下载请求 // handleUpdateDownload 处理更新下载请求
func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) { func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) {
defer stream.Close() defer stream.Close()
@@ -528,7 +514,7 @@ func (c *Client) performSelfUpdate(downloadURL string) error {
// Windows 需要特殊处理 // Windows 需要特殊处理
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token, c.ID) return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token)
} }
// 确定目标路径 // 确定目标路径
@@ -579,7 +565,7 @@ func (c *Client) performSelfUpdate(downloadURL string) error {
c.logf("Update completed successfully, restarting...") c.logf("Update completed successfully, restarting...")
// 重启进程(从新路径启动) // 重启进程(从新路径启动)
restartClientProcess(targetPath, c.ServerAddr, c.Token, c.ID) restartClientProcess(targetPath, c.ServerAddr, c.Token)
return nil return nil
} }
@@ -608,15 +594,10 @@ func (c *Client) checkUpdatePermissions(execPath string) error {
return nil return nil
} }
// performWindowsClientUpdate Windows 平台更新 // performWindowsClientUpdate Windows 平台更新
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token, id string) error { func performWindowsClientUpdate(newFile, currentPath, serverAddr, token string) error {
// 创建批处理脚本 // 创建批处理脚本
args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token) args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token)
if id != "" {
args += fmt.Sprintf(` -id "%s"`, id)
}
batchScript := fmt.Sprintf(`@echo off batchScript := fmt.Sprintf(`@echo off
:: Check for admin rights, request UAC elevation if needed :: Check for admin rights, request UAC elevation if needed
net session >nul 2>&1 net session >nul 2>&1
@@ -647,11 +628,8 @@ del "%%~f0"
} }
// restartClientProcess 重启客户端进程 // restartClientProcess 重启客户端进程
func restartClientProcess(path, serverAddr, token, id string) { func restartClientProcess(path, serverAddr, token string) {
args := []string{"-s", serverAddr, "-t", token} args := []string{"-s", serverAddr, "-t", token}
if id != "" {
args = append(args, "-id", id)
}
cmd := exec.Command(path, args...) cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@@ -660,7 +638,6 @@ func restartClientProcess(path, serverAddr, token, id string) {
os.Exit(0) os.Exit(0)
} }
// handleLogRequest 处理日志请求 // handleLogRequest 处理日志请求
func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) { func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) {
if c.logger == nil { if c.logger == nil {
@@ -737,8 +714,6 @@ func (c *Client) handleLogStop(stream net.Conn, msg *protocol.Message) {
c.logger.Unsubscribe(req.SessionID) c.logger.Unsubscribe(req.SessionID)
} }
// handleSystemStatsRequest 处理系统状态请求 // handleSystemStatsRequest 处理系统状态请求
func (c *Client) handleSystemStatsRequest(stream net.Conn, msg *protocol.Message) { func (c *Client) handleSystemStatsRequest(stream net.Conn, msg *protocol.Message) {
defer stream.Close() defer stream.Close()

View File

@@ -7,27 +7,38 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"sort"
"strings" "strings"
) )
// getMachineID 获取机器唯一标识 // getMachineID builds a stable fingerprint from multiple host identifiers
// 优先级系统机器ID > MAC地址哈希 // and hashes the combined result into the client ID we expose externally.
func getMachineID() string { func getMachineID() string {
// 尝试获取系统机器 ID return hashID(strings.Join(collectMachineIDParts(), "|"))
if id := getSystemMachineID(); id != "" { }
return hashID(id)
} func collectMachineIDParts() []string {
parts := []string{"os=" + runtime.GOOS, "arch=" + runtime.GOARCH}
// 备选:使用主网卡 MAC 地址
if id := getMACAddress(); id != "" { if id := getSystemMachineID(); id != "" {
return hashID(id) parts = append(parts, "system="+id)
} }
// 都失败则返回空,让服务端生成 if hostname, err := os.Hostname(); err == nil && hostname != "" {
return "" parts = append(parts, "host="+hostname)
}
if macs := getMACAddresses(); len(macs) > 0 {
parts = append(parts, "macs="+strings.Join(macs, ","))
}
if names := getInterfaceNames(); len(names) > 0 {
parts = append(parts, "ifaces="+strings.Join(names, ","))
}
return parts
} }
// getSystemMachineID 获取系统机器 ID
func getSystemMachineID() string { func getSystemMachineID() string {
switch runtime.GOOS { switch runtime.GOOS {
case "linux": case "linux":
@@ -41,20 +52,16 @@ func getSystemMachineID() string {
} }
} }
// getLinuxMachineID 获取 Linux 机器 ID
func getLinuxMachineID() string { func getLinuxMachineID() string {
// 优先读取 /etc/machine-id
if data, err := os.ReadFile("/etc/machine-id"); err == nil { if data, err := os.ReadFile("/etc/machine-id"); err == nil {
return strings.TrimSpace(string(data)) return strings.TrimSpace(string(data))
} }
// 备选 /var/lib/dbus/machine-id
if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil { if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
return strings.TrimSpace(string(data)) return strings.TrimSpace(string(data))
} }
return "" return ""
} }
// getDarwinMachineID 获取 macOS 机器 ID (IOPlatformUUID)
func getDarwinMachineID() string { func getDarwinMachineID() string {
cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice") cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
output, err := cmd.Output() output, err := cmd.Output()
@@ -62,72 +69,84 @@ func getDarwinMachineID() string {
return "" return ""
} }
// 解析 IOPlatformUUID for _, line := range strings.Split(string(output), "\n") {
lines := strings.Split(string(output), "\n") if !strings.Contains(line, "IOPlatformUUID") {
for _, line := range lines { continue
if strings.Contains(line, "IOPlatformUUID") { }
parts := strings.Split(line, "=") parts := strings.Split(line, "=")
if len(parts) == 2 { if len(parts) != 2 {
continue
}
uuid := strings.TrimSpace(parts[1]) uuid := strings.TrimSpace(parts[1])
uuid = strings.Trim(uuid, "\"") return strings.Trim(uuid, "\"")
return uuid
}
}
} }
return "" return ""
} }
// getWindowsMachineID 获取 Windows 机器 ID
func getWindowsMachineID() string { func getWindowsMachineID() string {
cmd := exec.Command("reg", "query", cmd := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
`HKLM\SOFTWARE\Microsoft\Cryptography`,
"/v", "MachineGuid")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "" return ""
} }
// 解析注册表输出 for _, line := range strings.Split(string(output), "\n") {
lines := strings.Split(string(output), "\n") if !strings.Contains(line, "MachineGuid") {
for _, line := range lines { continue
if strings.Contains(line, "MachineGuid") { }
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) >= 3 { if len(fields) >= 3 {
return fields[len(fields)-1] return fields[len(fields)-1]
} }
} }
}
return "" return ""
} }
// getMACAddress 获取主网卡 MAC 地址 func getMACAddresses() []string {
func getMACAddress() string {
interfaces, err := net.Interfaces() interfaces, err := net.Interfaces()
if err != nil { if err != nil {
return "" return nil
} }
macs := make([]string, 0, len(interfaces))
for _, iface := range interfaces { for _, iface := range interfaces {
// 跳过回环和无效接口
if iface.Flags&net.FlagLoopback != 0 { if iface.Flags&net.FlagLoopback != 0 {
continue continue
} }
if iface.Flags&net.FlagUp == 0 {
continue
}
if len(iface.HardwareAddr) == 0 { if len(iface.HardwareAddr) == 0 {
continue continue
} }
macs = append(macs, iface.HardwareAddr.String())
// 返回第一个有效的 MAC 地址
return iface.HardwareAddr.String()
} }
return ""
sort.Strings(macs)
return macs
}
func getInterfaceNames() []string {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
names := make([]string, 0, len(interfaces))
for _, iface := range interfaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
names = append(names, iface.Name)
}
sort.Strings(names)
return names
} }
// hashID 对 ID 进行哈希处理,生成固定长度的客户端 ID
func hashID(id string) string { func hashID(id string) string {
hash := sha256.Sum256([]byte(id)) hash := sha256.Sum256([]byte(id))
// 取前 16 个字符作为客户端 ID
return hex.EncodeToString(hash[:])[:16] return hex.EncodeToString(hash[:])[:16]
} }

View File

@@ -5,8 +5,8 @@ func (s *SQLiteStore) CreateInstallToken(token *InstallToken) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT INTO install_tokens (token, client_id, created_at, used) VALUES (?, ?, ?, ?)`, _, err := s.db.Exec(`INSERT INTO install_tokens (token, client_id, created_at, used) VALUES (?, '', ?, ?)`,
token.Token, token.ClientID, token.CreatedAt, 0) token.Token, token.CreatedAt, 0)
return err return err
} }
@@ -17,8 +17,8 @@ func (s *SQLiteStore) GetInstallToken(token string) (*InstallToken, error) {
var t InstallToken var t InstallToken
var used int var used int
err := s.db.QueryRow(`SELECT token, client_id, created_at, used FROM install_tokens WHERE token = ?`, token). err := s.db.QueryRow(`SELECT token, created_at, used FROM install_tokens WHERE token = ?`, token).
Scan(&t.Token, &t.ClientID, &t.CreatedAt, &used) Scan(&t.Token, &t.CreatedAt, &used)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -46,7 +46,6 @@ type TrafficStore interface {
// InstallToken 安装token // InstallToken 安装token
type InstallToken struct { type InstallToken struct {
Token string `json:"token"` Token string `json:"token"`
ClientID string `json:"client_id"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
Used bool `json:"used"` Used bool `json:"used"`
} }

View File

@@ -81,7 +81,7 @@ func (s *SQLiteStore) init() error {
_, err = s.db.Exec(` _, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS install_tokens ( CREATE TABLE IF NOT EXISTS install_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
client_id TEXT NOT NULL, client_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0 used INTEGER NOT NULL DEFAULT 0
) )

View File

@@ -1,15 +1,12 @@
package dto package dto
// UpdateServerConfigRequest 更新服务器配置请求 // UpdateServerConfigRequest is the config update payload.
// @Description 更新服务器配置
type UpdateServerConfigRequest struct { type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"` Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"` Web *WebConfigPart `json:"web"`
PluginStore *PluginStoreConfigPart `json:"plugin_store"`
} }
// ServerConfigPart 服务器配置部分 // ServerConfigPart is the server config subset.
// @Description 隧道服务器配置
type ServerConfigPart struct { type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"` BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"` BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -18,8 +15,7 @@ type ServerConfigPart struct {
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"` HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
} }
// WebConfigPart Web 配置部分 // WebConfigPart is the web console config subset.
// @Description Web 控制台配置
type WebConfigPart struct { type WebConfigPart struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"` BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -27,37 +23,25 @@ type WebConfigPart struct {
Password string `json:"password" binding:"omitempty,min=6,max=64"` Password string `json:"password" binding:"omitempty,min=6,max=64"`
} }
// ServerConfigResponse 服务器配置响应 // ServerConfigResponse is the config response payload.
// @Description 服务器配置信息
type ServerConfigResponse struct { type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"` Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"` Web WebConfigInfo `json:"web"`
PluginStore PluginStoreConfigInfo `json:"plugin_store"`
} }
// ServerConfigInfo 服务器配置信息 // ServerConfigInfo describes the server config.
type ServerConfigInfo struct { type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"` BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"` BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token Token string `json:"token"`
HeartbeatSec int `json:"heartbeat_sec"` HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"` HeartbeatTimeout int `json:"heartbeat_timeout"`
} }
// WebConfigInfo Web 配置信息 // WebConfigInfo describes the web console config.
type WebConfigInfo struct { type WebConfigInfo struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BindPort int `json:"bind_port"` BindPort int `json:"bind_port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` // 显示为 **** Password string `json:"password"`
}
// PluginStoreConfigPart 插件商店配置部分
type PluginStoreConfigPart struct {
URL string `json:"url"`
}
// PluginStoreConfigInfo 插件商店配置信息
type PluginStoreConfigInfo struct {
URL string `json:"url"`
} }

View File

@@ -3,7 +3,6 @@ package handler
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt"
"net/http" "net/http"
"time" "time"
@@ -11,98 +10,58 @@ import (
"github.com/gotunnel/internal/server/db" "github.com/gotunnel/internal/server/db"
) )
// InstallHandler 安装处理器
type InstallHandler struct { type InstallHandler struct {
app AppInterface app AppInterface
} }
// NewInstallHandler 创建安装处理器
func NewInstallHandler(app AppInterface) *InstallHandler { func NewInstallHandler(app AppInterface) *InstallHandler {
return &InstallHandler{app: app} return &InstallHandler{app: app}
} }
// GenerateInstallCommandRequest 生成安装命令请求
type GenerateInstallCommandRequest struct {
ClientID string `json:"client_id" binding:"required"`
}
// InstallCommandResponse 安装命令响应
type InstallCommandResponse struct { type InstallCommandResponse struct {
Token string `json:"token"` Token string `json:"token"`
Commands map[string]string `json:"commands"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
ServerAddr string `json:"server_addr"` TunnelPort int `json:"tunnel_port"`
} }
// GenerateInstallCommand 生成安装命令 // GenerateInstallCommand creates a one-time install token and returns
// @Summary 生成客户端安装命令 // the tunnel port so the frontend can build a host-aware command.
//
// @Summary Generate install command payload
// @Tags install // @Tags install
// @Accept json
// @Produce json // @Produce json
// @Param body body GenerateInstallCommandRequest true "客户端ID"
// @Success 200 {object} InstallCommandResponse // @Success 200 {object} InstallCommandResponse
// @Router /api/install/generate [post] // @Router /api/install/generate [post]
func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) { func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
var req GenerateInstallCommandRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 生成随机token
tokenBytes := make([]byte, 32) tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return return
} }
token := hex.EncodeToString(tokenBytes)
// 保存到数据库 token := hex.EncodeToString(tokenBytes)
now := time.Now().Unix() now := time.Now().Unix()
installToken := &db.InstallToken{ installToken := &db.InstallToken{
Token: token, Token: token,
ClientID: req.ClientID,
CreatedAt: now, CreatedAt: now,
Used: false, Used: false,
} }
store, ok := h.app.GetClientStore().(db.InstallTokenStore) store, ok := h.app.GetClientStore().(db.InstallTokenStore)
if !ok { if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储不支持安装token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "install token store is not supported"})
return return
} }
if err := store.CreateInstallToken(installToken); err != nil { if err := store.CreateInstallToken(installToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存token失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist token"})
return return
} }
// 获取服务器地址
serverAddr := fmt.Sprintf("%s:%d", h.app.GetConfig().Server.BindAddr, h.app.GetServer().GetBindPort())
if h.app.GetConfig().Server.BindAddr == "" || h.app.GetConfig().Server.BindAddr == "0.0.0.0" {
serverAddr = fmt.Sprintf("your-server-ip:%d", h.app.GetServer().GetBindPort())
}
// 生成安装命令
expiresAt := now + 3600 // 1小时过期
tlsFlag := ""
if h.app.GetConfig().Server.TLSDisabled {
tlsFlag = " -no-tls"
}
commands := map[string]string{
"linux": fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh | bash -s -- -s %s -t %s -id %s%s",
serverAddr, token, req.ClientID, tlsFlag),
"macos": fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh | bash -s -- -s %s -t %s -id %s%s",
serverAddr, token, req.ClientID, tlsFlag),
"windows": fmt.Sprintf("powershell -c \"irm https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.ps1 | iex; Install-GoTunnel -Server '%s' -Token '%s' -ClientID '%s'%s\"",
serverAddr, token, req.ClientID, tlsFlag),
}
c.JSON(http.StatusOK, InstallCommandResponse{ c.JSON(http.StatusOK, InstallCommandResponse{
Token: token, Token: token,
Commands: commands, ExpiresAt: now + 3600,
ExpiresAt: expiresAt, TunnelPort: h.app.GetServer().GetBindPort(),
ServerAddr: serverAddr,
}) })
} }

View File

@@ -1,10 +1,8 @@
package tunnel package tunnel
import ( import (
"crypto/rand"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -38,13 +36,6 @@ func isValidClientID(id string) bool {
return clientIDRegex.MatchString(id) return clientIDRegex.MatchString(id)
} }
// generateClientID 生成随机客户端 ID
func generateClientID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// Server 隧道服务端 // Server 隧道服务端
type Server struct { type Server struct {
clientStore db.ClientStore clientStore db.ClientStore
@@ -239,13 +230,7 @@ func (s *Server) handleConnection(conn net.Conn) {
validToken = true validToken = true
isInstallToken = true isInstallToken = true
// 验证客户端ID匹配 // 验证客户端ID匹配
if authReq.ClientID != "" && authReq.ClientID != installToken.ClientID {
security.LogInvalidClientID(clientIP, authReq.ClientID)
s.sendAuthResponse(conn, false, "client id mismatch", "")
return
}
// 使用token中的客户端ID // 使用token中的客户端ID
authReq.ClientID = installToken.ClientID
} }
} }
} }
@@ -259,9 +244,7 @@ func (s *Server) handleConnection(conn net.Conn) {
// 处理客户端 ID // 处理客户端 ID
clientID := authReq.ClientID clientID := authReq.ClientID
if clientID == "" { if clientID == "" || !isValidClientID(clientID) {
clientID = generateClientID()
} else if !isValidClientID(clientID) {
security.LogInvalidClientID(clientIP, clientID) security.LogInvalidClientID(clientIP, clientID)
s.sendAuthResponse(conn, false, "invalid client id format", "") s.sendAuthResponse(conn, false, "invalid client id format", "")
return return
@@ -757,11 +740,6 @@ func (s *Server) DisconnectClient(clientID string) error {
return cs.Session.Close() return cs.Session.Close()
} }
// startUDPListener 启动 UDP 监听 // startUDPListener 启动 UDP 监听
func (s *Server) startUDPListener(cs *ClientSession, rule *protocol.ProxyRule) { func (s *Server) startUDPListener(cs *ClientSession, rule *protocol.ProxyRule) {
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil { if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {
@@ -856,15 +834,6 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
} }
} }
// checkHTTPBasicAuth 检查 HTTP Basic Auth // checkHTTPBasicAuth 检查 HTTP Basic Auth
// 返回 (认证成功, 已读取的数据) // 返回 (认证成功, 已读取的数据)
func (s *Server) checkHTTPBasicAuth(conn net.Conn, username, password string) (bool, []byte) { func (s *Server) checkHTTPBasicAuth(conn net.Conn, username, password string) (bool, []byte) {
@@ -933,8 +902,6 @@ func (s *Server) sendHTTPUnauthorized(conn net.Conn) {
conn.Write([]byte(response)) conn.Write([]byte(response))
} }
// shouldPushToClient 检查是否应推送到指定客户端 // shouldPushToClient 检查是否应推送到指定客户端
func (s *Server) shouldPushToClient(autoPush []string, clientID string) bool { func (s *Server) shouldPushToClient(autoPush []string, clientID string) bool {
if len(autoPush) == 0 { if len(autoPush) == 0 {
@@ -980,11 +947,6 @@ func (s *Server) RestartClient(clientID string) error {
return nil return nil
} }
// IsPortAvailable 检查端口是否可用 // IsPortAvailable 检查端口是否可用
func (s *Server) IsPortAvailable(port int, excludeClientID string) bool { func (s *Server) IsPortAvailable(port int, excludeClientID string) bool {
// 检查系统端口 // 检查系统端口
@@ -1008,11 +970,6 @@ func (s *Server) IsPortAvailable(port int, excludeClientID string) bool {
return true return true
} }
// SendUpdateToClient 发送更新命令到客户端 // SendUpdateToClient 发送更新命令到客户端
func (s *Server) SendUpdateToClient(clientID, downloadURL string) error { func (s *Server) SendUpdateToClient(clientID, downloadURL string) error {
s.mu.RLock() s.mu.RLock()

File diff suppressed because it is too large Load Diff

View File

@@ -162,25 +162,19 @@ export interface WebConfigInfo {
password: string password: string
} }
export interface PluginStoreConfigInfo {
url: string
}
export interface ServerConfigResponse { export interface ServerConfigResponse {
server: ServerConfigInfo server: ServerConfigInfo
web: WebConfigInfo web: WebConfigInfo
plugin_store: PluginStoreConfigInfo
} }
export interface UpdateServerConfigRequest { export interface UpdateServerConfigRequest {
server?: Partial<ServerConfigInfo> server?: Partial<ServerConfigInfo>
web?: Partial<WebConfigInfo> web?: Partial<WebConfigInfo>
plugin_store?: Partial<PluginStoreConfigInfo>
} }
export const getServerConfig = () => get<ServerConfigResponse>('/config') export const getServerConfig = () => get<ServerConfigResponse>('/config')
export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config) export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config)
// 安装命令生成 // 安装命令生成
export const generateInstallCommand = (clientId: string) => export const generateInstallCommand = () =>
post<InstallCommandResponse>('/install/generate', { client_id: clientId }) post<InstallCommandResponse>('/install/generate')

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
label: string
value: string | number
hint?: string
tone?: 'default' | 'success' | 'warning' | 'info'
}>()
const toneClass = props.tone || 'default'
</script>
<template>
<article class="metric-card" :class="`metric-card--${toneClass}`">
<span class="metric-card__label">{{ label }}</span>
<strong class="metric-card__value">{{ value }}</strong>
<span v-if="hint" class="metric-card__hint">{{ hint }}</span>
</article>
</template>
<style scoped>
.metric-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
min-height: 128px;
border-radius: 20px;
background: var(--glass-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
}
.metric-card__label {
font-size: 13px;
color: var(--color-text-secondary);
}
.metric-card__value {
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
color: var(--color-text-primary);
letter-spacing: -0.04em;
}
.metric-card__hint {
margin-top: auto;
font-size: 13px;
color: var(--color-text-muted);
}
.metric-card--success {
border-color: rgba(16, 185, 129, 0.24);
}
.metric-card--warning {
border-color: rgba(245, 158, 11, 0.24);
}
.metric-card--info {
border-color: rgba(6, 182, 212, 0.24);
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
defineProps<{
title: string
subtitle?: string
eyebrow?: string
}>()
</script>
<template>
<section class="page-shell">
<div class="page-shell__glow page-shell__glow--primary"></div>
<div class="page-shell__glow page-shell__glow--secondary"></div>
<header class="page-shell__header">
<div class="page-shell__heading">
<span v-if="eyebrow" class="page-shell__eyebrow">{{ eyebrow }}</span>
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="page-shell__actions">
<slot name="actions" />
</div>
</header>
<div v-if="$slots.metrics" class="page-shell__metrics">
<slot name="metrics" />
</div>
<div class="page-shell__content">
<slot />
</div>
</section>
</template>
<style scoped>
.page-shell {
position: relative;
padding: 32px;
overflow: hidden;
}
.page-shell__glow {
position: absolute;
border-radius: 999px;
filter: blur(80px);
opacity: 0.18;
pointer-events: none;
}
.page-shell__glow--primary {
width: 320px;
height: 320px;
top: -120px;
right: -80px;
background: var(--color-accent);
}
.page-shell__glow--secondary {
width: 280px;
height: 280px;
bottom: -120px;
left: -40px;
background: #8b5cf6;
}
.page-shell__header,
.page-shell__metrics,
.page-shell__content {
position: relative;
z-index: 1;
}
.page-shell__header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
}
.page-shell__heading {
max-width: 720px;
}
.page-shell__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin-bottom: 12px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.18);
color: var(--color-accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.page-shell__heading h1 {
margin: 0;
font-size: clamp(28px, 4vw, 40px);
font-weight: 700;
letter-spacing: -0.03em;
color: var(--color-text-primary);
}
.page-shell__heading p {
margin: 10px 0 0;
max-width: 640px;
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.7;
}
.page-shell__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.page-shell__metrics {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 24px;
}
.page-shell__content {
display: flex;
flex-direction: column;
gap: 20px;
}
@media (max-width: 768px) {
.page-shell {
padding: 20px;
}
.page-shell__header {
flex-direction: column;
}
.page-shell__actions {
width: 100%;
justify-content: stretch;
}
.page-shell__actions :deep(*) {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="section-card glass-card">
<header class="section-card__header">
<div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div v-if="$slots.header" class="section-card__extra">
<slot name="header" />
</div>
</header>
<div class="section-card__body">
<slot />
</div>
</section>
</template>
<style scoped>
.section-card {
padding: 22px;
}
.section-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.section-card__header h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.section-card__header p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-secondary);
}
.section-card__extra {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.section-card__body {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 768px) {
.section-card {
padding: 18px;
}
.section-card__header {
flex-direction: column;
}
.section-card__extra {
width: 100%;
}
}
</style>

View File

@@ -67,11 +67,6 @@ export interface LogStreamOptions {
// 安装命令响应 // 安装命令响应
export interface InstallCommandResponse { export interface InstallCommandResponse {
token: string token: string
commands: {
linux: string
macos: string
windows: string
}
expires_at: number expires_at: number
server_addr: string tunnel_port: number
} }

View File

@@ -1,611 +1,317 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getClients, generateInstallCommand } from '../api' import GlassModal from '../components/GlassModal.vue'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
import { generateInstallCommand, getClients } from '../api'
import { useToast } from '../composables/useToast'
import type { ClientStatus, InstallCommandResponse } from '../types' import type { ClientStatus, InstallCommandResponse } from '../types'
const router = useRouter() const router = useRouter()
const message = useToast()
const clients = ref<ClientStatus[]>([]) const clients = ref<ClientStatus[]>([])
const loading = ref(true) const loading = ref(true)
const showInstallModal = ref(false) const showInstallModal = ref(false)
const installData = ref<InstallCommandResponse | null>(null) const installData = ref<InstallCommandResponse | null>(null)
const installClientId = ref('')
const generatingInstall = ref(false) const generatingInstall = ref(false)
const search = ref('')
const installScriptUrl = 'https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh'
const installPs1Url = 'https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.ps1'
const quoteShellArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`
const resolveTunnelHost = () => window.location.hostname || 'localhost'
const formatServerAddr = (host: string, port: number) => {
const normalizedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
return `${normalizedHost}:${port}`
}
const buildInstallCommands = (data: InstallCommandResponse) => {
const serverAddr = formatServerAddr(resolveTunnelHost(), data.tunnel_port)
return {
linux: `bash <(curl -fsSL ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)}`,
macos: `bash <(curl -fsSL ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)}`,
windows: `powershell -c \"irm ${installPs1Url} | iex; Install-GoTunnel -Server '${serverAddr}' -Token '${data.token}'\"`,
}
}
const loadClients = async () => { const loadClients = async () => {
loading.value = true loading.value = true
try { try {
const { data } = await getClients() const { data } = await getClients()
clients.value = data || [] clients.value = data || []
} catch (e) { } catch (error) {
console.error('Failed to load clients', e) console.error('Failed to load clients', error)
message.error('客户端列表加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const onlineClients = computed(() => clients.value.filter(c => c.online).length)
const viewClient = (id: string) => {
router.push(`/client/${id}`)
}
const openInstallModal = async () => { const openInstallModal = async () => {
installClientId.value = `client-${Date.now()}`
generatingInstall.value = true generatingInstall.value = true
try { try {
const { data } = await generateInstallCommand(installClientId.value) const { data } = await generateInstallCommand()
installData.value = data installData.value = data
showInstallModal.value = true showInstallModal.value = true
} catch (e) { } catch (error) {
console.error('Failed to generate install command', e) console.error('Failed to generate install command', error)
message.error('安装命令生成失败')
} finally { } finally {
generatingInstall.value = false generatingInstall.value = false
} }
} }
const copyCommand = (cmd: string) => { const copyCommand = async (command: string) => {
navigator.clipboard.writeText(cmd) try {
await navigator.clipboard.writeText(command)
message.success('命令已复制')
} catch (error) {
console.error('Failed to copy command', error)
message.error('复制失败,请手动复制')
}
} }
const closeInstallModal = () => { const filteredClients = computed(() => {
showInstallModal.value = false const keyword = search.value.trim().toLowerCase()
installData.value = null if (!keyword) return clients.value
} return clients.value.filter((client) => {
return [client.id, client.nickname, client.remote_addr, client.os, client.arch]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
})
})
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const installCommands = computed(() => (installData.value ? buildInstallCommands(installData.value) : null))
onMounted(loadClients) onMounted(loadClients)
</script> </script>
<template> <template>
<div class="clients-page"> <PageShell title="客户端" eyebrow="Clients" subtitle="统一管理已注册节点、连接状态与快速安装命令,减少操作跳转。">
<!-- Particles --> <template #actions>
<div class="particles"> <button class="glass-btn" :disabled="generatingInstall" @click="openInstallModal">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<div class="clients-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">客户端管理</h1>
<p class="page-subtitle">管理所有连接的客户端</p>
</div>
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ clients.length }}</span>
<span class="stat-label">总客户端</span>
</div>
<div class="stat-card">
<span class="stat-value online">{{ onlineClients }}</span>
<span class="stat-label">在线</span>
</div>
<div class="stat-card">
<span class="stat-value offline">{{ clients.length - onlineClients }}</span>
<span class="stat-label">离线</span>
</div>
</div>
<!-- Client List -->
<div class="glass-card">
<div class="card-header">
<h3>客户端列表</h3>
<div style="display: flex; gap: 8px;">
<button class="glass-btn small" @click="openInstallModal" :disabled="generatingInstall">
{{ generatingInstall ? '生成中...' : '安装命令' }} {{ generatingInstall ? '生成中...' : '安装命令' }}
</button> </button>
<button class="glass-btn small" @click="loadClients">刷新</button> <button class="glass-btn primary" @click="loadClients">{{ loading ? '刷新中...' : '刷新列表' }}</button>
</div> </template>
</div>
<div class="card-body"> <template #metrics>
<div v-if="loading" class="loading-state">加载中...</div> <MetricCard label="客户端总数" :value="clients.length" hint="已接入的全部节点" />
<div v-else-if="clients.length === 0" class="empty-state"> <MetricCard label="在线节点" :value="onlineClients" hint="可立即推送配置" tone="success" />
<p>暂无客户端连接</p> <MetricCard label="离线节点" :value="offlineClients" hint="等待心跳恢复" tone="warning" />
<p class="empty-hint">等待客户端连接...</p> <MetricCard label="当前筛选结果" :value="filteredClients.length" hint="支持 ID / 昵称 / 地址搜索" tone="info" />
</div> </template>
<div v-else class="clients-grid">
<div <SectionCard title="节点列表" description="使用统一卡片样式展示连接信息,便于快速判断状态与进入详情页。">
v-for="client in clients" <template #header>
:key="client.id" <input v-model="search" class="glass-input search-input" type="search" placeholder="搜索 ID / 昵称 / 地址" />
class="client-card" </template>
@click="viewClient(client.id)"
> <div v-if="loading" class="empty-state">正在加载客户端列表...</div>
<div class="client-header"> <div v-else-if="filteredClients.length === 0" class="empty-state">未找到匹配的客户端</div>
<div class="client-status" :class="{ online: client.online }"></div> <div v-else class="client-grid">
<h4 class="client-name">{{ client.nickname || client.id }}</h4> <article v-for="client in filteredClients" :key="client.id" class="client-card" @click="router.push(`/client/${client.id}`)">
</div> <div class="client-card__header">
<p v-if="client.nickname" class="client-id">{{ client.id }}</p> <div>
<div class="client-info"> <div class="client-card__title">
<span v-if="client.remote_addr && client.online">{{ client.remote_addr }}</span> <span class="status-dot" :class="{ online: client.online }"></span>
<span>{{ client.rule_count || 0 }} 条规则</span> <strong>{{ client.nickname || client.id }}</strong>
</div>
<div class="client-tag" :class="client.online ? 'online' : 'offline'">
{{ client.online ? '在线' : '离线' }}
</div>
<!-- Heartbeat indicator -->
<div class="heartbeat-indicator" :class="{ online: client.online, offline: !client.online }">
<span class="heartbeat-dot"></span>
</div>
</div>
</div>
</div> </div>
<p>{{ client.nickname ? client.id : client.remote_addr || '等待首次连接' }}</p>
</div> </div>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div> </div>
<!-- Install Command Modal --> <dl class="client-card__meta">
<div v-if="showInstallModal" class="modal-overlay" @click="closeInstallModal"> <div>
<div class="modal-content" @click.stop> <dt>地址</dt>
<div class="modal-header"> <dd>{{ client.remote_addr || '未上报' }}</dd>
<h3>客户端安装命令</h3>
<button class="close-btn" @click="closeInstallModal">×</button>
</div> </div>
<div class="modal-body" v-if="installData"> <div>
<p class="install-hint">选择您的操作系统复制命令并在目标机器上执行</p> <dt>规则数</dt>
<div class="install-section"> <dd>{{ client.rule_count || 0 }}</dd>
<h4>Linux</h4>
<div class="command-box">
<code>{{ installData.commands.linux }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.linux)">复制</button>
</div>
</div>
<div class="install-section">
<h4>macOS</h4>
<div class="command-box">
<code>{{ installData.commands.macos }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.macos)">复制</button>
</div>
</div>
<div class="install-section">
<h4>Windows</h4>
<div class="command-box">
<code>{{ installData.commands.windows }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.windows)">复制</button>
</div>
</div>
<p class="token-info">客户端ID: <strong>{{ installClientId }}</strong></p>
<p class="token-warning"> 此命令包含一次性token使用后需重新生成</p>
</div> </div>
<div>
<dt>平台</dt>
<dd>{{ [client.os, client.arch].filter(Boolean).join(' / ') || '未知' }}</dd>
</div> </div>
</dl>
</article>
</div> </div>
</SectionCard>
<GlassModal :show="showInstallModal" title="安装命令" width="760px" @close="showInstallModal = false">
<div v-if="installCommands" class="install-grid">
<article v-for="item in [
{ label: 'Linux', value: installCommands.linux },
{ label: 'macOS', value: installCommands.macos },
{ label: 'Windows', value: installCommands.windows },
]" :key="item.label" class="install-card">
<header>
<strong>{{ item.label }}</strong>
<button class="glass-btn small" @click="copyCommand(item.value)">复制</button>
</header>
<code>{{ item.value }}</code>
</article>
</div> </div>
<template #footer>
<span class="install-footnote">命令内含一次性 token使用后请重新生成</span>
</template>
</GlassModal>
</PageShell>
</template> </template>
<style scoped> <style scoped>
.clients-page { .search-input {
min-height: calc(100vh - 116px); min-width: min(320px, 100%);
position: relative;
overflow: hidden;
padding: 32px;
} }
/* 动画背景粒子 */ .client-grid {
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.clients-content {
position: relative;
z-index: 10;
max-width: 1200px;
margin: 0 auto;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Stats */
.stats-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 24px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.stat-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
padding: 20px;
text-align: center;
box-shadow: var(--shadow-card);
transition: all 0.2s ease;
position: relative;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-value.online { color: var(--color-success); }
.stat-value.offline { color: var(--color-text-muted); }
.stat-label {
font-size: 13px;
color: var(--color-text-secondary);
}
/* Glass Card */
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
position: relative;
}
.glass-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-body { padding: 20px; }
.loading-state, .empty-state {
text-align: center;
padding: 48px;
color: var(--color-text-muted);
}
.empty-hint {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 8px;
}
/* Clients Grid */
.clients-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 900px) {
.clients-grid { grid-template-columns: repeat(2, 1fr); }
.stats-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.clients-grid { grid-template-columns: 1fr; }
} }
.client-card { .client-card {
background: var(--glass-bg-light);
border-radius: 12px;
padding: 18px; padding: 18px;
border: 1px solid var(--color-border); border-radius: 18px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
position: relative;
} }
.client-card:hover { .client-card:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px); transform: translateY(-2px);
border-color: rgba(59, 130, 246, 0.24);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.client-header { .client-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.client-card__title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.client-status { .client-card__header p {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
}
.client-status.online {
background: var(--color-success);
box-shadow: 0 0 10px var(--color-success-glow);
}
.client-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.client-id {
font-size: 12px;
color: var(--color-text-muted);
margin: 0 0 8px 0;
font-family: monospace;
}
.client-info {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin-bottom: 12px;
}
.client-tag {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.client-tag.online {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.client-tag.offline {
background: var(--glass-bg-light);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
/* Button */
.glass-btn {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur-light);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px; font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.glass-btn:hover {
background: var(--glass-bg-hover);
transform: translateY(-1px);
}
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
/* Heartbeat Indicator */
.heartbeat-indicator {
position: absolute;
top: 18px;
right: 18px;
} }
.heartbeat-dot { .status-dot {
display: block;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 999px;
background: var(--color-error); background: var(--color-error);
} }
.heartbeat-indicator.online .heartbeat-dot { .status-dot.online {
background: var(--color-success); background: var(--color-success);
animation: heartbeat-pulse 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--color-success-glow);
} }
.heartbeat-indicator.offline .heartbeat-dot { .state-pill {
background: var(--color-error); height: fit-content;
animation: none; padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
} }
@keyframes heartbeat-pulse { .state-pill.online {
0%, 100% { color: var(--color-success);
box-shadow: 0 0 0 0 var(--color-success-glow); background: rgba(16, 185, 129, 0.12);
transform: scale(1); border-color: rgba(16, 185, 129, 0.2);
}
50% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
transform: scale(1.1);
}
} }
/* Install Modal */ .state-pill.offline {
.modal-overlay { color: var(--color-text-secondary);
position: fixed; background: rgba(148, 163, 184, 0.12);
inset: 0; border-color: rgba(148, 163, 184, 0.2);
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
} }
.modal-content { .client-card__meta {
background: var(--glass-bg); display: grid;
backdrop-filter: blur(20px); grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid var(--glass-border); gap: 12px;
}
.client-card__meta dt {
margin-bottom: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
.client-card__meta dd {
color: var(--color-text-primary);
font-size: 13px;
word-break: break-word;
}
.install-grid {
display: grid;
gap: 14px;
}
.install-card {
padding: 16px;
border-radius: 16px; border-radius: 16px;
max-width: 800px; background: var(--glass-bg-light);
width: 90%; border: 1px solid var(--color-border-light);
max-height: 80vh;
overflow-y: auto;
} }
.modal-header { .install-card header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 24px; gap: 12px;
border-bottom: 1px solid var(--glass-border); margin-bottom: 12px;
} }
.modal-header h3 { .install-card code {
margin: 0; display: block;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--color-text-secondary);
line-height: 1;
}
.modal-body {
padding: 24px;
}
.install-hint {
margin-bottom: 20px;
color: var(--color-text-secondary);
}
.install-section {
margin-bottom: 20px;
}
.install-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--color-text-primary); color: var(--color-text-primary);
} white-space: pre-wrap;
.command-box {
display: flex;
gap: 8px;
background: rgba(0, 0, 0, 0.3);
padding: 12px;
border-radius: 8px;
border: 1px solid var(--glass-border);
}
.command-box code {
flex: 1;
font-family: monospace;
font-size: 12px;
word-break: break-all; word-break: break-all;
color: var(--color-text-primary);
}
.copy-btn {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px; font-size: 12px;
cursor: pointer; line-height: 1.7;
color: var(--color-text-primary);
white-space: nowrap;
} }
.copy-btn:hover { .install-footnote {
background: var(--glass-bg-hover); color: var(--color-text-secondary);
font-size: 12px;
} }
.token-info { .empty-state {
margin-top: 20px; padding: 48px 20px;
padding: 12px; text-align: center;
background: rgba(59, 130, 246, 0.1); color: var(--color-text-secondary);
border-radius: 8px; background: var(--glass-bg-light);
font-size: 13px; border: 1px dashed var(--color-border);
border-radius: 16px;
} }
.token-warning { @media (max-width: 768px) {
margin-top: 12px; .client-card__header {
padding: 12px; flex-direction: column;
background: rgba(245, 158, 11, 0.1); }
border-radius: 8px;
font-size: 13px;
color: #f59e0b;
}
.client-card__meta {
grid-template-columns: 1fr;
}
}
</style> </style>

View File

@@ -1,653 +1,324 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api' import { getClients, getTrafficHourly, getTrafficStats, type TrafficRecord } from '../api'
import type { ClientStatus } from '../types' import type { ClientStatus } from '../types'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
const clients = ref<ClientStatus[]>([]) const clients = ref<ClientStatus[]>([])
// 流量统计数据
const traffic24h = ref({ inbound: 0, outbound: 0 }) const traffic24h = ref({ inbound: 0, outbound: 0 })
const trafficTotal = ref({ inbound: 0, outbound: 0 }) const trafficTotal = ref({ inbound: 0, outbound: 0 })
const trafficHistory = ref<TrafficRecord[]>([]) const trafficHistory = ref<TrafficRecord[]>([])
const loading = ref(true)
// 格式化字节数
const formatBytes = (bytes: number): { value: string; unit: string } => { const formatBytes = (bytes: number): { value: string; unit: string } => {
if (bytes === 0) return { value: '0', unit: 'B' } if (bytes === 0) return { value: '0', unit: 'B' }
const k = 1024 const units = ['B', 'KB', 'MB', 'GB', 'TB']
const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB'] const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
return { return {
value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(), value: (bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1),
unit: sizes[i] as string unit: units[index] ?? 'B',
} }
} }
// 加载流量统计 const loadDashboard = async () => {
const loadTrafficStats = async () => { loading.value = true
try { try {
const { data } = await getTrafficStats() const [{ data: clientData }, { data: statsData }, { data: hourlyData }] = await Promise.all([
traffic24h.value = data.traffic_24h getClients(),
trafficTotal.value = data.traffic_total getTrafficStats(),
} catch (e) { getTrafficHourly(),
console.error('Failed to load traffic stats', e) ])
}
} clients.value = clientData || []
traffic24h.value = statsData.traffic_24h
trafficTotal.value = statsData.traffic_total
const records = hourlyData.records || []
if (records.length) {
trafficHistory.value = records.slice(-12)
return
}
// 加载每小时流量
const loadTrafficHourly = async () => {
try {
const { data } = await getTrafficHourly()
const records = data.records || []
// 如果没有数据生成从当前时间开始的24小时空数据
if (records.length === 0) {
const now = new Date() const now = new Date()
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours()) trafficHistory.value = Array.from({ length: 12 }, (_, index) => {
const emptyRecords: TrafficRecord[] = [] const slot = new Date(now.getTime() - (11 - index) * 3600 * 1000)
for (let i = 23; i >= 0; i--) { return {
const ts = new Date(currentHour.getTime() - i * 3600 * 1000) timestamp: Math.floor(slot.getTime() / 1000),
emptyRecords.push({
timestamp: Math.floor(ts.getTime() / 1000),
inbound: 0, inbound: 0,
outbound: 0 outbound: 0,
}
}) })
} } catch (error) {
trafficHistory.value = emptyRecords console.error('Failed to load dashboard', error)
} else { } finally {
trafficHistory.value = records loading.value = false
}
} catch (e) {
console.error('Failed to load hourly traffic', e)
} }
} }
const loadClients = async () => { const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
try { const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const { data } = await getClients() const totalRules = computed(() => clients.value.reduce((sum, client) => sum + (client.rule_count || 0), 0))
clients.value = data || [] const topClients = computed(() => [...clients.value].sort((a, b) => Number(b.online) - Number(a.online)).slice(0, 6))
} catch (e) { const chartMax = computed(() => Math.max(...trafficHistory.value.flatMap((item) => [item.inbound, item.outbound]), 1))
console.error('Failed to load clients', e)
}
}
const onlineClients = computed(() => {
return clients.value.filter(client => client.online).length
})
const totalRules = computed(() => {
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
})
// 格式化后的流量统计
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound)) const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound)) const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound)) const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound)) const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
const formatHour = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
// Chart helpers onMounted(loadDashboard)
const maxTraffic = computed(() => {
const max = Math.max(
...trafficHistory.value.map(d => Math.max(d.inbound, d.outbound))
)
return max || 100
})
const getBarHeight = (value: number) => {
return (value / maxTraffic.value) * 100
}
// 格式化时间戳为小时
const formatHour = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.getHours().toString().padStart(2, '0') + ':00'
}
onMounted(() => {
loadClients()
loadTrafficStats()
loadTrafficHourly()
})
</script> </script>
<template> <template>
<div class="dashboard-container"> <PageShell title="控制台" eyebrow="Overview" subtitle="统一查看连接状态、流量趋势与客户端健康情况,减少页面层级并突出关键数据。">
<!-- Animated background particles --> <template #actions>
<div class="particles"> <button class="glass-btn" @click="loadDashboard">{{ loading ? '刷新中...' : '刷新数据' }}</button>
<div class="particle particle-1"></div> </template>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<div class="particle particle-4"></div>
<div class="particle particle-5"></div>
</div>
<!-- Main content --> <template #metrics>
<div class="dashboard-content"> <MetricCard label="在线客户端" :value="onlineClients" :hint="`离线 ${offlineClients} 台`" tone="success" />
<!-- Header --> <MetricCard label="代理规则" :value="totalRules" hint="全部客户端规则总数" />
<div class="dashboard-header"> <MetricCard
<h1 class="dashboard-title">仪表盘</h1> label="24H 出站"
<p class="dashboard-subtitle">监控隧道连接和流量状态</p> :value="formatted24hOutbound.value"
</div> :hint="formatted24hOutbound.unit"
tone="info"
/>
<MetricCard
label="总入站"
:value="formattedTotalInbound.value"
:hint="formattedTotalInbound.unit"
tone="warning"
/>
</template>
<!-- Stats Grid --> <div class="dashboard-grid">
<div class="stats-grid"> <SectionCard title="流量趋势" description="近 12 小时入站 / 出站流量概览。">
<!-- 24H Traffic Combined --> <div class="traffic-summary">
<div class="stat-card glass-stat"> <div class="traffic-pill">
<div class="stat-icon-large traffic-24h"> <span>24H 入站</span>
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <strong>{{ formatted24hInbound.value }} {{ formatted24hInbound.unit }}</strong>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">24H 出站</span>
<div class="stat-data">
<span class="stat-number">{{ formatted24hOutbound.value }}</span>
<span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">24H 入站</span>
<div class="stat-data">
<span class="stat-number">{{ formatted24hInbound.value }}</span>
<span class="stat-unit">{{ formatted24hInbound.unit }}</span>
</div> </div>
<div class="traffic-pill">
<span>24H 出站</span>
<strong>{{ formatted24hOutbound.value }} {{ formatted24hOutbound.unit }}</strong>
</div> </div>
<div class="traffic-pill">
<span>总出站</span>
<strong>{{ formattedTotalOutbound.value }} {{ formattedTotalOutbound.unit }}</strong>
</div> </div>
</div> </div>
<!-- Total Traffic Combined --> <div class="traffic-chart">
<div class="stat-card glass-stat"> <div v-for="item in trafficHistory" :key="item.timestamp" class="traffic-chart__item">
<div class="stat-icon-large traffic-total"> <div class="traffic-chart__bars">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <span class="bar bar--inbound" :style="{ height: `${(item.inbound / chartMax) * 100}%` }"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <span class="bar bar--outbound" :style="{ height: `${(item.outbound / chartMax) * 100}%` }"></span>
</svg>
</div>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">总出站</span>
<div class="stat-data">
<span class="stat-number">{{ formattedTotalOutbound.value }}</span>
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">总入站</span>
<div class="stat-data">
<span class="stat-number">{{ formattedTotalInbound.value }}</span>
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
</div>
</div> </div>
<span class="traffic-chart__label">{{ formatHour(item.timestamp) }}</span>
</div> </div>
</div> </div>
</SectionCard>
<!-- Client Count --> <SectionCard title="客户端概况" description="优先展示在线客户端,并保留连接来源与规则数量。">
<div class="stat-card glass-stat"> <div v-if="topClients.length" class="client-list">
<div class="stat-icon-large clients"> <article v-for="client in topClients" :key="client.id" class="client-row">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /> <div class="client-row__title">
</svg> <span class="client-dot" :class="{ online: client.online }"></span>
</div> <strong>{{ client.nickname || client.id }}</strong>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">在线客户端</span>
<div class="stat-data">
<span class="stat-number online">{{ onlineClients }}</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">总客户端</span>
<div class="stat-data">
<span class="stat-number">{{ clients.length }}</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
<div class="online-indicator" :class="{ active: onlineClients > 0 }">
<span class="pulse"></span>
</div>
</div>
<!-- Rules Count -->
<div class="stat-card glass-stat">
<div class="stat-icon-large rules">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row single">
<span class="stat-title">代理规则</span>
<div class="stat-data">
<span class="stat-number">{{ totalRules }}</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Traffic Chart Section -->
<div class="chart-section">
<div class="section-header">
<h2 class="section-title">24小时流量趋势</h2>
<div class="chart-legend">
<span class="legend-item inbound"><span class="legend-dot"></span>入站</span>
<span class="legend-item outbound"><span class="legend-dot"></span>出站</span>
</div>
</div>
<div class="chart-card glass-card">
<div class="chart-container">
<div class="chart-bars">
<div v-for="(data, index) in trafficHistory" :key="index" class="bar-group">
<div class="bar-wrapper">
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
</div>
<span class="bar-label">{{ formatHour(data.timestamp) }}</span>
</div>
</div>
</div> </div>
<p>{{ client.remote_addr || '等待连接地址' }}</p>
</div> </div>
<div class="client-row__meta">
<span>{{ client.rule_count || 0 }} 条规则</span>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div> </div>
</article>
</div> </div>
<div v-else class="empty-state">暂无客户端数据</div>
</SectionCard>
</div> </div>
</PageShell>
</template> </template>
<style scoped> <style scoped>
/* Container */ .dashboard-grid {
.dashboard-container {
min-height: calc(100vh - 116px);
position: relative;
overflow: hidden;
padding: 32px;
}
/* 动画背景粒子 */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 400px;
height: 400px;
background: var(--color-accent);
top: -100px;
right: -100px;
}
.particle-2 {
width: 300px;
height: 300px;
background: #8b5cf6;
bottom: -50px;
left: -50px;
animation-delay: -5s;
}
.particle-3 {
width: 250px;
height: 250px;
background: var(--color-info);
top: 50%;
left: 50%;
animation-delay: -10s;
}
.particle-4 {
width: 200px;
height: 200px;
background: var(--color-success);
bottom: 20%;
right: 20%;
animation-delay: -15s;
}
.particle-5 {
width: 350px;
height: 350px;
background: #ec4899;
top: 30%;
left: 10%;
animation-delay: -7s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
/* Main content */
.dashboard-content {
position: relative;
z-index: 10;
max-width: 1200px;
margin: 0 auto;
}
.dashboard-header {
margin-bottom: 32px;
}
.dashboard-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.dashboard-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Stats Grid */
.stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px; gap: 20px;
margin-bottom: 32px; grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
} }
@media (max-width: 1024px) { .traffic-summary {
.stats-grid { display: grid;
grid-template-columns: repeat(2, 1fr); gap: 12px;
} grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
} }
@media (max-width: 640px) { .traffic-pill {
.stats-grid { padding: 14px 16px;
grid-template-columns: 1fr;
}
}
/* Glass stat card - 毛玻璃效果 */
.glass-stat {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--color-border); background: var(--glass-bg-light);
padding: 20px; border: 1px solid var(--color-border-light);
display: flex;
align-items: center;
gap: 16px;
position: relative;
transition: all 0.2s ease;
box-shadow: var(--shadow-card);
} }
/* 卡片顶部高光 */ .traffic-pill span {
.glass-stat::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.glass-stat:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
/* Large Stat icon */
.stat-icon-large {
width: 64px;
height: 64px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.stat-icon-large.traffic-24h {
background: var(--gradient-accent);
}
.stat-icon-large.traffic-total {
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
}
.stat-icon-large.clients {
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
}
.stat-icon-large.rules {
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
}
.icon-lg {
width: 32px;
height: 32px;
color: white;
}
/* Stat details */
.stat-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-row.single {
padding: 12px 0;
}
.stat-title {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 500;
}
.stat-data {
display: flex;
align-items: baseline;
gap: 4px;
}
.stat-number {
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-number.online {
color: var(--color-success);
}
.stat-unit {
font-size: 12px;
color: var(--color-text-muted);
}
/* Online indicator with pulse */
.online-indicator {
position: absolute;
top: 16px;
right: 16px;
}
.online-indicator .pulse {
display: block; display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
}
.online-indicator.active .pulse {
background: var(--color-success);
animation: pulse-animation 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--color-success-glow);
}
@keyframes pulse-animation {
0%, 100% { box-shadow: 0 0 0 0 var(--color-success-glow); }
50% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
}
/* Chart Section */
.chart-section {
margin-top: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.chart-legend {
display: flex;
gap: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12px;
} }
.legend-dot { .traffic-pill strong {
width: 10px; display: block;
height: 10px; margin-top: 8px;
border-radius: 2px; color: var(--color-text-primary);
font-size: 18px;
} }
.legend-item.inbound .legend-dot { .traffic-chart {
background: #8b5cf6; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: end;
min-height: 240px;
} }
.legend-item.outbound .legend-dot { .traffic-chart__item {
background: var(--color-accent);
}
/* Chart Card */
.chart-card {
padding: 24px;
}
.chart-container {
height: 200px;
overflow-x: auto;
}
.chart-bars {
display: flex;
gap: 4px;
height: 100%;
min-width: 600px;
align-items: flex-end;
}
.bar-group {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
} }
.bar-wrapper { .traffic-chart__bars {
flex: 1;
width: 100%;
display: flex; display: flex;
gap: 2px; align-items: end;
align-items: flex-end; gap: 4px;
height: 200px;
width: 100%;
} }
.bar { .bar {
flex: 1; flex: 1;
border-radius: 3px 3px 0 0; min-height: 4px;
min-height: 2px; border-radius: 999px 999px 6px 6px;
transition: height 0.3s ease;
} }
.bar.inbound { .bar--inbound {
background: #8b5cf6; background: linear-gradient(180deg, rgba(6, 182, 212, 0.95), rgba(6, 182, 212, 0.25));
} }
.bar.outbound { .bar--outbound {
background: var(--color-accent); background: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(59, 130, 246, 0.25));
} }
.bar-label { .traffic-chart__label {
font-size: 10px; font-size: 11px;
color: var(--color-text-muted);
white-space: nowrap;
}
.chart-hint {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
text-align: center;
font-size: 12px;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* Glass card base - 毛玻璃效果 */ .client-list {
.glass-card { display: flex;
background: var(--glass-bg); flex-direction: column;
backdrop-filter: var(--glass-blur); gap: 12px;
-webkit-backdrop-filter: var(--glass-blur); }
.client-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--color-border); background: var(--glass-bg-light);
box-shadow: var(--shadow-card); border: 1px solid var(--color-border-light);
position: relative;
} }
.glass-card::before { .client-row__title {
content: ''; display: flex;
position: absolute; align-items: center;
top: 0; gap: 10px;
left: 10%; margin-bottom: 8px;
right: 10%; }
height: 1px;
background: linear-gradient(90deg, .client-row p {
transparent 0%, color: var(--color-text-secondary);
rgba(255, 255, 255, 0.1) 50%, font-size: 13px;
transparent 100%); }
.client-row__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
color: var(--color-text-secondary);
font-size: 13px;
}
.client-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--color-error);
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.08);
}
.client-dot.online {
background: var(--color-success);
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.1);
}
.state-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
.state-pill.online {
color: var(--color-success);
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.2);
}
.state-pill.offline {
color: var(--color-text-secondary);
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
}
.empty-state {
padding: 36px 16px;
text-align: center;
color: var(--color-text-secondary);
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.traffic-chart {
overflow-x: auto;
padding-bottom: 8px;
}
.traffic-chart__bars {
width: 28px;
}
.client-row {
flex-direction: column;
}
.client-row__meta {
align-items: flex-start;
}
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { login, setToken } from '../api' import { login, setToken } from '../api'
@@ -9,6 +9,14 @@ const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const features = [
'统一管理隧道、客户端与规则状态',
'自动下发配置,客户端零配置接入',
'内置更新与运行状态查看,便于运维排障',
]
const canSubmit = computed(() => Boolean(username.value && password.value) && !loading.value)
const handleLogin = async () => { const handleLogin = async () => {
if (!username.value || !password.value) { if (!username.value || !password.value) {
error.value = '请输入用户名和密码' error.value = '请输入用户名和密码'
@@ -17,7 +25,6 @@ const handleLogin = async () => {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const { data } = await login(username.value, password.value) const { data } = await login(username.value, password.value)
setToken(data.token) setToken(data.token)
@@ -32,65 +39,39 @@ const handleLogin = async () => {
<template> <template>
<div class="login-page"> <div class="login-page">
<!-- Animated particles --> <div class="login-shell glass-card">
<div class="particles"> <section class="login-hero">
<div class="particle particle-1"></div> <span class="login-badge">GoTunnel Console</span>
<div class="particle particle-2"></div> <h1>更统一更轻量的管理界面</h1>
<div class="particle particle-3"></div> <p>聚焦连接状态更新能力与节点管理让日常操作更直观页面更简洁</p>
<div class="particle particle-4"></div> <ul>
<li v-for="item in features" :key="item">{{ item }}</li>
</ul>
</section>
<section class="login-panel">
<div class="login-panel__header">
<h2>登录控制台</h2>
<p>使用服务端配置的 Web 账号进入管理界面</p>
</div> </div>
<!-- Login card --> <form class="login-form" @submit.prevent="handleLogin">
<div class="login-card"> <label class="form-group">
<div class="login-header"> <span>用户名</span>
<div class="logo-icon"> <input v-model="username" class="glass-input" type="text" autocomplete="username" placeholder="请输入用户名" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </label>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <label class="form-group">
</svg> <span>密码</span>
</div> <input v-model="password" class="glass-input" type="password" autocomplete="current-password" placeholder="请输入密码" />
<h1 class="logo-text">GoTunnel</h1> </label>
<p class="subtitle">安全的内网穿透工具</p>
</div>
<form @submit.prevent="handleLogin" class="login-form"> <div v-if="error" class="error-alert">{{ error }}</div>
<div class="form-group">
<label class="form-label">用户名</label>
<input
v-model="username"
type="text"
class="glass-input"
placeholder="请输入用户名"
:disabled="loading"
/>
</div>
<div class="form-group"> <button class="glass-btn primary submit-btn" type="submit" :disabled="!canSubmit">
<label class="form-label">密码</label> {{ loading ? '登录中...' : '进入控制台' }}
<input
v-model="password"
type="password"
class="glass-input"
placeholder="请输入密码"
:disabled="loading"
/>
</div>
<div v-if="error" class="error-alert">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
<button type="submit" class="glass-button" :disabled="loading">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
</button> </button>
</form> </form>
</section>
<div class="login-footer">
<span>欢迎使用 GoTunnel</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -101,145 +82,94 @@ const handleLogin = async () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--gradient-bg); padding: 24px;
padding: 16px; }
position: relative;
.login-shell {
width: min(1080px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
overflow: hidden; overflow: hidden;
} }
/* 动画背景粒子 */ .login-hero,
.particles { .login-panel {
position: absolute; padding: 40px;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
} }
.particle { .login-hero {
position: absolute;
border-radius: 50%;
opacity: 0.2;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 400px;
height: 400px;
background: var(--color-accent);
top: -100px;
right: -100px;
}
.particle-2 {
width: 300px;
height: 300px;
background: #8b5cf6;
bottom: -50px;
left: -50px;
animation-delay: -5s;
}
.particle-3 {
width: 250px;
height: 250px;
background: var(--color-info);
top: 50%;
left: 20%;
animation-delay: -10s;
}
.particle-4 {
width: 200px;
height: 200px;
background: #ec4899;
bottom: 30%;
right: 10%;
animation-delay: -15s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
/* Login card - 毛玻璃效果 */
.login-card {
width: 100%;
max-width: 400px;
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 20px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
padding: 48px 36px;
position: relative;
z-index: 10;
}
/* 卡片顶部高光 */
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%);
}
/* Header */
.login-header {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
background: var(--gradient-accent);
border-radius: 16px;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; justify-content: center;
box-shadow: 0 8px 24px var(--color-accent-glow); gap: 18px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(139, 92, 246, 0.08));
border-right: 1px solid var(--color-border);
} }
.logo-icon svg { .login-badge {
color: white; width: fit-content;
width: 32px; padding: 6px 10px;
height: 32px; border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--color-accent);
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.18);
} }
.logo-text { .login-hero h1 {
font-size: 28px;
font-weight: 700;
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 8px 0;
}
.subtitle {
color: var(--color-text-secondary);
margin: 0; margin: 0;
font-size: 14px; font-size: clamp(34px, 4.5vw, 54px);
line-height: 1.1;
letter-spacing: -0.05em;
}
.login-hero p {
margin: 0;
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.8;
}
.login-hero ul {
display: grid;
gap: 12px;
padding: 0;
margin: 8px 0 0;
list-style: none;
}
.login-hero li {
padding: 14px 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
color: var(--color-text-primary);
}
.login-panel {
display: flex;
flex-direction: column;
justify-content: center;
gap: 26px;
}
.login-panel__header h2 {
margin: 0 0 8px;
font-size: 28px;
}
.login-panel__header p {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.7;
} }
/* Form */
.login-form { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
} }
.form-group { .form-group {
@@ -248,111 +178,44 @@ const handleLogin = async () => {
gap: 8px; gap: 8px;
} }
.form-label { .form-group span {
font-size: 14px; color: var(--color-text-secondary);
font-weight: 500;
color: var(--color-text-primary);
}
.glass-input {
background: var(--glass-bg-light);
backdrop-filter: var(--glass-blur-light);
-webkit-backdrop-filter: var(--glass-blur-light);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 14px 16px;
color: var(--color-text-primary);
font-size: 15px;
width: 100%;
transition: all 0.2s ease;
outline: none;
}
.glass-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-glow);
}
.glass-input::placeholder {
color: var(--color-text-muted);
}
.glass-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error alert */
.error-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 10px;
color: var(--color-error);
font-size: 14px;
}
.error-alert svg {
flex-shrink: 0;
}
/* Button */
.glass-button {
background: var(--gradient-accent);
border: none;
border-radius: 12px;
padding: 14px 24px;
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 15px var(--color-accent-glow);
}
.glass-button:hover:not(:disabled) {
box-shadow: 0 6px 20px var(--color-accent-glow);
transform: translateY(-2px);
filter: brightness(1.1);
}
.glass-button:active:not(:disabled) {
transform: translateY(0) scale(0.98);
}
.glass-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Loading spinner */
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Footer */
.login-footer {
text-align: center;
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 13px; font-size: 13px;
} }
.submit-btn {
justify-content: center;
width: 100%;
margin-top: 8px;
}
.error-alert {
padding: 12px 14px;
border-radius: 14px;
color: var(--color-error);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.18);
}
@media (max-width: 900px) {
.login-shell {
grid-template-columns: 1fr;
}
.login-hero {
border-right: none;
border-bottom: 1px solid var(--color-border);
}
}
@media (max-width: 640px) {
.login-page {
padding: 16px;
}
.login-hero,
.login-panel {
padding: 28px 22px;
}
}
</style> </style>

View File

@@ -1,88 +1,90 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5' import MetricCard from '../components/MetricCard.vue'
import { useToast } from '../composables/useToast' import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
import { import {
getVersionInfo, getServerConfig, updateServerConfig, getServerConfig,
type VersionInfo, type ServerConfigResponse getVersionInfo,
updateServerConfig,
type ServerConfigResponse,
type UpdateServerConfigRequest,
type VersionInfo,
} from '../api' } from '../api'
import { useToast } from '../composables/useToast'
const message = useToast() const message = useToast()
const versionInfo = ref<VersionInfo | null>(null) const versionInfo = ref<VersionInfo | null>(null)
const loading = ref(true)
// 服务器配置
const serverConfig = ref<ServerConfigResponse | null>(null) const serverConfig = ref<ServerConfigResponse | null>(null)
const configLoading = ref(false) const loadingVersion = ref(true)
const savingConfig = ref(false) const loadingConfig = ref(true)
const saving = ref(false)
// 配置表单
const configForm = ref({ const configForm = ref({
heartbeat_sec: 30, heartbeat_sec: 30,
heartbeat_timeout: 90, heartbeat_timeout: 90,
web_username: '', web_username: '',
web_password: '', web_password: '',
plugin_store_url: ''
}) })
const loadVersionInfo = async () => { const loadVersionInfo = async () => {
loadingVersion.value = true
try { try {
const { data } = await getVersionInfo() const { data } = await getVersionInfo()
versionInfo.value = data versionInfo.value = data
} catch (e) { } catch (error) {
console.error('Failed to load version info', e) console.error('Failed to load version info', error)
} finally { } finally {
loading.value = false loadingVersion.value = false
} }
} }
const loadServerConfig = async () => { const loadServerConfig = async () => {
configLoading.value = true loadingConfig.value = true
try { try {
const { data } = await getServerConfig() const { data } = await getServerConfig()
serverConfig.value = data serverConfig.value = data
// 填充表单
configForm.value = { configForm.value = {
heartbeat_sec: data.server.heartbeat_sec, heartbeat_sec: data.server.heartbeat_sec,
heartbeat_timeout: data.server.heartbeat_timeout, heartbeat_timeout: data.server.heartbeat_timeout,
web_username: data.web.username, web_username: data.web.username,
web_password: '', web_password: '',
plugin_store_url: data.plugin_store.url
} }
} catch (e) { } catch (error) {
console.error('Failed to load server config', e) console.error('Failed to load server config', error)
message.error('服务器配置加载失败')
} finally { } finally {
configLoading.value = false loadingConfig.value = false
} }
} }
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
savingConfig.value = true saving.value = true
try { try {
const updateReq: any = { const payload: UpdateServerConfigRequest = {
server: { server: {
heartbeat_sec: configForm.value.heartbeat_sec, heartbeat_sec: configForm.value.heartbeat_sec,
heartbeat_timeout: configForm.value.heartbeat_timeout heartbeat_timeout: configForm.value.heartbeat_timeout,
}, },
web: { web: {
username: configForm.value.web_username username: configForm.value.web_username,
}, },
plugin_store: {
url: configForm.value.plugin_store_url
} }
}
// 只有填写了密码才更新
if (configForm.value.web_password) { if (configForm.value.web_password) {
updateReq.web.password = configForm.value.web_password payload.web = {
...payload.web,
password: configForm.value.web_password,
} }
await updateServerConfig(updateReq) }
message.success('配置已保存,部分配置需要重启服务后生效')
await updateServerConfig(payload)
configForm.value.web_password = '' configForm.value.web_password = ''
} catch (e: any) { message.success('配置已保存,部分配置需要重启后生效')
message.error(e.response?.data || '保存配置失败') } catch (error: any) {
message.error(error.response?.data || '保存配置失败')
} finally { } finally {
savingConfig.value = false saving.value = false
} }
} }
@@ -93,458 +95,122 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="settings-page"> <PageShell title="系统设置" eyebrow="Settings" subtitle="统一整理运行版本与服务配置,减少样式重复并保留关键运维操作。">
<!-- Particles --> <template #actions>
<div class="particles"> <button class="glass-btn" @click="loadVersionInfo">刷新版本</button>
<div class="particle particle-1"></div> <button class="glass-btn primary" :disabled="saving" @click="handleSaveConfig">{{ saving ? '保存中...' : '保存配置' }}</button>
<div class="particle particle-2"></div> </template>
<div class="particle particle-3"></div>
</div>
<div class="settings-content"> <template #metrics>
<!-- Header --> <MetricCard label="当前版本" :value="versionInfo?.version || '—'" :hint="versionInfo?.git_commit?.slice(0, 8) || '未知提交'" />
<div class="page-header"> <MetricCard label="Go 版本" :value="versionInfo?.go_version || '—'" hint="运行时版本" tone="info" />
<h1 class="page-title">系统设置</h1> <MetricCard label="运行平台" :value="versionInfo ? `${versionInfo.os}/${versionInfo.arch}` : '—'" hint="服务端当前平台" tone="success" />
<p class="page-subtitle">管理服务端配置和系统更新</p> <MetricCard label="Web 用户名" :value="configForm.web_username || '—'" hint="控制台登录账号" tone="warning" />
</div> </template>
<!-- Version Info Card --> <div class="settings-grid">
<div class="glass-card"> <SectionCard title="版本信息" description="查看当前服务端构建信息,方便排查环境与升级状态。">
<div class="card-header"> <div v-if="loadingVersion" class="empty-state">正在加载版本信息...</div>
<h3>版本信息</h3> <dl v-else-if="versionInfo" class="info-grid">
<ServerOutline class="header-icon" /> <div><dt>版本号</dt><dd>{{ versionInfo.version }}</dd></div>
</div> <div><dt>Git 提交</dt><dd>{{ versionInfo.git_commit || 'N/A' }}</dd></div>
<div class="card-body"> <div><dt>构建时间</dt><dd>{{ versionInfo.build_time || 'N/A' }}</dd></div>
<div v-if="loading" class="loading-state">加载中...</div> <div><dt>Go 版本</dt><dd>{{ versionInfo.go_version }}</dd></div>
<div v-else-if="versionInfo" class="info-grid"> <div><dt>操作系统</dt><dd>{{ versionInfo.os }}</dd></div>
<div class="info-item"> <div><dt>架构</dt><dd>{{ versionInfo.arch }}</dd></div>
<span class="info-label">版本号</span> </dl>
<span class="info-value">{{ versionInfo.version }}</span> <div v-else class="empty-state">无法获取版本信息</div>
</div> </SectionCard>
<div class="info-item">
<span class="info-label">Git 提交</span>
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">构建时间</span>
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">Go 版本</span>
<span class="info-value">{{ versionInfo.go_version }}</span>
</div>
<div class="info-item">
<span class="info-label">操作系统</span>
<span class="info-value">{{ versionInfo.os }}</span>
</div>
<div class="info-item">
<span class="info-label">架构</span>
<span class="info-value">{{ versionInfo.arch }}</span>
</div>
</div>
<div v-else class="empty-state">无法加载版本信息</div>
</div>
</div>
<!-- Server Config Card --> <SectionCard title="服务配置" description="保留最常用的心跳与登录项配置,页面结构更精简。">
<div class="glass-card"> <div v-if="loadingConfig" class="empty-state">正在加载服务器配置...</div>
<div class="card-header"> <form v-else class="config-form" @submit.prevent="handleSaveConfig">
<h3>服务器配置</h3> <label class="form-group">
<SettingsOutline class="header-icon" /> <span>心跳间隔</span>
</div> <input v-model.number="configForm.heartbeat_sec" class="glass-input" min="1" max="300" type="number" />
<div class="card-body"> </label>
<div v-if="configLoading" class="loading-state">加载中...</div> <label class="form-group">
<div v-else-if="serverConfig" class="config-form"> <span>心跳超时</span>
<div class="form-row"> <input v-model.number="configForm.heartbeat_timeout" class="glass-input" min="1" max="600" type="number" />
<div class="form-group"> </label>
<label class="form-label">心跳间隔 ()</label> <label class="form-group form-group--full">
<input <span>Web 用户名</span>
v-model.number="configForm.heartbeat_sec" <input v-model="configForm.web_username" class="glass-input" type="text" placeholder="admin" />
type="number" </label>
class="glass-input" <label class="form-group form-group--full">
min="1" <span>Web 密码</span>
max="300" <input v-model="configForm.web_password" class="glass-input" type="password" placeholder="留空则保持不变" />
/> </label>
</div> </form>
<div class="form-group"> </SectionCard>
<label class="form-label">心跳超时 ()</label>
<input
v-model.number="configForm.heartbeat_timeout"
type="number"
class="glass-input"
min="1"
max="600"
/>
</div>
</div>
<div class="form-divider"></div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Web 用户名</label>
<input
v-model="configForm.web_username"
type="text"
class="glass-input"
placeholder="admin"
/>
</div>
<div class="form-group">
<label class="form-label">Web 密码</label>
<input
v-model="configForm.web_password"
type="password"
class="glass-input"
placeholder="留空则不修改"
/>
</div>
</div>
<div class="form-divider"></div>
<div class="form-group">
<label class="form-label">插件商店地址</label>
<input
v-model="configForm.plugin_store_url"
type="text"
class="glass-input"
placeholder="https://git.92coco.cn/flik/GoTunnel-Plugins/raw/branch/main/store.json"
/>
<span class="form-hint">插件商店的 API 地址留空使用默认地址</span>
</div>
<div class="form-actions">
<button
class="glass-btn primary"
:disabled="savingConfig"
@click="handleSaveConfig"
>
<SaveOutline class="btn-icon" />
保存配置
</button>
</div>
</div>
<div v-else class="empty-state">无法加载配置信息</div>
</div>
</div>
</div>
</div> </div>
</PageShell>
</template> </template>
<style scoped> <style scoped>
.settings-page { .settings-grid {
min-height: calc(100vh - 108px); display: grid;
background: transparent; gap: 20px;
position: relative; grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
padding: 32px;
} }
/* Hide particles */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.settings-content {
position: relative;
z-index: 10;
max-width: 900px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Glass Card */
.glass-card {
background: var(--color-bg-tertiary);
border-radius: 12px;
border: 1px solid var(--color-border);
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-body {
padding: 20px;
}
/* Info Grid */
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); gap: 14px;
gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@media (max-width: 600px) { .info-grid div,
.info-grid { grid-template-columns: repeat(2, 1fr); } .form-group {
padding: 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
} }
.info-item { .info-grid dt,
display: flex; .form-group span {
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 12px;
color: var(--color-text-muted);
}
.info-value {
font-size: 14px;
color: var(--color-text-primary);
font-weight: 500;
}
.info-value.mono {
font-family: monospace;
}
/* States */
.loading-state, .empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
/* Update Alert */
.update-alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
}
.update-alert.success {
background: rgba(0, 186, 124, 0.15);
border: 1px solid rgba(0, 186, 124, 0.3);
color: var(--color-success);
}
.update-alert.info {
background: rgba(29, 155, 240, 0.15);
border: 1px solid rgba(29, 155, 240, 0.3);
color: var(--color-info);
}
/* Download Info */
.download-info {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 12px;
}
/* Release Note */
.release-note {
margin-bottom: 16px;
}
.note-label {
display: block; display: block;
font-size: 12px; margin-bottom: 8px;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.release-note pre {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: var(--color-bg-elevated);
padding: 12px;
border-radius: 8px;
max-height: 150px;
overflow-y: auto;
}
/* Glass Button */
.glass-btn {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px; font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
} }
.glass-btn:hover:not(:disabled) { .info-grid dd {
background: var(--color-border); color: var(--color-text-primary);
word-break: break-word;
} }
.glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.glass-btn.small {
padding: 6px 12px;
font-size: 12px;
}
.glass-btn.primary {
background: var(--color-accent);
border: none;
}
.glass-btn.primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
/* Icon styles */
.header-icon {
width: 20px;
height: 20px;
color: var(--color-text-muted);
}
.btn-icon {
width: 14px;
height: 14px;
}
/* Config Form */
.config-form { .config-form {
display: flex; display: grid;
flex-direction: column; gap: 14px;
gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.form-label { .form-group--full {
font-size: 13px; grid-column: 1 / -1;
}
.empty-state {
padding: 48px 20px;
text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-weight: 500; background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
} }
.form-hint { @media (max-width: 960px) {
font-size: 11px; .settings-grid,
color: var(--color-text-muted); .info-grid,
} .config-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 500px) {
.form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.form-divider {
height: 1px;
background: var(--color-border-light);
margin: 8px 0;
}
.form-actions {
margin-top: 8px;
}
/* Glass Input */
.glass-input {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 14px;
color: var(--color-text-primary);
font-size: 14px;
outline: none;
transition: all 0.15s;
}
.glass-input:focus {
border-color: var(--color-accent);
}
.glass-input::placeholder {
color: var(--color-text-muted);
}
</style> </style>