Add SQLite support and install page

This commit is contained in:
2026-06-13 21:09:06 +00:00
parent a6f7852a10
commit c48a40d6da
22 changed files with 5865 additions and 219 deletions
+74 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -38,8 +39,23 @@ func main() {
}
defer logger.Sync()
// 检查是否已安装
if !cfg.Install.Installed {
logger.Info("系统未安装,启动安装模式")
startInstallMode(cfg, logger)
return
}
// 连接数据库
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
var db *gorm.DB
if cfg.Database.Type == "sqlite" {
// SQLite 连接
db, err = gorm.Open(sqlite.Open(cfg.Database.DSN()), &gorm.Config{})
} else {
// PostgreSQL 连接
db, err = gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
}
if err != nil {
logger.Fatal("连接数据库失败", zap.Error(err))
}
@@ -53,6 +69,7 @@ func main() {
&models.IPChangeLog{},
&models.ConnectionLog{},
&models.IPRefreshRule{},
&models.InstallStatus{},
); err != nil {
logger.Fatal("数据库迁移失败", zap.Error(err))
}
@@ -100,7 +117,7 @@ func main() {
go startHealthCheck(ctx, repos.Node, healthChecker, logger, time.Duration(cfg.Scheduler.HealthCheckInterval)*time.Second)
// 启动 API 服务器
apiServer := NewAPIServer(cfg, repos, logger)
apiServer := NewAPIServer(cfg, repos, db, logger)
go func() {
if err := apiServer.Start(); err != nil && err != http.ErrServerClosed {
logger.Error("API 服务器错误", zap.Error(err))
@@ -128,6 +145,51 @@ func main() {
logger.Info("服务器已关闭")
}
// startInstallMode 启动安装模式
func startInstallMode(cfg *config.Config, logger *zap.Logger) {
gin.SetMode("release")
router := gin.New()
router.Use(gin.Recovery())
// 安装相关路由
api := router.Group("/api/v1")
{
api.GET("/install/check", handler.CheckInstall())
api.POST("/install", handler.DoInstall(logger))
api.GET("/install/status", handler.GetInstallStatus(nil))
}
// 静态文件服务(前端)
router.Static("/assets", "web/dist/assets")
router.StaticFile("/", "web/dist/index.html")
router.StaticFile("/favicon.ico", "web/dist/favicon.ico")
// 前端路由回退
router.NoRoute(func(c *gin.Context) {
c.File("web/dist/index.html")
})
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: router,
}
logger.Info("安装模式启动", zap.String("addr", server.Addr))
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("服务器错误", zap.Error(err))
}
}()
<-quit
logger.Info("安装模式关闭")
}
// SimpleAuthenticator 简单认证器
type SimpleAuthenticator struct {
userRepo *repository.UserRepository
@@ -253,18 +315,20 @@ func startHealthCheck(ctx context.Context, nodeRepo *repository.NodeRepository,
type APIServer struct {
cfg *config.Config
repos *repository.Repositories
db *gorm.DB
logger *zap.Logger
server *http.Server
router *gin.Engine
}
func NewAPIServer(cfg *config.Config, repos *repository.Repositories, logger *zap.Logger) *APIServer {
func NewAPIServer(cfg *config.Config, repos *repository.Repositories, db *gorm.DB, logger *zap.Logger) *APIServer {
gin.SetMode(cfg.Server.Mode)
router := gin.New()
server := &APIServer{
cfg: cfg,
repos: repos,
db: db,
logger: logger,
router: router,
server: &http.Server{
@@ -288,6 +352,13 @@ func (s *APIServer) setupRoutes() {
// API 路由组
api := s.router.Group("/api/v1")
{
// 安装相关
install := api.Group("/install")
{
install.GET("/check", handler.CheckInstall())
install.GET("/status", handler.GetInstallStatus(s.db))
}
// 用户相关
users := api.Group("/users")
{
+17 -24
View File
@@ -1,41 +1,34 @@
# 调度中心配置
server:
host: "0.0.0.0"
host: 0.0.0.0
port: 8080
mode: "debug" # debug, release
mode: release
socks5:
host: "0.0.0.0"
host: 0.0.0.0
port: 1080
max_connections: 10000
timeout: 30 # 秒
timeout: 30
database:
host: "localhost"
port: 5432
user: "postgres"
password: "postgres"
database: "proxy_platform"
sslmode: "disable"
type: sqlite
path: data/proxy.db
redis:
host: "localhost"
enabled: false
host: localhost
port: 6379
password: ""
db: 0
scheduler:
# 负载均衡策略
strategy: "least_latency" # least_latency, least_connections, weighted_round_robin
# 健康检查间隔
health_check_interval: 10 # 秒
# 解锁检测间隔
unlock_check_interval: 300 # 秒
# 节点超时阈值
node_timeout: 30 # 秒
strategy: least_latency
health_check_interval: 60
unlock_check_interval: 300
node_timeout: 120
logging:
level: "info" # debug, info, warn, error
output: "stdout" # stdout, file
file: "logs/scheduler.log"
level: info
output: stdout
install:
installed: false
+14 -12
View File
@@ -1,29 +1,31 @@
# 节点 Agent Dockerfile
FROM golang:1.21-alpine AS builder
# 构建阶段
FROM golang:1.22-alpine AS builder
WORKDIR /app
WORKDIR /build
RUN apk add --no-cache git
RUN apk add --no-cache git make gcc musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /agent ./cmd/agent
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o agent ./cmd/agent
FROM alpine:latest
# 运行阶段
FROM alpine:3.19
WORKDIR /app
RUN apk --no-cache add ca-certificates tzdata curl bash
RUN apk add --no-cache ca-certificates tzdata curl
# 安装 Cloudflare WARP
RUN curl -fsSL https://pkg.cloudflareclient.com/install.sh | bash
COPY --from=builder /build/agent .
COPY --from=builder /build/configs ./configs
COPY --from=builder /agent /app/agent
COPY --from=builder /app/configs /app/configs
RUN mkdir -p /app/data
EXPOSE 1080
ENTRYPOINT ["/app/agent"]
ENV TZ=Asia/Shanghai
CMD ["./agent"]
+21 -14
View File
@@ -1,12 +1,12 @@
# 调度中心 Dockerfile
FROM golang:1.21-alpine AS builder
# 构建阶段
FROM golang:1.22-alpine AS builder
WORKDIR /app
WORKDIR /build
# 安装依赖
RUN apk add --no-cache git
RUN apk add --no-cache git make gcc musl-dev
# 复制 go.mod 和 go.sum
# 复制 go mod 文件
COPY go.mod go.sum ./
RUN go mod download
@@ -14,22 +14,29 @@ RUN go mod download
COPY . .
# 构建
RUN CGO_ENABLED=0 GOOS=linux go build -o /scheduler ./cmd/scheduler
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o scheduler ./cmd/scheduler
# 运行镜像
FROM alpine:latest
# 运行阶段
FROM alpine:3.19
WORKDIR /app
# 安装 ca-certificates(用于 HTTPS
RUN apk --no-cache add ca-certificates tzdata
# 安装必要工具
RUN apk add --no-cache ca-certificates tzdata
# 从构建阶段复制二进制文件
COPY --from=builder /scheduler /app/scheduler
COPY --from=builder /app/configs /app/configs
COPY --from=builder /build/scheduler .
COPY --from=builder /build/configs ./configs
COPY --from=builder /build/web/dist ./web/dist
# 创建数据目录
RUN mkdir -p /app/data
# 暴露端口
EXPOSE 8080 1080
# 运行
ENTRYPOINT ["/app/scheduler"]
# 设置时区
ENV TZ=Asia/Shanghai
# 启动命令
CMD ["./scheduler"]
+22 -64
View File
@@ -1,71 +1,29 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: proxy-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: proxy_platform
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: proxy-redis
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
scheduler:
build:
context: .
dockerfile: deployments/Dockerfile.scheduler
container_name: proxy-scheduler
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- CONFIG_PATH=/app/configs/scheduler.yaml
volumes:
- ./configs:/app/configs
context: ..
dockerfile: Dockerfile.scheduler
image: ${SCHEDULER_IMAGE:-proxy-scheduler:latest}
container_name: ${SCHEDULER_CONTAINER:-proxy-scheduler}
restart: unless-stopped
ports:
- "8080:8080" # API
- "1080:1080" # SOCKS5
restart: unless-stopped
# 示例节点 Agent(可选)
agent:
build:
context: .
dockerfile: deployments/Dockerfile.agent
container_name: proxy-agent
depends_on:
- scheduler
environment:
- CONFIG_PATH=/app/configs/agent.yaml
- "${API_PORT:-8080}:8080"
- "${SOCKS5_PORT:-1080}:1080"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./configs:/app/configs
restart: unless-stopped
profiles:
- agent # 使用 --profile agent 启动
environment:
- TZ=Asia/Shanghai
networks:
- proxy-network
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
postgres_data:
redis_data:
networks:
proxy-network:
driver: bridge
+5 -4
View File
@@ -4,13 +4,13 @@ go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.17.0
golang.org/x/net v0.19.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
)
require (
@@ -35,6 +35,7 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -51,9 +52,9 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+10 -8
View File
@@ -28,8 +28,6 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -64,6 +62,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -124,14 +124,14 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
@@ -146,6 +146,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+54 -23
View File
@@ -8,12 +8,13 @@ import (
// Config 调度中心配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
SOCKS5 SOCKS5Config `mapstructure:"socks5"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
Server ServerConfig `mapstructure:"server"`
SOCKS5 SOCKS5Config `mapstructure:"socks5"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Logging LoggingConfig `mapstructure:"logging"`
Logging LoggingConfig `mapstructure:"logging"`
Install InstallConfig `mapstructure:"install"`
}
type ServerConfig struct {
@@ -30,20 +31,26 @@ type SOCKS5Config struct {
}
type DatabaseConfig struct {
Type string `mapstructure:"type"` // sqlite, postgres
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
SSLMode string `mapstructure:"sslmode"`
Path string `mapstructure:"path"` // SQLite 数据库文件路径
}
func (c DatabaseConfig) DSN() string {
if c.Type == "sqlite" {
return c.Path
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
c.Host, c.Port, c.User, c.Password, c.Database, c.SSLMode)
}
type RedisConfig struct {
Enabled bool `mapstructure:"enabled"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
@@ -55,10 +62,10 @@ func (c RedisConfig) Addr() string {
}
type SchedulerConfig struct {
Strategy string `mapstructure:"strategy"`
HealthCheckInterval int `mapstructure:"health_check_interval"`
UnlockCheckInterval int `mapstructure:"unlock_check_interval"`
NodeTimeout int `mapstructure:"node_timeout"`
Strategy string `mapstructure:"strategy"`
HealthCheckInterval int `mapstructure:"health_check_interval"`
UnlockCheckInterval int `mapstructure:"unlock_check_interval"`
NodeTimeout int `mapstructure:"node_timeout"`
}
type LoggingConfig struct {
@@ -67,6 +74,10 @@ type LoggingConfig struct {
File string `mapstructure:"file"`
}
type InstallConfig struct {
Installed bool `mapstructure:"installed"`
}
// Load 加载配置
func Load(configPath string) (*Config, error) {
viper.SetConfigFile(configPath)
@@ -76,6 +87,10 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.mode", "release")
viper.SetDefault("database.type", "sqlite")
viper.SetDefault("database.path", "data/proxy.db")
viper.SetDefault("redis.enabled", false)
viper.SetDefault("install.installed", false)
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
@@ -89,15 +104,31 @@ func Load(configPath string) (*Config, error) {
return &config, nil
}
// Save 保存配置
func Save(configPath string, cfg *Config) error {
viper.Set("server", cfg.Server)
viper.Set("socks5", cfg.SOCKS5)
viper.Set("database", cfg.Database)
viper.Set("redis", cfg.Redis)
viper.Set("scheduler", cfg.Scheduler)
viper.Set("logging", cfg.Logging)
viper.Set("install", cfg.Install)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
return nil
}
// AgentConfig 节点 Agent 配置
type AgentConfig struct {
Agent AgentSettings `mapstructure:"agent"`
Scheduler SchedulerConn `mapstructure:"scheduler"`
WARP WARPConfig `mapstructure:"warp"`
SOCKS5 SOCKS5Config `mapstructure:"socks5"`
Unlock UnlockConfig `mapstructure:"unlock"`
Routing RoutingConfig `mapstructure:"routing"`
Logging LoggingConfig `mapstructure:"logging"`
Agent AgentSettings `mapstructure:"agent"`
Scheduler SchedulerConn `mapstructure:"scheduler"`
WARP WARPConfig `mapstructure:"warp"`
SOCKS5 SOCKS5Config `mapstructure:"socks5"`
Unlock UnlockConfig `mapstructure:"unlock"`
Routing RoutingConfig `mapstructure:"routing"`
Logging LoggingConfig `mapstructure:"logging"`
}
type AgentSettings struct {
@@ -107,10 +138,10 @@ type AgentSettings struct {
}
type SchedulerConn struct {
Host string `mapstructure:"host"`
APIKey string `mapstructure:"api_key"`
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
ReportInterval int `mapstructure:"report_interval"`
Host string `mapstructure:"host"`
APIKey string `mapstructure:"api_key"`
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
ReportInterval int `mapstructure:"report_interval"`
}
type WARPConfig struct {
@@ -128,10 +159,10 @@ type UnlockConfig struct {
}
type ServiceConfig struct {
Name string `mapstructure:"name"`
URL string `mapstructure:"url"`
Name string `mapstructure:"name"`
URL string `mapstructure:"url"`
SuccessKeywords []string `mapstructure:"success_keywords"`
FailKeywords []string `mapstructure:"fail_keywords"`
FailKeywords []string `mapstructure:"fail_keywords"`
}
type RoutingConfig struct {
+264
View File
@@ -0,0 +1,264 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"proxy-platform/internal/config"
"proxy-platform/internal/models"
"proxy-platform/internal/repository"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// CheckInstall 检查安装状态
func CheckInstall() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查数据库是否存在安装记录
dbPath := "data/proxy.db"
installed := false
if _, err := os.Stat(dbPath); err == nil {
// 数据库文件存在,检查安装状态
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err == nil {
var status models.InstallStatus
if result := db.First(&status); result.Error == nil {
installed = status.Installed
}
sqlDB, _ := db.DB()
sqlDB.Close()
}
}
c.JSON(http.StatusOK, gin.H{
"installed": installed,
})
}
}
// InstallRequest 安装请求
type InstallRequest struct {
DbType string `json:"db_type" binding:"required"` // sqlite, postgres
AdminUser string `json:"admin_user" binding:"required"`
AdminPass string `json:"admin_pass" binding:"required"`
PlatformName string `json:"platform_name"`
// SQLite 配置
SqlitePath string `json:"sqlite_path"`
// PostgreSQL 配置(可选)
PgHost string `json:"pg_host"`
PgPort int `json:"pg_port"`
PgUser string `json:"pg_user"`
PgPassword string `json:"pg_password"`
PgDatabase string `json:"pg_database"`
// Redis 配置(可选)
RedisEnabled bool `json:"redis_enabled"`
RedisHost string `json:"redis_host"`
RedisPort int `json:"redis_port"`
RedisPassword string `json:"redis_password"`
RedisDB int `json:"redis_db"`
}
// DoInstall 执行安装
func DoInstall(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var req InstallRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证必填项
if req.AdminUser == "" || req.AdminPass == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
return
}
if req.DbType != "sqlite" && req.DbType != "postgres" {
c.JSON(http.StatusBadRequest, gin.H{"error": "数据库类型只能是 sqlite 或 postgres"})
return
}
if req.DbType == "postgres" {
if req.PgHost == "" || req.PgDatabase == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "PostgreSQL 配置不完整"})
return
}
}
// 确定数据库路径
dbPath := req.SqlitePath
if dbPath == "" {
dbPath = "data/proxy.db"
}
// 创建数据目录
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建数据目录失败: " + err.Error()})
return
}
// 初始化数据库
var db *gorm.DB
var err error
if req.DbType == "sqlite" {
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
} else {
// PostgreSQL 支持(需要额外配置 postgres driver
c.JSON(http.StatusBadRequest, gin.H{"error": "PostgreSQL 支持需要额外配置,请使用 SQLite"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据库连接失败: " + err.Error()})
return
}
// 自动迁移
if err := db.AutoMigrate(
&models.User{},
&models.Node{},
&models.NodeGroup{},
&models.UnlockStatus{},
&models.IPChangeLog{},
&models.ConnectionLog{},
&models.IPRefreshRule{},
&models.InstallStatus{},
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据库迁移失败: " + err.Error()})
return
}
// 创建管理员用户
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPass), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
adminUser := &models.User{
Username: req.AdminUser,
PasswordHash: string(hashedPassword),
Status: "active",
TrafficQuota: 0, // 无限制
}
userRepo := repository.NewUserRepository(db)
if err := userRepo.Create(adminUser); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建管理员失败: " + err.Error()})
return
}
// 创建安装状态记录
platformName := req.PlatformName
if platformName == "" {
platformName = "代理管理平台"
}
installStatus := &models.InstallStatus{
Installed: true,
DbType: req.DbType,
RedisEnabled: req.RedisEnabled,
AdminUser: req.AdminUser,
PlatformName: platformName,
}
if err := db.Create(installStatus).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存安装状态失败: " + err.Error()})
return
}
// 生成配置文件
cfg := &config.Config{
Server: config.ServerConfig{
Host: "0.0.0.0",
Port: 8080,
Mode: "release",
},
SOCKS5: config.SOCKS5Config{
Host: "0.0.0.0",
Port: 1080,
MaxConnections: 10000,
Timeout: 30,
},
Database: config.DatabaseConfig{
Type: req.DbType,
Path: dbPath,
},
Redis: config.RedisConfig{
Enabled: req.RedisEnabled,
Host: req.RedisHost,
Port: req.RedisPort,
Password: req.RedisPassword,
DB: req.RedisDB,
},
Scheduler: config.SchedulerConfig{
Strategy: "least_latency",
HealthCheckInterval: 60,
UnlockCheckInterval: 300,
NodeTimeout: 120,
},
Logging: config.LoggingConfig{
Level: "info",
Output: "stdout",
},
Install: config.InstallConfig{
Installed: true,
},
}
// 保存配置文件
configPath := "configs/scheduler.yaml"
if err := config.Save(configPath, cfg); err != nil {
logger.Error("保存配置文件失败", zap.Error(err))
}
// 关闭数据库连接
sqlDB, _ := db.DB()
sqlDB.Close()
logger.Info("安装完成",
zap.String("db_type", req.DbType),
zap.Bool("redis_enabled", req.RedisEnabled),
zap.String("admin_user", req.AdminUser),
)
c.JSON(http.StatusOK, gin.H{
"message": "安装成功",
"db_type": req.DbType,
"redis_enabled": req.RedisEnabled,
"admin_user": req.AdminUser,
})
}
}
// GetInstallStatus 获取安装状态(从数据库)
func GetInstallStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var status models.InstallStatus
if result := db.First(&status); result.Error != nil {
c.JSON(http.StatusOK, gin.H{
"installed": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"installed": status.Installed,
"db_type": status.DbType,
"redis_enabled": status.RedisEnabled,
"platform_name": status.PlatformName,
"admin_user": status.AdminUser,
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package models
import (
"time"
)
// InstallStatus 安装状态
type InstallStatus struct {
ID uint `gorm:"primaryKey" json:"id"`
Installed bool `gorm:"default:false" json:"installed"`
DbType string `gorm:"type:varchar(20);default:'sqlite'" json:"db_type"` // sqlite, postgres
RedisEnabled bool `gorm:"default:false" json:"redis_enabled"`
AdminUser string `gorm:"size:64" json:"admin_user"`
PlatformName string `gorm:"size:128;default:'代理管理平台'" json:"platform_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名
func (InstallStatus) TableName() string {
return "install_status"
}
Executable
BIN
View File
Binary file not shown.
+4849
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -4,34 +4,34 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.5"
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-echarts": "^6.6.5",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"@vue/tsconfig": "^0.5.1",
"typescript": "~5.3.0",
"vite": "^5.0.10",
"vue-tsc": "^1.8.25",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"eslint-plugin-vue": "^9.19.2",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.55.0",
"eslint-plugin-vue": "^9.19.2",
"sass": "^1.69.5",
"typescript": "~5.3.0",
"unplugin-auto-import": "^0.17.2",
"unplugin-vue-components": "^0.26.0",
"sass": "^1.69.5"
"vite": "^5.0.10",
"vue-tsc": "^1.8.25"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#409eff"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-size="16" font-weight="bold">P</text>
</svg>

After

Width:  |  Height:  |  Size: 222 B

+87
View File
@@ -0,0 +1,87 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+4 -6
View File
@@ -92,8 +92,6 @@ const handleLogout = () => {
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.layout-container {
height: 100vh;
}
@@ -105,7 +103,7 @@ const handleLogout = () => {
}
.logo {
height: $header-height;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
@@ -146,12 +144,12 @@ const handleLogout = () => {
.layout-header {
background-color: #fff;
border-bottom: 1px solid $border-color;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
box-shadow: $shadow-light;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.header-left {
@@ -183,7 +181,7 @@ const handleLogout = () => {
}
.layout-main {
background-color: $bg-color;
background-color: #f5f7fa;
padding: 20px;
overflow-y: auto;
}
+13 -1
View File
@@ -3,6 +3,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes: RouteRecordRaw[] = [
{
path: '/install',
name: 'Install',
component: () => import('@/views/install/index.vue'),
meta: { title: '安装向导', requiresAuth: false },
},
{
path: '/login',
name: 'Login',
@@ -60,12 +66,18 @@ const router = createRouter({
})
// 路由守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 代理管理平台` : '代理管理平台'
// 安装页面不需要检查登录
if (to.path === '/install') {
next()
return
}
// 检查是否需要登录
if (to.meta.requiresAuth !== false && !userStore.isLoggedIn) {
next({ name: 'Login', query: { redirect: to.fullPath } })
+6 -6
View File
@@ -36,13 +36,13 @@ body {
// Element Plus 自定义
.el-table {
--el-table-border-color: #{$border-color};
--el-table-header-bg-color: #{$bg-color};
--el-table-border-color: #e4e7ed;
--el-table-header-bg-color: #f5f7fa;
}
.el-card {
border: none;
box-shadow: $shadow;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
// 通用样式
@@ -56,7 +56,7 @@ body {
.page-title {
font-size: 20px;
font-weight: 600;
color: $text-primary;
color: #303133;
}
.card-stat {
@@ -64,11 +64,11 @@ body {
.stat-value {
font-size: 28px;
font-weight: 600;
color: $primary-color;
color: #409eff;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
color: #909399;
}
}
+6 -31
View File
@@ -1,32 +1,7 @@
// 变量定义
$primary-color: #409eff;
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
$text-primary: #303133;
$text-regular: #606266;
$text-secondary: #909399;
$text-placeholder: #c0c4cc;
$border-color: #ebeef5;
$border-color-light: #e4e7ed;
$border-color-lighter: #ebeef5;
$border-color-extra-light: #f2f6fc;
$bg-color: #f5f7fa;
$bg-color-light: #fafafa;
$shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
$shadow-light: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
$shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
$radius: 4px;
$radius-medium: 6px;
$radius-large: 8px;
$transition: all 0.3s;
$menu-width: 210px;
$header-height: 60px;
$bg-color: #f5f7fa;
$border-color: #e4e7ed;
$shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
$text-primary: #303133;
$text-secondary: #909399;
$primary-color: #409eff;
+3 -3
View File
@@ -245,7 +245,7 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.dashboard {
.stat-row {
@@ -277,12 +277,12 @@ onMounted(() => {
.stat-value {
font-size: 24px;
font-weight: 600;
color: $text-primary;
color: #303133;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
color: #909399;
margin-top: 4px;
}
}
+313
View File
@@ -0,0 +1,313 @@
<template>
<div class="install-container">
<el-card class="install-card">
<template #header>
<div class="card-header">
<h2>{{ form.platform_name || '代理管理平台' }}</h2>
<p>安装向导 - Installation Wizard</p>
</div>
</template>
<el-steps :active="currentStep" finish-status="success" simple>
<el-step title="数据库配置" />
<el-step title="管理员设置" />
<el-step title="完成安装" />
</el-steps>
<div class="step-content" v-show="currentStep === 0">
<el-form :model="form" label-width="150px">
<el-divider content-position="left">数据库配置</el-divider>
<el-form-item label="数据库类型">
<el-radio-group v-model="form.db_type">
<el-radio value="sqlite">
<el-icon><Database /></el-icon>
SQLite推荐
</el-radio>
<el-radio value="postgres">
PostgreSQL
</el-radio>
</el-radio-group>
<div class="form-tip">
SQLite 无需额外配置适合大多数场景PostgreSQL 适合大规模部署
</div>
</el-form-item>
<template v-if="form.db_type === 'sqlite'">
<el-form-item label="数据文件路径">
<el-input v-model="form.sqlite_path" placeholder="data/proxy.db">
<template #prepend>./</template>
</el-input>
<div class="form-tip">默认存储在 data 目录下</div>
</el-form-item>
</template>
<template v-if="form.db_type === 'postgres'">
<el-form-item label="主机地址">
<el-input v-model="form.pg_host" placeholder="localhost" />
</el-form-item>
<el-form-item label="端口">
<el-input-number v-model="form.pg_port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.pg_user" placeholder="postgres" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.pg_password" type="password" show-password />
</el-form-item>
<el-form-item label="数据库">
<el-input v-model="form.pg_database" placeholder="proxy_platform" />
</el-form-item>
</template>
<el-divider content-position="left">Redis 缓存可选</el-divider>
<el-form-item label="启用 Redis">
<el-switch v-model="form.redis_enabled" />
<span class="switch-label">{{ form.redis_enabled ? '已启用' : '未启用' }}</span>
</el-form-item>
<template v-if="form.redis_enabled">
<el-form-item label="Redis 主机">
<el-input v-model="form.redis_host" placeholder="localhost" />
</el-form-item>
<el-form-item label="Redis 端口">
<el-input-number v-model="form.redis_port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="Redis 密码">
<el-input v-model="form.redis_password" type="password" show-password placeholder="无密码请留空" />
</el-form-item>
<el-form-item label="Redis DB">
<el-input-number v-model="form.redis_db" :min="0" :max="15" />
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="nextStep">下一步</el-button>
</el-form-item>
</el-form>
</div>
<div class="step-content" v-show="currentStep === 1">
<el-form :model="form" label-width="150px">
<el-divider content-position="left">管理员设置</el-divider>
<el-form-item label="平台名称">
<el-input v-model="form.platform_name" placeholder="代理管理平台" />
</el-form-item>
<el-form-item label="管理员用户名">
<el-input v-model="form.admin_user" placeholder="admin" />
</el-form-item>
<el-form-item label="管理员密码">
<el-input v-model="form.admin_pass" type="password" show-password placeholder="请输入密码" />
<div class="form-tip">密码长度至少 6 </div>
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="form.admin_pass_confirm" type="password" show-password placeholder="请再次输入密码" />
</el-form-item>
<el-form-item>
<el-button @click="prevStep">上一步</el-button>
<el-button type="primary" @click="checkAndInstall" :loading="installing">
开始安装
</el-button>
</el-form-item>
</el-form>
</div>
<div class="step-content" v-show="currentStep === 2">
<div class="install-success">
<el-icon :size="80" color="#67C23A"><SuccessFilled /></el-icon>
<h3>安装成功</h3>
<p>您的代理管理平台已经完成安装</p>
<el-descriptions :column="1" border>
<el-descriptions-item label="数据库类型">{{ form.db_type }}</el-descriptions-item>
<el-descriptions-item label="Redis 缓存">{{ form.redis_enabled ? '已启用' : '未启用' }}</el-descriptions-item>
<el-descriptions-item label="管理员">{{ form.admin_user }}</el-descriptions-item>
</el-descriptions>
<el-button type="primary" size="large" @click="goToLogin" class="login-btn">
进入登录页面
</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Database, SuccessFilled } from '@element-plus/icons-vue'
import axios from 'axios'
const router = useRouter()
const currentStep = ref(0)
const installing = ref(false)
const form = reactive({
db_type: 'sqlite',
sqlite_path: 'data/proxy.db',
pg_host: 'localhost',
pg_port: 5432,
pg_user: 'postgres',
pg_password: '',
pg_database: 'proxy_platform',
redis_enabled: false,
redis_host: 'localhost',
redis_port: 6379,
redis_password: '',
redis_db: 0,
platform_name: '代理管理平台',
admin_user: 'admin',
admin_pass: '',
admin_pass_confirm: '',
})
const nextStep = () => {
currentStep.value++
}
const prevStep = () => {
currentStep.value--
}
const checkAndInstall = async () => {
// 验证表单
if (!form.admin_user) {
ElMessage.error('请输入管理员用户名')
return
}
if (!form.admin_pass || form.admin_pass.length < 6) {
ElMessage.error('密码长度至少 6 位')
return
}
if (form.admin_pass !== form.admin_pass_confirm) {
ElMessage.error('两次输入的密码不一致')
return
}
if (form.db_type === 'postgres') {
if (!form.pg_host || !form.pg_database) {
ElMessage.error('PostgreSQL 配置不完整')
return
}
}
installing.value = true
try {
const res = await axios.post('/api/v1/install', form)
if (res.data.message) {
ElMessage.success(res.data.message)
currentStep.value = 2
}
} catch (error: any) {
const errorMsg = error.response?.data?.error || '安装失败'
ElMessage.error(errorMsg)
} finally {
installing.value = false
}
}
const goToLogin = () => {
router.push('/login')
}
// 检查是否已安装
onMounted(async () => {
try {
const res = await axios.get('/api/v1/install/check')
if (res.data.installed) {
ElMessage.info('系统已安装,跳转到登录页面')
router.push('/login')
}
} catch (error) {
// 未安装,继续显示安装页面
}
})
</script>
<style lang="scss" scoped>
.install-container {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.install-card {
width: 600px;
max-height: 90vh;
overflow-y: auto;
border-radius: 8px;
}
.card-header {
text-align: center;
h2 {
margin: 0;
color: #303133;
font-size: 24px;
}
p {
margin: 8px 0 0;
color: #909399;
font-size: 14px;
}
}
.step-content {
padding: 20px 40px;
}
.form-tip {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
.switch-label {
margin-left: 12px;
color: #606266;
}
.install-success {
text-align: center;
padding: 40px 0;
h3 {
margin: 20px 0 10px;
color: #67C23A;
}
p {
color: #909399;
margin-bottom: 20px;
}
.el-descriptions {
margin: 20px auto;
max-width: 300px;
}
.login-btn {
margin-top: 30px;
width: 200px;
}
}
:deep(.el-radio__label) {
display: flex;
align-items: center;
gap: 8px;
}
</style>